Compare commits
21
Commits
34ebf1abb9
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca787985f8 | ||
|
|
0b0f207423 | ||
|
|
2b8c98ba1f | ||
|
|
82e20e0cb5 | ||
|
|
2421cdc273 | ||
|
|
f35e3ddfe8 | ||
|
|
0dc26041dc | ||
|
|
c80753c56e | ||
|
|
26f4abef7f | ||
|
|
f35cbd01cf | ||
|
|
70a8ea1b92 | ||
|
|
432097ee3f | ||
|
|
5d6f6ffaf9 | ||
|
|
bff65f4e2f | ||
|
|
ee9ba08a8a | ||
|
|
0797e5def2 | ||
|
|
e77dfb6891 | ||
|
|
74bdc6a449 | ||
|
|
808e69f442 | ||
|
|
6ec52e5b87 | ||
|
|
cff385af41 |
@@ -61,6 +61,10 @@ linters:
|
|||||||
desc: >-
|
desc: >-
|
||||||
Test-support code belongs in test files and in packages whose
|
Test-support code belongs in test files and in packages whose
|
||||||
directory name ends in test, not in the shipped binary.
|
directory name ends in test, not in the shipped binary.
|
||||||
|
- pkg: sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest
|
||||||
|
desc: >-
|
||||||
|
Test-support code belongs in test files and in packages whose
|
||||||
|
directory name ends in test, not in the shipped binary.
|
||||||
# Only decisions already recorded in the Go package defaults are
|
# Only decisions already recorded in the Go package defaults are
|
||||||
# listed here. Every entry matches the module path exactly.
|
# listed here. Every entry matches the module path exactly.
|
||||||
gomodguard_v2:
|
gomodguard_v2:
|
||||||
|
|||||||
+29
-1
@@ -29,6 +29,12 @@ RUN go mod download
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# go.mod and go.sum must be as `go mod tidy` writes them, which is what
|
||||||
|
# `make tidy` does. Checked before the tests, which a missing go.sum line
|
||||||
|
# fails with a message that does not name `make tidy`.
|
||||||
|
RUN go mod tidy -diff || \
|
||||||
|
{ echo "go.mod or go.sum is not tidy: run make tidy" >&2; exit 1; }
|
||||||
|
|
||||||
# Go's build cache is kept on a tmpfs, out of the image: nothing uses it
|
# Go's build cache is kept on a tmpfs, out of the image: nothing uses it
|
||||||
# after this step, and writing it into the image takes seconds.
|
# after this step, and writing it into the image takes seconds.
|
||||||
RUN --mount=type=tmpfs,target=/root/.cache/go-build \
|
RUN --mount=type=tmpfs,target=/root/.cache/go-build \
|
||||||
@@ -36,7 +42,25 @@ RUN --mount=type=tmpfs,target=/root/.cache/go-build \
|
|||||||
{ echo "--- Rerunning with -v for details ---"; \
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
go test -timeout 90s -race -v ./...; exit 1; }
|
go test -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
# Build stage. Nothing is wanted from the two phases above; the copies
|
# Tidy stage: `go mod tidy` in the test phase's Go, so that the files it
|
||||||
|
# writes pass the test phase's check. Nothing else depends on it, so only
|
||||||
|
# script/tidy, which names the stage after it, builds it.
|
||||||
|
#
|
||||||
|
# golang 1.27.1-trixie, 2026-09-19
|
||||||
|
FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 AS tidy
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN go mod tidy
|
||||||
|
|
||||||
|
# go.mod and go.sum alone, which script/tidy writes into the working tree.
|
||||||
|
FROM scratch AS tidy-files
|
||||||
|
|
||||||
|
COPY --from=tidy /src/go.mod /src/go.sum /
|
||||||
|
|
||||||
|
# Build stage. Nothing is wanted from the lint and test phases; the copies
|
||||||
# are what make BuildKit build them first, so the image, which needs this
|
# are what make BuildKit build them first, so the image, which needs this
|
||||||
# stage, cannot be produced unless lint and test passed.
|
# stage, cannot be produced unless lint and test passed.
|
||||||
#
|
#
|
||||||
@@ -167,6 +191,10 @@ RUN groupadd --system --gid 65532 smallwebwaf \
|
|||||||
# smallwebwaf user at each start.
|
# smallwebwaf user at each start.
|
||||||
RUN mkdir /var/lib/smallwebwaf
|
RUN mkdir /var/lib/smallwebwaf
|
||||||
|
|
||||||
|
# The default rule file, in SWWAF_RULES_DIR by default, where an app's
|
||||||
|
# Dockerfile can copy rule files of its own beside it.
|
||||||
|
COPY share/rules.d/00-default.rules /etc/smallwebwaf/rules.d/00-default.rules
|
||||||
|
|
||||||
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
|
# 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/smallwebwaf.run /etc/service/smallwebwaf/run
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks build run \
|
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
||||||
example-app
|
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). build and run are for working on the code by hand;
|
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
||||||
|
# which test checks. build and run are for working on the code by hand;
|
||||||
# example-app checks the image with an app built on it.
|
# example-app checks the image with an app built on it.
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@@ -24,6 +25,9 @@ fmt:
|
|||||||
fmt-check:
|
fmt-check:
|
||||||
@script/fmt-check
|
@script/fmt-check
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
@script/tidy
|
||||||
|
|
||||||
check:
|
check:
|
||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from a directory of hand-editable text files.
|
|||||||
- Defence against traffic floods that saturate the host's network link. That
|
- Defence against traffic floods that saturate the host's network link. That
|
||||||
needs help upstream of the host.
|
needs help upstream of the host.
|
||||||
- A web UI or a configuration file. Settings are environment variables. Apart
|
- A web UI or a configuration file. Settings are environment variables. Apart
|
||||||
from settings given as files (the `_FILE` form of any setting, such as
|
from settings given as files (the `_FILE` form of a setting, such as
|
||||||
`SWWAF_ADMIN_TOKEN_FILE`, and `SWWAF_LOG_REMOTE_TLS_CA_FILE`), its own state
|
`SWWAF_ADMIN_TOKEN_FILE`, and `SWWAF_LOG_REMOTE_TLS_CA_FILE`), its own state
|
||||||
files and the lookup database, the only files read are the rule files, which
|
files and the lookup database, the only files read are the rule files, which
|
||||||
hold one regex per line and nothing more elaborate.
|
hold one regex per line and nothing more elaborate.
|
||||||
@@ -298,7 +298,8 @@ it.
|
|||||||
- A list set to an empty value is an empty list, and replaces the default.
|
- A list set to an empty value is an empty list, and replaces the default.
|
||||||
- Every setting may instead be given as a file holding the value, named by the
|
- Every setting may instead be given as a file holding the value, named by the
|
||||||
setting's name with `_FILE` added, such as `SWWAF_ADMIN_TOKEN_FILE`, for
|
setting's name with `_FILE` added, such as `SWWAF_ADMIN_TOKEN_FILE`, for
|
||||||
secrets and long lists.
|
secrets and long lists. `SWWAF_LOG_REMOTE_TLS_CA_FILE`, whose value names a
|
||||||
|
file already, has no `_FILE` form.
|
||||||
- Settings, including those given as files, are read once at start; changing one
|
- Settings, including those given as files, are read once at start; changing one
|
||||||
means restarting the container. The files `smallwebwaf` watches while it runs
|
means restarting the container. The files `smallwebwaf` watches while it runs
|
||||||
are its state files, its rule files and the lookup database.
|
are its state files, its rule files and the lookup database.
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ module sneak.berlin/go/smallwebwaf
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
|
github.com/maxmind/mmdbwriter v1.2.0
|
||||||
|
github.com/oschwald/maxminddb-golang/v2 v2.7.0
|
||||||
github.com/prometheus/client_golang v1.24.1
|
github.com/prometheus/client_golang v1.24.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,6 +18,7 @@ require (
|
|||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.70.1 // indirect
|
github.com/prometheus/common v0.70.1 // indirect
|
||||||
github.com/prometheus/procfs v0.21.1 // indirect
|
github.com/prometheus/procfs v0.21.1 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
|||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
@@ -12,10 +12,12 @@ github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJn
|
|||||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/maxmind/mmdbwriter v1.2.0 h1:hyvDopImmgvle3aR8AaddxXnT0iQH2KWJX3vNfkwzYM=
|
||||||
|
github.com/maxmind/mmdbwriter v1.2.0/go.mod h1:EQmKHhk2y9DRVvyNxwCLKC5FrkXZLx4snc5OlLY5XLE=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/oschwald/maxminddb-golang/v2 v2.7.0 h1:ZcAr3GYc2LYC8aec2mCMX9+QOF0EolH3jDFKRV/Z1+U=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/oschwald/maxminddb-golang/v2 v2.7.0/go.mod h1:DuKJLbbug6TXC0yJXgs1MWifvXHmudRWzMobMIUu04g=
|
||||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
@@ -24,15 +26,17 @@ github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi
|
|||||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
|
|||||||
@@ -0,0 +1,930 @@
|
|||||||
|
// Package alerts sends alerts on bans, on traffic over an anomaly
|
||||||
|
// threshold, on a source that fails and on a file with an error to each
|
||||||
|
// destination set: to the webhook
|
||||||
|
// SWWAF_ALERT_WEBHOOK_URL names, each as one JSON object, as the "Alert
|
||||||
|
// webhook schema" section of SPEC.md describes, to the Slack incoming
|
||||||
|
// webhook SWWAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
||||||
|
// ntfy topic SWWAF_ALERT_NTFY_URL names. A repeat within
|
||||||
|
// SWWAF_ALERT_COOLDOWN is held back, and so is an alert past
|
||||||
|
// SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
||||||
|
// bounded queue of each destination's own, so that a destination that is
|
||||||
|
// slow or unreachable holds up neither the others nor any request. The
|
||||||
|
// state is written to alerts.json and read from it by the state package.
|
||||||
|
// Nothing logged names a destination's URL, whose path or query can carry
|
||||||
|
// a secret.
|
||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The events an alert is for, as SWWAF_ALERT_EVENTS names them.
|
||||||
|
const (
|
||||||
|
// EventBan is a ban smallwebwaf made.
|
||||||
|
EventBan = "ban"
|
||||||
|
// EventPermanentBan is a permanent ban smallwebwaf made, or a ban it
|
||||||
|
// made permanent.
|
||||||
|
EventPermanentBan = "permanent_ban"
|
||||||
|
// EventAnomaly is a count of requests or bytes over an anomaly
|
||||||
|
// threshold.
|
||||||
|
EventAnomaly = "anomaly"
|
||||||
|
// EventWAFBlock comes with the Core Rule Set; nothing raises it yet.
|
||||||
|
EventWAFBlock = "waf_block"
|
||||||
|
// EventReputationHit is a request whose client a blocklist or a DNSBL
|
||||||
|
// zone lists, or whose AbuseIPDB score is a hit.
|
||||||
|
EventReputationHit = "reputation_hit"
|
||||||
|
// EventSourceFailure is GeoJS failing or refusing smallwebwaf, a fetch
|
||||||
|
// of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
|
||||||
|
// failing or refused, or the day's AbuseIPDB checks used up.
|
||||||
|
EventSourceFailure = "source_failure"
|
||||||
|
// EventFileError is a rule file or state file edited while smallwebwaf
|
||||||
|
// runs that does not parse, a replacement of the lookup database that
|
||||||
|
// cannot be read, or a state file that cannot be written.
|
||||||
|
EventFileError = "file_error"
|
||||||
|
// EventSummary is the summary sent as an hour ends: of the alerts held
|
||||||
|
// back in it past SWWAF_ALERT_MAX_PER_HOUR, and of the repeats held
|
||||||
|
// back by the cooldowns dropped as it ends, which no alert let through
|
||||||
|
// has given. It is sent with SWWAF_ALERT_MAX_PER_HOUR off too, for
|
||||||
|
// those repeats. SWWAF_ALERT_EVENTS does not name it.
|
||||||
|
EventSummary = "summary"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Events returns every event SWWAF_ALERT_EVENTS can name, which is its
|
||||||
|
// default.
|
||||||
|
func Events() []string {
|
||||||
|
return []string{
|
||||||
|
EventBan, EventPermanentBan, EventWAFBlock, EventAnomaly,
|
||||||
|
EventReputationHit, EventSourceFailure, EventFileError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The destinations alerts are sent to, as the metrics and alerts.json
|
||||||
|
// name them.
|
||||||
|
const (
|
||||||
|
// DestinationWebhook is the webhook SWWAF_ALERT_WEBHOOK_URL names.
|
||||||
|
DestinationWebhook = "webhook"
|
||||||
|
// DestinationSlack is the Slack incoming webhook
|
||||||
|
// SWWAF_ALERT_SLACK_WEBHOOK_URL names.
|
||||||
|
DestinationSlack = "slack"
|
||||||
|
// DestinationNtfy is the ntfy topic SWWAF_ALERT_NTFY_URL names.
|
||||||
|
DestinationNtfy = "ntfy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Destinations returns every destination alerts can be sent to.
|
||||||
|
func Destinations() []string {
|
||||||
|
return []string{DestinationWebhook, DestinationSlack, DestinationNtfy}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// queueSize is the most alerts that wait to be sent to a destination.
|
||||||
|
// Past it, the oldest is dropped.
|
||||||
|
queueSize = 1000
|
||||||
|
// sendTimeout bounds one request to a destination.
|
||||||
|
sendTimeout = 10 * time.Second
|
||||||
|
// After a request to a destination fails, the alert is sent again a
|
||||||
|
// second later, and retryDelayFactor times as long after each further
|
||||||
|
// failure in a row, up to a minute.
|
||||||
|
firstRetryDelay = time.Second
|
||||||
|
retryDelayFactor = 2
|
||||||
|
maxRetryDelay = time.Minute
|
||||||
|
// maxAnswerBytes is the most of a destination's answer that is read.
|
||||||
|
maxAnswerBytes = 64 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errStatus = errors.New("the destination answered")
|
||||||
|
// errRefused is a 4xx answer other than 408 and 429: the destination
|
||||||
|
// refuses the alert itself, and would refuse it again.
|
||||||
|
errRefused = errors.New("the destination refused the alert, answering")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Params are what New needs. With none of WebhookURL, SlackURL and
|
||||||
|
// NtfyURL set, no alert is sent.
|
||||||
|
type Params struct {
|
||||||
|
// WebhookURL is where each alert is posted as JSON
|
||||||
|
// (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
||||||
|
// are sent with each (SWWAF_ALERT_WEBHOOK_HEADERS).
|
||||||
|
WebhookURL *url.URL
|
||||||
|
WebhookHeaders http.Header
|
||||||
|
// SlackURL is the Slack incoming webhook each alert is posted to as a
|
||||||
|
// message (SWWAF_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
||||||
|
SlackURL *url.URL
|
||||||
|
// NtfyURL is the ntfy topic each alert is published to
|
||||||
|
// (SWWAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
||||||
|
// empty, is sent with each as a bearer token (SWWAF_ALERT_NTFY_TOKEN).
|
||||||
|
NtfyURL *url.URL
|
||||||
|
NtfyToken string
|
||||||
|
// Events are the events alerts are sent for (SWWAF_ALERT_EVENTS).
|
||||||
|
Events []string
|
||||||
|
// Cooldown is how long a repeat of an alert is held back
|
||||||
|
// (SWWAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
||||||
|
// sent in an hour (SWWAF_ALERT_MAX_PER_HOUR), 0 for no limit.
|
||||||
|
Cooldown time.Duration
|
||||||
|
MaxPerHour int
|
||||||
|
// Instance is SWWAF_INSTANCE_NAME, which every alert gives.
|
||||||
|
Instance string
|
||||||
|
// Now tells the time of an alert, normally time.Now in UTC.
|
||||||
|
Now func() time.Time
|
||||||
|
// ProcessLog receives the requests to a destination that fail.
|
||||||
|
ProcessLog *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
||||||
|
// with the fields of the "Alert webhook schema" section of SPEC.md. ASN,
|
||||||
|
// ASName and Country are, for a ban, the client's as the ban's notes give
|
||||||
|
// them.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // SPEC.md's alert webhook schema names its fields in snake_case
|
||||||
|
type Alert struct {
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
Client netip.Addr `json:"client"`
|
||||||
|
Netblock netip.Prefix `json:"netblock"`
|
||||||
|
ASN string `json:"asn"`
|
||||||
|
ASName string `json:"as_name"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
// Reason is a short sentence, and Detail what is particular to the
|
||||||
|
// event: for a file_error, its "file", for a source_failure, its
|
||||||
|
// "source", and for an anomaly, its "scope", with the "asn" or the
|
||||||
|
// "name" of some scopes, which the cooldown tells repeats by.
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Detail map[string]any `json:"detail"`
|
||||||
|
// SuppressedRepeats is how many repeats of the alert the cooldown
|
||||||
|
// held back since the last one let through. For a summary, it is how
|
||||||
|
// many the cooldowns dropped as the hour ended had held back that no
|
||||||
|
// alert let through gave.
|
||||||
|
SuppressedRepeats int `json:"suppressed_repeats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cooldown is, for an event on a netblock, about a file or a source, or
|
||||||
|
// for an anomaly in a scope, when the last alert let through was raised,
|
||||||
|
// and how many repeats the cooldown has held back since, as alerts.json
|
||||||
|
// holds it.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||||
|
type Cooldown struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
Netblock netip.Prefix `json:"netblock"`
|
||||||
|
File string `json:"file,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
ASN string `json:"asn,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Sent time.Time `json:"sent"`
|
||||||
|
SuppressedRepeats int `json:"suppressed_repeats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hour is the hour under way, by the clock, as alerts.json holds it: when
|
||||||
|
// it started, how many alerts were let through in it, and how many were
|
||||||
|
// held back in it past MaxPerHour, by event, for its summary.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||||
|
type Hour struct {
|
||||||
|
Start time.Time `json:"start"`
|
||||||
|
Sent int `json:"sent"`
|
||||||
|
HeldBack map[string]int `json:"held_back"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is what alerts.json holds: the cooldowns, the hour under way, and
|
||||||
|
// for each destination set, the alerts waiting to be sent to it, oldest
|
||||||
|
// first.
|
||||||
|
type State struct {
|
||||||
|
Cooldowns []Cooldown `json:"cooldowns"`
|
||||||
|
Hour Hour `json:"hour"`
|
||||||
|
Waiting map[string][]Alert `json:"waiting"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts are, for a destination, how many alerts it took, how many
|
||||||
|
// requests to it failed, and how many alerts were dropped from its full
|
||||||
|
// queue or given up as it refused them.
|
||||||
|
type Counts struct {
|
||||||
|
Sent, Failed, Dropped int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue takes the alerts raised, holds back those it must, and sends the
|
||||||
|
// others to each destination set, from a queue of the destination's own.
|
||||||
|
// It is safe for concurrent use.
|
||||||
|
type Queue struct {
|
||||||
|
params Params
|
||||||
|
// destinations are the destinations set, in the order of
|
||||||
|
// Destinations.
|
||||||
|
destinations []*destination
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
// cooldowns are the alerts last let through, by event and netblock,
|
||||||
|
// file, source or scope.
|
||||||
|
cooldowns map[cooldownKey]*Cooldown
|
||||||
|
hour Hour
|
||||||
|
|
||||||
|
suppressed atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// destination is a destination set, with the alerts waiting to be sent
|
||||||
|
// to it. Its mu is taken after the Queue's, never before.
|
||||||
|
type destination struct {
|
||||||
|
// name is how the metrics and alerts.json name the destination, and
|
||||||
|
// setting the setting that is its URL, which the log names in place
|
||||||
|
// of the URL.
|
||||||
|
name string
|
||||||
|
setting string
|
||||||
|
url *url.URL
|
||||||
|
// message returns the body an alert is posted with, and the headers
|
||||||
|
// sent with it.
|
||||||
|
message func(alert *Alert) ([]byte, http.Header, error)
|
||||||
|
// httpClient follows no redirect: a redirect is a failure.
|
||||||
|
httpClient *http.Client
|
||||||
|
processLog *slog.Logger
|
||||||
|
// queued receives a value when an alert joins the queue, unless one
|
||||||
|
// waits already, so that run looks at the queue again.
|
||||||
|
queued chan struct{}
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
// waiting are the alerts waiting to be sent, oldest first.
|
||||||
|
waiting []*Alert
|
||||||
|
|
||||||
|
sent, failed, dropped atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// cooldownKey is what makes an alert a repeat of another: the same event
|
||||||
|
// on the same netblock, and about the same file or source, or in the same
|
||||||
|
// scope with the same AS number or name, as its detail names them. Each
|
||||||
|
// is empty for an alert without one.
|
||||||
|
type cooldownKey struct {
|
||||||
|
event string
|
||||||
|
netblock netip.Prefix
|
||||||
|
file string
|
||||||
|
source string
|
||||||
|
scope string
|
||||||
|
asn string
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// cooldownKeyOf returns what makes another alert a repeat of alert.
|
||||||
|
func cooldownKeyOf(alert *Alert) cooldownKey {
|
||||||
|
file, _ := alert.Detail["file"].(string)
|
||||||
|
source, _ := alert.Detail["source"].(string)
|
||||||
|
scope, _ := alert.Detail["scope"].(string)
|
||||||
|
asn, _ := alert.Detail["asn"].(string)
|
||||||
|
name, _ := alert.Detail["name"].(string)
|
||||||
|
|
||||||
|
return cooldownKey{alert.Event, alert.Netblock, file, source, scope, asn, name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Queue with no alert yet.
|
||||||
|
func New(params Params) *Queue {
|
||||||
|
q := &Queue{
|
||||||
|
params: params,
|
||||||
|
cooldowns: map[cooldownKey]*Cooldown{},
|
||||||
|
hour: Hour{HeldBack: map[string]int{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.WebhookURL != nil {
|
||||||
|
q.addDestination(DestinationWebhook, "SWWAF_ALERT_WEBHOOK_URL",
|
||||||
|
params.WebhookURL, q.webhookMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.SlackURL != nil {
|
||||||
|
q.addDestination(DestinationSlack, "SWWAF_ALERT_SLACK_WEBHOOK_URL",
|
||||||
|
params.SlackURL, slackMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.NtfyURL != nil {
|
||||||
|
q.addDestination(DestinationNtfy, "SWWAF_ALERT_NTFY_URL",
|
||||||
|
params.NtfyURL, q.ntfyMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raise sends alert, which names its event and what is particular to it,
|
||||||
|
// unless no destination is set or SWWAF_ALERT_EVENTS leaves its event
|
||||||
|
// out. It gives alert the instance and the time. An alert that repeats
|
||||||
|
// the last one let through less than Cooldown before is held back and
|
||||||
|
// counted. The next one let through gives that count, unless an hour of
|
||||||
|
// the clock ends first after the cooldown has run out: the cooldown is
|
||||||
|
// then dropped, and that hour's summary gives the count. Past MaxPerHour
|
||||||
|
// alerts let through in the hour under way, an alert is held back for
|
||||||
|
// that hour's summary instead, which is sent once the hour has ended; it
|
||||||
|
// starts no cooldown. Raise never waits: an alert let through joins the
|
||||||
|
// queue of each destination, from which Run sends it, and with queueSize
|
||||||
|
// alerts waiting for a destination, the oldest is dropped.
|
||||||
|
func (q *Queue) Raise(alert Alert) {
|
||||||
|
if len(q.destinations) == 0 || !slices.Contains(q.params.Events, alert.Event) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
now := q.params.Now()
|
||||||
|
alert.Instance = q.params.Instance
|
||||||
|
alert.Time = now
|
||||||
|
|
||||||
|
if q.repeat(&alert, now) {
|
||||||
|
q.suppressed.Add(1)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q.endHour(now)
|
||||||
|
|
||||||
|
if q.params.MaxPerHour > 0 && q.hour.Sent >= q.params.MaxPerHour {
|
||||||
|
q.hour.HeldBack[alert.Event]++
|
||||||
|
q.suppressed.Add(1)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q.startCooldown(&alert, now)
|
||||||
|
q.hour.Sent++
|
||||||
|
q.queue(&alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WouldSend reports whether Raise would let an alert for event on
|
||||||
|
// netblock through now: a destination is set, SWWAF_ALERT_EVENTS chooses
|
||||||
|
// event, no alert for event on netblock was let through less than
|
||||||
|
// Cooldown before, and fewer than MaxPerHour alerts have been let through
|
||||||
|
// in the hour under way. Unlike Raise, it counts nothing.
|
||||||
|
func (q *Queue) WouldSend(event string, netblock netip.Prefix) bool {
|
||||||
|
if len(q.destinations) == 0 || !slices.Contains(q.params.Events, event) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
now := q.params.Now()
|
||||||
|
|
||||||
|
last, found := q.cooldowns[cooldownKey{event: event, netblock: netblock}]
|
||||||
|
if q.params.Cooldown > 0 && found && now.Sub(last.Sent) < q.params.Cooldown {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
q.endHour(now)
|
||||||
|
|
||||||
|
return q.params.MaxPerHour == 0 || q.hour.Sent < q.params.MaxPerHour
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run sends the alerts waiting to each destination, from its own queue,
|
||||||
|
// as destination.run does, until ctx is done. It also ends each hour as
|
||||||
|
// Raise does, so that the hour's summary is sent as it ends. With no
|
||||||
|
// destination set, it returns at once.
|
||||||
|
func (q *Queue) Run(ctx context.Context) {
|
||||||
|
if len(q.destinations) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var sending sync.WaitGroup
|
||||||
|
|
||||||
|
for _, d := range q.destinations {
|
||||||
|
sending.Go(func() { d.run(ctx) })
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
q.mu.Lock()
|
||||||
|
untilHourEnds := q.hour.Start.Add(time.Hour).Sub(q.params.Now())
|
||||||
|
q.mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
sending.Wait()
|
||||||
|
|
||||||
|
return
|
||||||
|
case <-time.After(untilHourEnds):
|
||||||
|
q.mu.Lock()
|
||||||
|
q.endHour(q.params.Now())
|
||||||
|
q.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts returns the counts of the destination name, all 0 for one not
|
||||||
|
// set.
|
||||||
|
func (q *Queue) Counts(name string) Counts {
|
||||||
|
for _, d := range q.destinations {
|
||||||
|
if d.name == name {
|
||||||
|
return Counts{
|
||||||
|
Sent: d.sent.Load(), Failed: d.failed.Load(), Dropped: d.dropped.Load(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Counts{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DestinationsSet returns the destinations set, in the order of
|
||||||
|
// Destinations.
|
||||||
|
func (q *Queue) DestinationsSet() []string {
|
||||||
|
names := make([]string, 0, len(q.destinations))
|
||||||
|
for _, d := range q.destinations {
|
||||||
|
names = append(names, d.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppressed is how many alerts were held back: by the cooldown, and past
|
||||||
|
// MaxPerHour. No destination is sent such an alert.
|
||||||
|
func (q *Queue) Suppressed() int64 {
|
||||||
|
return q.suppressed.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns the queue's state, as alerts.json holds it, with the
|
||||||
|
// cooldowns sorted by netblock, then by event, file, source, scope, AS
|
||||||
|
// number and name.
|
||||||
|
func (q *Queue) Snapshot() State {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Cooldowns: make([]Cooldown, 0, len(q.cooldowns)),
|
||||||
|
Hour: q.hour,
|
||||||
|
Waiting: map[string][]Alert{},
|
||||||
|
}
|
||||||
|
state.Hour.HeldBack = maps.Clone(q.hour.HeldBack)
|
||||||
|
|
||||||
|
for _, cooldown := range q.cooldowns {
|
||||||
|
state.Cooldowns = append(state.Cooldowns, *cooldown)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(state.Cooldowns, func(a, b Cooldown) int {
|
||||||
|
return cmp.Or(a.Netblock.Compare(b.Netblock), cmp.Compare(a.Event, b.Event),
|
||||||
|
cmp.Compare(a.File, b.File), cmp.Compare(a.Source, b.Source),
|
||||||
|
cmp.Compare(a.Scope, b.Scope), cmp.Compare(a.ASN, b.ASN),
|
||||||
|
cmp.Compare(a.Name, b.Name))
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, d := range q.destinations {
|
||||||
|
state.Waiting[d.name] = d.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load puts state, read from alerts.json, in place of the queue's state.
|
||||||
|
// Each cooldown's netblock is masked to its length, so that
|
||||||
|
// 203.0.113.9/24 is 203.0.113.0/24. The alerts waiting for a destination
|
||||||
|
// that is not set are dropped, and so are the oldest past queueSize
|
||||||
|
// alerts waiting for one that is.
|
||||||
|
func (q *Queue) Load(state State) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
q.cooldowns = map[cooldownKey]*Cooldown{}
|
||||||
|
|
||||||
|
for _, cooldown := range state.Cooldowns {
|
||||||
|
cooldown.Netblock = cooldown.Netblock.Masked()
|
||||||
|
key := cooldownKey{
|
||||||
|
cooldown.Event, cooldown.Netblock, cooldown.File, cooldown.Source,
|
||||||
|
cooldown.Scope, cooldown.ASN, cooldown.Name,
|
||||||
|
}
|
||||||
|
q.cooldowns[key] = &cooldown
|
||||||
|
}
|
||||||
|
|
||||||
|
q.hour = state.Hour
|
||||||
|
q.hour.HeldBack = maps.Clone(state.Hour.HeldBack)
|
||||||
|
|
||||||
|
if q.hour.HeldBack == nil {
|
||||||
|
q.hour.HeldBack = map[string]int{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range q.destinations {
|
||||||
|
d.load(state.Waiting[d.name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addDestination adds a destination: its name, the setting that gives
|
||||||
|
// its URL, that URL, target, and message, which makes the messages sent
|
||||||
|
// to it.
|
||||||
|
func (q *Queue) addDestination(
|
||||||
|
name, setting string, target *url.URL,
|
||||||
|
message func(alert *Alert) ([]byte, http.Header, error),
|
||||||
|
) {
|
||||||
|
q.destinations = append(q.destinations, &destination{
|
||||||
|
name: name,
|
||||||
|
setting: setting,
|
||||||
|
url: target,
|
||||||
|
message: message,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
},
|
||||||
|
processLog: q.params.ProcessLog,
|
||||||
|
queued: make(chan struct{}, 1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeat reports whether alert, raised at now, repeats the last one let
|
||||||
|
// through less than Cooldown before, and counts it if it does.
|
||||||
|
func (q *Queue) repeat(alert *Alert, now time.Time) bool {
|
||||||
|
if q.params.Cooldown == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
last, found := q.cooldowns[cooldownKeyOf(alert)]
|
||||||
|
if !found || now.Sub(last.Sent) >= q.params.Cooldown {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
last.SuppressedRepeats++
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// startCooldown gives alert, let through at now, the count of the repeats
|
||||||
|
// held back since the last one let through, and notes alert as the last
|
||||||
|
// one let through.
|
||||||
|
func (q *Queue) startCooldown(alert *Alert, now time.Time) {
|
||||||
|
if q.params.Cooldown == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := cooldownKeyOf(alert)
|
||||||
|
|
||||||
|
last, found := q.cooldowns[key]
|
||||||
|
if found {
|
||||||
|
alert.SuppressedRepeats = last.SuppressedRepeats
|
||||||
|
}
|
||||||
|
|
||||||
|
q.cooldowns[key] = &Cooldown{
|
||||||
|
Event: alert.Event, Netblock: alert.Netblock, File: key.file, Source: key.source,
|
||||||
|
Scope: key.scope, ASN: key.asn, Name: key.name, Sent: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// endHour ends the hour under way, if now is past it. It drops the
|
||||||
|
// cooldowns that have run out, whatever repeats they held back, so that
|
||||||
|
// they do not pile up, and queues that hour's summary when alerts were
|
||||||
|
// held back in it past MaxPerHour, or when a cooldown dropped had held
|
||||||
|
// back repeats, which no alert let through has given: the summary gives
|
||||||
|
// them.
|
||||||
|
func (q *Queue) endHour(now time.Time) {
|
||||||
|
start := now.Truncate(time.Hour)
|
||||||
|
if !start.After(q.hour.Start) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repeats := 0
|
||||||
|
|
||||||
|
for key, cooldown := range q.cooldowns {
|
||||||
|
if now.Sub(cooldown.Sent) >= q.params.Cooldown {
|
||||||
|
repeats += cooldown.SuppressedRepeats
|
||||||
|
|
||||||
|
delete(q.cooldowns, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
heldBack := 0
|
||||||
|
for _, count := range q.hour.HeldBack {
|
||||||
|
heldBack += count
|
||||||
|
}
|
||||||
|
|
||||||
|
var reasons []string
|
||||||
|
|
||||||
|
if heldBack > 0 {
|
||||||
|
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
|
||||||
|
"past the %d an hour SWWAF_ALERT_MAX_PER_HOUR allows", heldBack,
|
||||||
|
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
|
||||||
|
}
|
||||||
|
|
||||||
|
if repeats > 0 {
|
||||||
|
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
|
||||||
|
"SWWAF_ALERT_COOLDOWN that no later alert gives", repeats))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reasons) > 0 {
|
||||||
|
q.queue(&Alert{
|
||||||
|
Instance: q.params.Instance,
|
||||||
|
Time: now,
|
||||||
|
Event: EventSummary,
|
||||||
|
Reason: strings.Join(reasons, "; "),
|
||||||
|
Detail: map[string]any{
|
||||||
|
"hour": q.hour.Start, "count": heldBack, "events": q.hour.HeldBack,
|
||||||
|
},
|
||||||
|
SuppressedRepeats: repeats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
q.hour = Hour{Start: start, HeldBack: map[string]int{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// queue adds alert to the alerts waiting for each destination.
|
||||||
|
func (q *Queue) queue(alert *Alert) {
|
||||||
|
for _, d := range q.destinations {
|
||||||
|
d.add(alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run sends the alerts waiting, oldest first, until ctx is done. An alert
|
||||||
|
// stays in the queue until the destination answers it with a 2xx status,
|
||||||
|
// or refuses it with a 4xx status other than 408 and 429: a refused alert
|
||||||
|
// is logged, counted as dropped, and given up, so that the next is sent.
|
||||||
|
// Any other request that fails is logged, and the alert sent again
|
||||||
|
// firstRetryDelay later, retryDelayFactor times as long after each
|
||||||
|
// further failure in a row, up to maxRetryDelay.
|
||||||
|
func (d *destination) run(ctx context.Context) {
|
||||||
|
var (
|
||||||
|
retryDelay time.Duration
|
||||||
|
retryAt time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
for {
|
||||||
|
alert := d.oldest()
|
||||||
|
|
||||||
|
var due <-chan time.Time // nil while no alert waits
|
||||||
|
if alert != nil {
|
||||||
|
due = time.After(time.Until(retryAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-d.queued:
|
||||||
|
case <-due:
|
||||||
|
err := d.send(ctx, alert)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
d.remove(alert)
|
||||||
|
d.sent.Add(1)
|
||||||
|
|
||||||
|
retryDelay = 0
|
||||||
|
retryAt = time.Time{}
|
||||||
|
case errors.Is(err, errRefused):
|
||||||
|
d.remove(alert)
|
||||||
|
d.failed.Add(1)
|
||||||
|
d.dropped.Add(1)
|
||||||
|
|
||||||
|
retryDelay = 0
|
||||||
|
retryAt = time.Time{}
|
||||||
|
|
||||||
|
d.processLog.Warn("gave up an alert "+d.setting+" refused",
|
||||||
|
"event", alert.Event, "error", err.Error())
|
||||||
|
case ctx.Err() == nil: // not cut off as smallwebwaf stops
|
||||||
|
d.failed.Add(1)
|
||||||
|
|
||||||
|
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
|
||||||
|
maxRetryDelay)
|
||||||
|
retryAt = time.Now().Add(retryDelay)
|
||||||
|
|
||||||
|
d.processLog.Warn("sending an alert to "+d.setting+" failed",
|
||||||
|
"error", err.Error(), "sending_again_in", retryDelay.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add adds alert to the alerts waiting, first dropping the oldest while
|
||||||
|
// queueSize wait, and has run look at the queue again.
|
||||||
|
func (d *destination) add(alert *Alert) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
if len(d.waiting) == queueSize {
|
||||||
|
d.waiting = slices.Delete(d.waiting, 0, 1)
|
||||||
|
d.dropped.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.waiting = append(d.waiting, alert)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case d.queued <- struct{}{}:
|
||||||
|
default: // a value waits already
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// load puts waiting, read from alerts.json, in place of the alerts
|
||||||
|
// waiting, as add adds them.
|
||||||
|
func (d *destination) load(waiting []Alert) {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.waiting = nil
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
for _, alert := range waiting {
|
||||||
|
d.add(&alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshot returns the alerts waiting, oldest first.
|
||||||
|
func (d *destination) snapshot() []Alert {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
waiting := make([]Alert, 0, len(d.waiting))
|
||||||
|
for _, alert := range d.waiting {
|
||||||
|
waiting = append(waiting, *alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
return waiting
|
||||||
|
}
|
||||||
|
|
||||||
|
// oldest returns the oldest alert waiting, nil when none waits.
|
||||||
|
func (d *destination) oldest() *Alert {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
if len(d.waiting) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.waiting[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove takes alert, which run has sent or given up, out of the queue,
|
||||||
|
// unless it has been dropped from it, or load has replaced the queue,
|
||||||
|
// since run took it. Only the oldest alert is ever dropped, so alert is
|
||||||
|
// the oldest if it is there at all.
|
||||||
|
func (d *destination) remove(alert *Alert) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
if len(d.waiting) > 0 && d.waiting[0] == alert {
|
||||||
|
d.waiting = slices.Delete(d.waiting, 0, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// send posts alert to the destination, as message makes it, and returns
|
||||||
|
// an error unless the destination answers with a 2xx status: one that
|
||||||
|
// wraps errRefused for a 4xx status other than 408 and 429. No error
|
||||||
|
// names the destination's URL, whose path or query can carry a secret.
|
||||||
|
func (d *destination) send(ctx context.Context, alert *Alert) error {
|
||||||
|
body, header, err := d.message(alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.url.String(),
|
||||||
|
bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("make the request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
maps.Copy(req.Header, header)
|
||||||
|
|
||||||
|
res, err := d.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// The client's error names the URL: only what went wrong is kept.
|
||||||
|
if urlErr, ok := errors.AsType[*url.Error](err); ok {
|
||||||
|
return urlErr.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = res.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Read, so that the connection can be used again.
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, maxAnswerBytes))
|
||||||
|
|
||||||
|
switch status := res.StatusCode; {
|
||||||
|
case status >= http.StatusOK && status < http.StatusMultipleChoices:
|
||||||
|
return nil
|
||||||
|
case status >= http.StatusBadRequest && status < http.StatusInternalServerError &&
|
||||||
|
status != http.StatusRequestTimeout && status != http.StatusTooManyRequests:
|
||||||
|
return fmt.Errorf("%w %s", errRefused, res.Status)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w %s", errStatus, res.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookMessage returns alert as JSON, for the webhook, and the headers
|
||||||
|
// sent with it: WebhookHeaders, and its Content-Type.
|
||||||
|
func (q *Queue) webhookMessage(alert *Alert) ([]byte, http.Header, error) {
|
||||||
|
body, err := json.Marshal(alert)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("encode the alert: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
header := http.Header{}
|
||||||
|
maps.Copy(header, q.params.WebhookHeaders)
|
||||||
|
header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
return body, header, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// slackMessage returns alert as a message for a Slack incoming webhook,
|
||||||
|
// in JSON: its title in bold, then its text, with &, < and > escaped, as
|
||||||
|
// Slack asks, so that nothing in them is read as a link or a mention.
|
||||||
|
func slackMessage(alert *Alert) ([]byte, http.Header, error) {
|
||||||
|
escape := strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace
|
||||||
|
|
||||||
|
body, err := json.Marshal(map[string]string{
|
||||||
|
"text": "*" + escape(title(alert)) + "*\n" + escape(text(alert)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("encode the message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, http.Header{"Content-Type": {"application/json"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ntfyMessage returns alert's text, as the message published to ntfy,
|
||||||
|
// and the headers sent with it: its title, the priority and the tag of
|
||||||
|
// its event, and NtfyToken, unless it is empty, as a bearer token.
|
||||||
|
func (q *Queue) ntfyMessage(alert *Alert) ([]byte, http.Header, error) {
|
||||||
|
header := http.Header{
|
||||||
|
"Title": {title(alert)},
|
||||||
|
"Priority": {ntfyPriority(alert.Event)},
|
||||||
|
"Tags": {ntfyTag(alert.Event)},
|
||||||
|
}
|
||||||
|
|
||||||
|
if q.params.NtfyToken != "" {
|
||||||
|
header.Set("Authorization", "Bearer "+q.params.NtfyToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte(text(alert)), header, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ntfyPriority returns the priority an alert for event is published to
|
||||||
|
// ntfy with: high for an event the admin needs to look at.
|
||||||
|
func ntfyPriority(event string) string {
|
||||||
|
switch event {
|
||||||
|
case EventPermanentBan, EventAnomaly, EventSourceFailure, EventFileError:
|
||||||
|
return "high"
|
||||||
|
case EventReputationHit:
|
||||||
|
return "low"
|
||||||
|
default: // ban, waf_block and summary
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ntfyTag returns the tag an alert for event is published to ntfy with,
|
||||||
|
// which ntfy shows as an emoji.
|
||||||
|
func ntfyTag(event string) string {
|
||||||
|
switch event {
|
||||||
|
case EventBan, EventPermanentBan:
|
||||||
|
return "no_entry"
|
||||||
|
case EventWAFBlock:
|
||||||
|
return "shield"
|
||||||
|
case EventAnomaly:
|
||||||
|
return "chart_with_upwards_trend"
|
||||||
|
case EventReputationHit:
|
||||||
|
return "label"
|
||||||
|
case EventSourceFailure, EventFileError:
|
||||||
|
return "warning"
|
||||||
|
default: // summary
|
||||||
|
return "bar_chart"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// title returns the title of alert in Slack and ntfy: the instance and
|
||||||
|
// the event.
|
||||||
|
func title(alert *Alert) string {
|
||||||
|
return alert.Instance + ": " + alert.Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// text returns the text of alert in Slack and ntfy: its reason, then a
|
||||||
|
// line for each of its client, netblock and country, the file, source,
|
||||||
|
// error and mode its detail gives, and its suppressed repeats, that it
|
||||||
|
// has.
|
||||||
|
func text(alert *Alert) string {
|
||||||
|
lines := []string{alert.Reason}
|
||||||
|
|
||||||
|
if alert.Client.IsValid() {
|
||||||
|
lines = append(lines, "client: "+alert.Client.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if alert.Netblock.IsValid() {
|
||||||
|
lines = append(lines, "netblock: "+alert.Netblock.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if alert.Country != "" {
|
||||||
|
lines = append(lines, "country: "+alert.Country)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"file", "source", "error", "mode"} {
|
||||||
|
value, _ := alert.Detail[name].(string)
|
||||||
|
if value != "" {
|
||||||
|
lines = append(lines, name+": "+value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if alert.SuppressedRepeats > 0 {
|
||||||
|
lines = append(lines, fmt.Sprintf("suppressed repeats: %d", alert.SuppressedRepeats))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// QueueSize is the most alerts that wait to be sent to a destination.
|
||||||
|
const QueueSize = queueSize
|
||||||
|
|
||||||
|
// SetTransport has q's requests to the destination name go through
|
||||||
|
// transport instead of the network.
|
||||||
|
func (q *Queue) SetTransport(name string, transport http.RoundTripper) {
|
||||||
|
for _, d := range q.destinations {
|
||||||
|
if d.name == name {
|
||||||
|
d.httpClient.Transport = transport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
// Package anomaly counts requests and bytes over a minute and an hour, per
|
||||||
|
// client, per surrounding netblock, per AS number, for the whole service
|
||||||
|
// and per named netblock, and raises an anomaly alert for a count over its
|
||||||
|
// threshold, as "Anomaly thresholds" under "Configuration surface" in
|
||||||
|
// SPEC.md describes. It refuses and bans nothing. At most 20,000 counters
|
||||||
|
// are kept, in memory, and written to alerts.json and read from it by the
|
||||||
|
// state package.
|
||||||
|
package anomaly
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxCounters is how many counters are kept. Past it, the counter counted
|
||||||
|
// least recently is dropped, and starts afresh if it is counted again.
|
||||||
|
const maxCounters = 20000
|
||||||
|
|
||||||
|
// The scopes, what a counter counts, as the settings, alerts.json and the
|
||||||
|
// alerts name them.
|
||||||
|
const (
|
||||||
|
// ScopeClient is one client: an IPv4 address, or an IPv6 /64.
|
||||||
|
ScopeClient = "client"
|
||||||
|
// ScopeNet is the netblock around a client, SWWAF_ANOMALY_NET_V4_PREFIX
|
||||||
|
// or SWWAF_ANOMALY_NET_V6_PREFIX long.
|
||||||
|
ScopeNet = "net"
|
||||||
|
// ScopeASN is an AS number.
|
||||||
|
ScopeASN = "asn"
|
||||||
|
// ScopeTotal is the whole service.
|
||||||
|
ScopeTotal = "total"
|
||||||
|
// ScopeWatch is a named netblock of SWWAF_WATCH_NETS.
|
||||||
|
ScopeWatch = "watch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scopes returns every scope.
|
||||||
|
func Scopes() []string {
|
||||||
|
return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatch}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The windows a counter counts in, as the alerts name them.
|
||||||
|
const (
|
||||||
|
minute = "minute"
|
||||||
|
hour = "hour"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Thresholds are the most requests and the most bytes a scope may have
|
||||||
|
// counted in a minute and in an hour before an alert is raised. Zero is
|
||||||
|
// off.
|
||||||
|
type Thresholds struct {
|
||||||
|
RequestsPerMinute int64
|
||||||
|
RequestsPerHour int64
|
||||||
|
BytesPerMinute int64
|
||||||
|
BytesPerHour int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamedNetblock is a netblock SWWAF_WATCH_NETS names.
|
||||||
|
type NamedNetblock struct {
|
||||||
|
Name string
|
||||||
|
Netblock netip.Prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params are what New needs.
|
||||||
|
type Params struct {
|
||||||
|
// The thresholds of each scope: SWWAF_ANOMALY_CLIENT_*,
|
||||||
|
// SWWAF_ANOMALY_NET_*, SWWAF_ANOMALY_ASN_*, SWWAF_ANOMALY_TOTAL_* and
|
||||||
|
// SWWAF_WATCH_*.
|
||||||
|
Client, Net, ASN, Total, Watch Thresholds
|
||||||
|
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
||||||
|
// client (SWWAF_ANOMALY_NET_V4_PREFIX and SWWAF_ANOMALY_NET_V6_PREFIX).
|
||||||
|
NetV4Prefix, NetV6Prefix int
|
||||||
|
// NamedNetblocks are SWWAF_WATCH_NETS.
|
||||||
|
NamedNetblocks []NamedNetblock
|
||||||
|
// Alerts receive the anomaly alerts.
|
||||||
|
Alerts *alerts.Queue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter is one scope's counts, as alerts.json holds them: the scope,
|
||||||
|
// with the netblock, the AS number or the name that tells it from the
|
||||||
|
// others in that scope, and its two buckets of requests and of bytes in
|
||||||
|
// the minute and in the hour. A bucket whose threshold is off counts
|
||||||
|
// nothing, and is left out.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||||
|
type Counter struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Netblock netip.Prefix `json:"netblock,omitzero"`
|
||||||
|
ASN string `json:"asn,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Minute ratelimit.Buckets `json:"minute,omitzero"`
|
||||||
|
Hour ratelimit.Buckets `json:"hour,omitzero"`
|
||||||
|
MinuteBytes ratelimit.Buckets `json:"minute_bytes,omitzero"`
|
||||||
|
HourBytes ratelimit.Buckets `json:"hour_bytes,omitzero"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request is a request that has ended, as the counters count it.
|
||||||
|
type Request struct {
|
||||||
|
// Client is the client's address, and ClientGroup the client it is
|
||||||
|
// counted as: its IPv4 address, or its IPv6 /64.
|
||||||
|
Client netip.Addr
|
||||||
|
ClientGroup netip.Prefix
|
||||||
|
// ASN, ASName and Country are the client's as looked up, each "" when
|
||||||
|
// unknown.
|
||||||
|
ASN, ASName, Country string
|
||||||
|
// Bytes are the request's bytes, as SWWAF_BYTES_COUNT counts them.
|
||||||
|
Bytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counters counts each request in the scopes it is in. It is safe for
|
||||||
|
// concurrent use.
|
||||||
|
type Counters struct {
|
||||||
|
params Params
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
counters *simplelru.LRU[key, *Counter]
|
||||||
|
}
|
||||||
|
|
||||||
|
// key is what tells a counter from the others: its scope, with its
|
||||||
|
// netblock, AS number or name.
|
||||||
|
type key struct {
|
||||||
|
scope string
|
||||||
|
netblock netip.Prefix
|
||||||
|
asn string
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns Counters for params, with nothing counted yet.
|
||||||
|
func New(params Params) *Counters {
|
||||||
|
counters, err := simplelru.NewLRU[key, *Counter](maxCounters, nil)
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // NewLRU fails only for a size below one
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Counters{params: params, counters: counters}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count counts r, a request that has ended, at now, in each scope it is
|
||||||
|
// in whose thresholds are not all off: its client, the netblock around
|
||||||
|
// it, its AS number once known, the whole service, and each named
|
||||||
|
// netblock it is in. Only the counts whose threshold is set are counted.
|
||||||
|
// For each scope whose count is over a threshold, it raises an anomaly
|
||||||
|
// alert, for the first such count in the order requests and bytes in the
|
||||||
|
// minute, then in the hour; the alert queue's cooldown holds back the
|
||||||
|
// repeats. Nothing is refused or banned.
|
||||||
|
func (c *Counters) Count(now time.Time, r Request) {
|
||||||
|
var raised []alerts.Alert
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
|
||||||
|
for _, scope := range c.scopesOf(r) {
|
||||||
|
counter, found := c.counters.Get(scope.key)
|
||||||
|
if !found {
|
||||||
|
counter = scope.key.counter()
|
||||||
|
c.counters.Add(scope.key, counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
over, passed := counter.add(now, r.Bytes, scope.thresholds)
|
||||||
|
if passed {
|
||||||
|
raised = append(raised, alertFor(r, scope.key, over))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
for _, alert := range raised {
|
||||||
|
c.params.Alerts.Raise(alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns every counter, sorted by scope, then by netblock, AS
|
||||||
|
// number and name, as alerts.json lists them.
|
||||||
|
func (c *Counters) Snapshot() []Counter {
|
||||||
|
c.mu.Lock()
|
||||||
|
|
||||||
|
counters := make([]Counter, 0, c.counters.Len())
|
||||||
|
for _, counter := range c.counters.Values() {
|
||||||
|
counters = append(counters, *counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
slices.SortFunc(counters, func(a, b Counter) int {
|
||||||
|
return cmp.Or(cmp.Compare(a.Scope, b.Scope), a.Netblock.Compare(b.Netblock),
|
||||||
|
cmp.Compare(a.ASN, b.ASN), cmp.Compare(a.Name, b.Name))
|
||||||
|
})
|
||||||
|
|
||||||
|
return counters
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load puts counters, read from alerts.json, in place of those held, in
|
||||||
|
// the order they were last counted, as the starts of their buckets tell,
|
||||||
|
// so that the one counted least recently is dropped first. Each netblock
|
||||||
|
// is masked to its length, so that 203.0.113.9/24 is 203.0.113.0/24.
|
||||||
|
// Buckets whose time has passed at now are emptied, and a counter left
|
||||||
|
// with every bucket empty is dropped.
|
||||||
|
func (c *Counters) Load(counters []Counter, now time.Time) {
|
||||||
|
counters = slices.Clone(counters)
|
||||||
|
slices.SortStableFunc(counters, func(a, b Counter) int {
|
||||||
|
return a.lastStart().Compare(b.lastStart())
|
||||||
|
})
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.counters.Purge()
|
||||||
|
|
||||||
|
for _, counter := range counters {
|
||||||
|
counter.Netblock = counter.Netblock.Masked()
|
||||||
|
empty := true
|
||||||
|
|
||||||
|
for _, count := range counter.counts() {
|
||||||
|
if count.buckets.Passed(now, count.length) {
|
||||||
|
*count.buckets = ratelimit.Buckets{}
|
||||||
|
}
|
||||||
|
|
||||||
|
empty = empty && *count.buckets == ratelimit.Buckets{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !empty {
|
||||||
|
c.counters.Add(counter.key(), &counter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scope is a scope a request is counted in, and its thresholds.
|
||||||
|
type scope struct {
|
||||||
|
key key
|
||||||
|
thresholds Thresholds
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopesOf returns the scopes r is in whose thresholds are not all off.
|
||||||
|
func (c *Counters) scopesOf(r Request) []scope {
|
||||||
|
p := c.params
|
||||||
|
client := r.Client.Unmap()
|
||||||
|
|
||||||
|
all := []scope{
|
||||||
|
{key{scope: ScopeClient, netblock: r.ClientGroup}, p.Client},
|
||||||
|
{key{scope: ScopeNet, netblock: c.netAround(client)}, p.Net},
|
||||||
|
{key{scope: ScopeTotal}, p.Total},
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.ASN != "" {
|
||||||
|
all = append(all, scope{key{scope: ScopeASN, asn: r.ASN}, p.ASN})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, named := range p.NamedNetblocks {
|
||||||
|
if named.Netblock.Contains(client) {
|
||||||
|
all = append(all, scope{
|
||||||
|
key{scope: ScopeWatch, netblock: named.Netblock, name: named.Name}, p.Watch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slices.DeleteFunc(all, func(s scope) bool {
|
||||||
|
return s.thresholds == Thresholds{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// netAround returns the netblock around client that ScopeNet counts it
|
||||||
|
// in: NetV4Prefix or NetV6Prefix long.
|
||||||
|
func (c *Counters) netAround(client netip.Addr) netip.Prefix {
|
||||||
|
length := c.params.NetV6Prefix
|
||||||
|
if client.Is4() {
|
||||||
|
length = c.params.NetV4Prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
return netip.PrefixFrom(client, length).Masked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// overThreshold is a count over its threshold: what it counts, requests or
|
||||||
|
// bytes, its window, the count and the threshold.
|
||||||
|
type overThreshold struct {
|
||||||
|
kind, window string
|
||||||
|
count float64
|
||||||
|
threshold int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// add counts a request of bytes at now in each of c's counts whose
|
||||||
|
// threshold, in thresholds, is set, and returns the first count over its
|
||||||
|
// threshold, and whether there is one.
|
||||||
|
func (c *Counter) add(
|
||||||
|
now time.Time, bytes int64, thresholds Thresholds,
|
||||||
|
) (overThreshold, bool) {
|
||||||
|
// In the order of counts.
|
||||||
|
inOrder := [4]int64{
|
||||||
|
thresholds.RequestsPerMinute, thresholds.BytesPerMinute,
|
||||||
|
thresholds.RequestsPerHour, thresholds.BytesPerHour,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
first overThreshold
|
||||||
|
passed bool
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, count := range c.counts() {
|
||||||
|
threshold := inOrder[i]
|
||||||
|
if threshold == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
n := int64(1)
|
||||||
|
if count.kind == ratelimit.KindBytes {
|
||||||
|
n = bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
counted := count.buckets.Add(now, count.length, n)
|
||||||
|
if !passed && counted > float64(threshold) {
|
||||||
|
first = overThreshold{count.kind, count.window, counted, threshold}
|
||||||
|
passed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return first, passed
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketCount is one of a counter's four counts: requests or bytes, in a
|
||||||
|
// window of length, and the buckets they are counted in.
|
||||||
|
type bucketCount struct {
|
||||||
|
kind, window string
|
||||||
|
length time.Duration
|
||||||
|
buckets *ratelimit.Buckets
|
||||||
|
}
|
||||||
|
|
||||||
|
// counts returns c's counts: requests and bytes in the minute, then in
|
||||||
|
// the hour.
|
||||||
|
func (c *Counter) counts() [4]bucketCount {
|
||||||
|
return [4]bucketCount{
|
||||||
|
{ratelimit.KindRequests, minute, time.Minute, &c.Minute},
|
||||||
|
{ratelimit.KindBytes, minute, time.Minute, &c.MinuteBytes},
|
||||||
|
{ratelimit.KindRequests, hour, time.Hour, &c.Hour},
|
||||||
|
{ratelimit.KindBytes, hour, time.Hour, &c.HourBytes},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastStart returns the start of c's latest bucket, which tells, to the
|
||||||
|
// minute or to the hour, when c was last counted.
|
||||||
|
func (c *Counter) lastStart() time.Time {
|
||||||
|
var latest time.Time
|
||||||
|
|
||||||
|
for _, count := range c.counts() {
|
||||||
|
if count.buckets.Start.After(latest) {
|
||||||
|
latest = count.buckets.Start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
// key returns what tells c from the other counters.
|
||||||
|
func (c *Counter) key() key {
|
||||||
|
return key{scope: c.Scope, netblock: c.Netblock, asn: c.ASN, name: c.Name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// counter returns a counter for k, with nothing counted yet.
|
||||||
|
func (k key) counter() *Counter {
|
||||||
|
return &Counter{Scope: k.scope, Netblock: k.netblock, ASN: k.asn, Name: k.name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// alertFor returns the anomaly alert for o, a count over its threshold in
|
||||||
|
// the scope k, which r took over it. It gives r's client, with its AS
|
||||||
|
// number, AS name and country, and the netblock counted, of a client, the
|
||||||
|
// netblock around it or a named netblock. Its detail gives the scope, the
|
||||||
|
// AS number or the name of a scope that has one, the window, what is
|
||||||
|
// counted, the count and the threshold.
|
||||||
|
func alertFor(r Request, k key, o overThreshold) alerts.Alert {
|
||||||
|
detail := map[string]any{
|
||||||
|
"scope": k.scope, "window": o.window, "kind": o.kind, "count": o.count,
|
||||||
|
"threshold": o.threshold,
|
||||||
|
}
|
||||||
|
|
||||||
|
var counted string
|
||||||
|
|
||||||
|
switch k.scope {
|
||||||
|
case ScopeClient:
|
||||||
|
counted = "the client " + k.netblock.String()
|
||||||
|
case ScopeNet:
|
||||||
|
counted = "the netblock " + k.netblock.String()
|
||||||
|
case ScopeASN:
|
||||||
|
counted = k.asn
|
||||||
|
detail["asn"] = k.asn
|
||||||
|
case ScopeTotal:
|
||||||
|
counted = "the whole service"
|
||||||
|
default: // watch
|
||||||
|
counted = "the named netblock " + k.name + ", " + k.netblock.String()
|
||||||
|
detail["name"] = k.name
|
||||||
|
}
|
||||||
|
|
||||||
|
return alerts.Alert{
|
||||||
|
Event: alerts.EventAnomaly,
|
||||||
|
Client: r.Client,
|
||||||
|
Netblock: k.netblock,
|
||||||
|
ASN: r.ASN,
|
||||||
|
ASName: r.ASName,
|
||||||
|
Country: r.Country,
|
||||||
|
Reason: fmt.Sprintf("%s per %s of %s over the threshold of %d", o.kind, o.window,
|
||||||
|
counted, o.threshold),
|
||||||
|
Detail: detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package anomaly_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxCounters is how many counters are kept.
|
||||||
|
const maxCounters = 20000
|
||||||
|
|
||||||
|
func TestEachScopeHasACooldownOfItsOwn(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
queue := newQueue()
|
||||||
|
office := netip.MustParsePrefix("203.0.113.0/24")
|
||||||
|
overAtTheSecond := anomaly.Thresholds{RequestsPerMinute: 1}
|
||||||
|
counters := anomaly.New(anomaly.Params{
|
||||||
|
Client: overAtTheSecond, Net: overAtTheSecond, ASN: overAtTheSecond,
|
||||||
|
Total: overAtTheSecond, Watch: overAtTheSecond,
|
||||||
|
// The netblock around a client is the client's own, and two names
|
||||||
|
// name one netblock.
|
||||||
|
NetV4Prefix: 32,
|
||||||
|
NamedNetblocks: []anomaly.NamedNetblock{
|
||||||
|
{Name: "office", Netblock: office}, {Name: "hq", Netblock: office},
|
||||||
|
},
|
||||||
|
Alerts: queue,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The first client's second request is over the threshold in the six
|
||||||
|
// scopes it is in. The other client's two are both over it in the whole
|
||||||
|
// service and in each named netblock, three repeats each, and its
|
||||||
|
// second is over it in the scopes of its own, its client, its netblock
|
||||||
|
// and its AS number, which are no repeats.
|
||||||
|
for _, r := range []anomaly.Request{
|
||||||
|
{Client: netip.MustParseAddr("203.0.113.9"), ASN: "AS64496"},
|
||||||
|
{Client: netip.MustParseAddr("203.0.113.10"), ASN: "AS64511"},
|
||||||
|
} {
|
||||||
|
r.ClientGroup = netip.PrefixFrom(r.Client, 32)
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
counters.Count(midnight(), r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 9 || queue.Suppressed() != 6 {
|
||||||
|
t.Fatalf("%d alerts wait and %d are held back, want 9 and 6: %+v",
|
||||||
|
len(waiting), queue.Suppressed(), waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
// alerts.json keeps each scope's cooldown: each alert raised again
|
||||||
|
// after a restart is a repeat.
|
||||||
|
data, err := json.Marshal(queue.Snapshot())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var read alerts.State
|
||||||
|
|
||||||
|
err = json.Unmarshal(data, &read)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after := newQueue()
|
||||||
|
after.Load(read)
|
||||||
|
|
||||||
|
for _, alert := range read.Waiting[alerts.DestinationWebhook] {
|
||||||
|
after.Raise(alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
if after.Suppressed() != 9 {
|
||||||
|
t.Errorf("after loading, %d alerts are held back, want 9", after.Suppressed())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeepsAtMost20000CountersDroppingTheLeastRecentlyCounted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
counters := newCounters(anomaly.Params{
|
||||||
|
Client: anomaly.Thresholds{RequestsPerMinute: 1000},
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := range maxCounters {
|
||||||
|
counters.Count(midnight(), request(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counted again, the first client is the most recently counted, and
|
||||||
|
// the second is dropped for a new one.
|
||||||
|
counters.Count(midnight(), request(0))
|
||||||
|
counters.Count(midnight(), request(maxCounters))
|
||||||
|
|
||||||
|
got := counters.Snapshot()
|
||||||
|
if len(got) != maxCounters || !holds(got, 0) || holds(got, 1) ||
|
||||||
|
!holds(got, maxCounters) {
|
||||||
|
t.Errorf("%d counters, holding the first client %v, the second %v and the "+
|
||||||
|
"new one %v, want %d, the first and the new one", len(got), holds(got, 0),
|
||||||
|
holds(got, 1), holds(got, maxCounters), maxCounters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadEmptiesBucketsWhoseTimeHasPassedAndDropsEmptyCounters(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
counters := newCounters(anomaly.Params{
|
||||||
|
Net: anomaly.Thresholds{RequestsPerMinute: 1000, RequestsPerHour: 1000},
|
||||||
|
Total: anomaly.Thresholds{RequestsPerMinute: 1000},
|
||||||
|
NetV4Prefix: 24,
|
||||||
|
})
|
||||||
|
halfAnHourOn := midnight().Add(30 * time.Minute)
|
||||||
|
|
||||||
|
// Half an hour on, the hour's buckets count still, and the minute's
|
||||||
|
// do not.
|
||||||
|
counters.Load([]anomaly.Counter{
|
||||||
|
{
|
||||||
|
Scope: anomaly.ScopeNet,
|
||||||
|
Netblock: netip.MustParsePrefix("203.0.113.9/24"),
|
||||||
|
Minute: ratelimit.Buckets{Start: midnight(), Current: 5},
|
||||||
|
Hour: ratelimit.Buckets{Start: midnight(), Current: 7},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Scope: anomaly.ScopeTotal,
|
||||||
|
Minute: ratelimit.Buckets{Start: midnight(), Current: 1},
|
||||||
|
},
|
||||||
|
}, halfAnHourOn)
|
||||||
|
|
||||||
|
// The whole service's counter, left empty, is dropped, and the
|
||||||
|
// netblock read is masked to its length.
|
||||||
|
netblock := anomaly.Counter{
|
||||||
|
Scope: anomaly.ScopeNet,
|
||||||
|
Netblock: netip.MustParsePrefix("203.0.113.0/24"),
|
||||||
|
Hour: ratelimit.Buckets{Start: midnight(), Current: 7},
|
||||||
|
}
|
||||||
|
if got, want := counters.Snapshot(), []anomaly.Counter{netblock}; !reflect.DeepEqual(
|
||||||
|
got, want) {
|
||||||
|
t.Errorf("counters read\n%+v\nwant\n%+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request from the netblock is counted with the requests read.
|
||||||
|
counters.Count(halfAnHourOn, anomaly.Request{
|
||||||
|
Client: netip.MustParseAddr("203.0.113.9"),
|
||||||
|
ClientGroup: netip.MustParsePrefix("203.0.113.9/32"),
|
||||||
|
})
|
||||||
|
|
||||||
|
netblock.Minute = ratelimit.Buckets{Start: halfAnHourOn, Current: 1}
|
||||||
|
netblock.Hour.Current = 8
|
||||||
|
want := []anomaly.Counter{netblock, {
|
||||||
|
Scope: anomaly.ScopeTotal,
|
||||||
|
Minute: ratelimit.Buckets{Start: halfAnHourOn, Current: 1},
|
||||||
|
}}
|
||||||
|
|
||||||
|
if got := counters.Snapshot(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("counters after a request\n%+v\nwant\n%+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDropsTheLeastRecentlyCountedFirst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
counters := newCounters(anomaly.Params{
|
||||||
|
Client: anomaly.Thresholds{RequestsPerMinute: 1000},
|
||||||
|
})
|
||||||
|
now := midnight().Add(time.Minute)
|
||||||
|
|
||||||
|
// The second half of the file was counted in the minute before the
|
||||||
|
// first half.
|
||||||
|
read := make([]anomaly.Counter, 0, maxCounters)
|
||||||
|
for i := range maxCounters {
|
||||||
|
start := now
|
||||||
|
if i >= maxCounters/2 {
|
||||||
|
start = midnight()
|
||||||
|
}
|
||||||
|
|
||||||
|
read = append(read, anomaly.Counter{
|
||||||
|
Scope: anomaly.ScopeClient, Netblock: request(i).ClientGroup,
|
||||||
|
Minute: ratelimit.Buckets{Start: start, Current: 1},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
counters.Load(read, now)
|
||||||
|
counters.Count(now, request(maxCounters))
|
||||||
|
|
||||||
|
got := counters.Snapshot()
|
||||||
|
if !holds(got, 0) || holds(got, maxCounters/2) {
|
||||||
|
t.Errorf("holding the first client of the file %v, and the first counted in "+
|
||||||
|
"the minute before %v, want only the first", holds(got, 0),
|
||||||
|
holds(got, maxCounters/2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// midnight is the time of the tests' requests.
|
||||||
|
func midnight() time.Time {
|
||||||
|
return time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCounters returns Counters for params, whose alerts go nowhere.
|
||||||
|
func newCounters(params anomaly.Params) *anomaly.Counters {
|
||||||
|
params.Alerts = alerts.New(alerts.Params{})
|
||||||
|
|
||||||
|
return anomaly.New(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newQueue returns a queue of alerts to a webhook, with the default
|
||||||
|
// cooldown, which keeps them waiting, since it is never run.
|
||||||
|
func newQueue() *alerts.Queue {
|
||||||
|
return alerts.New(alerts.Params{
|
||||||
|
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
||||||
|
Events: alerts.Events(),
|
||||||
|
Cooldown: 15 * time.Minute,
|
||||||
|
MaxPerHour: 60,
|
||||||
|
Now: midnight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// request returns a request from client number i, an address in
|
||||||
|
// 10.0.0.0/8.
|
||||||
|
func request(i int) anomaly.Request {
|
||||||
|
client := netip.MustParseAddr(fmt.Sprintf("10.%d.%d.%d", i>>16, i>>8&255, i&255))
|
||||||
|
|
||||||
|
return anomaly.Request{Client: client, ClientGroup: netip.PrefixFrom(client, 32)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// holds reports whether counters hold the counter of client number i.
|
||||||
|
func holds(counters []anomaly.Counter, i int) bool {
|
||||||
|
return slices.ContainsFunc(counters, func(counter anomaly.Counter) bool {
|
||||||
|
return counter.Netblock == request(i).ClientGroup
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package bans_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
ledger.Load([]bans.Ban{{Netblock: netblock, Start: midnight()}})
|
||||||
|
|
||||||
|
if got := ledger.Bans(netblock)[0].Cause; got != bans.CauseAdmin {
|
||||||
|
t.Errorf("the ban's cause is %q, want admin", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rules := defaultRules()
|
||||||
|
rules.MaxBans = 1
|
||||||
|
ledger := bans.New(rules)
|
||||||
|
adminsOnly := netip.MustParsePrefix("198.51.100.0/24")
|
||||||
|
both := netip.MustParsePrefix("203.0.113.1/32")
|
||||||
|
second := netip.MustParsePrefix("203.0.113.2/32")
|
||||||
|
third := netip.MustParsePrefix("203.0.113.3/32")
|
||||||
|
|
||||||
|
// Seen longest ago, a netblock with two of an admin's bans alone, and
|
||||||
|
// then one with an admin's ban before a ban smallwebwaf made: the one
|
||||||
|
// ban counted toward MaxBans.
|
||||||
|
ledger.Load([]bans.Ban{
|
||||||
|
{Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin},
|
||||||
|
{Netblock: adminsOnly, Start: midnight().Add(-2 * time.Hour), Cause: bans.CauseAdmin},
|
||||||
|
{Netblock: both, Start: midnight().Add(-time.Hour), Cause: bans.CauseAdmin},
|
||||||
|
{
|
||||||
|
Netblock: both,
|
||||||
|
Start: midnight(),
|
||||||
|
Expires: midnight().Add(time.Hour),
|
||||||
|
Cause: bans.CauseLimit,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 2})
|
||||||
|
|
||||||
|
// A new ban drops the ban smallwebwaf made, and only that one.
|
||||||
|
ledger.BanForLimit(second, midnight(), bans.Notes{})
|
||||||
|
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1})
|
||||||
|
|
||||||
|
if ledger.Bans(both)[0].Cause != bans.CauseAdmin {
|
||||||
|
t.Errorf("%s kept %+v, want the admin's ban", both, ledger.Bans(both))
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the next drops that one.
|
||||||
|
ledger.BanForLimit(third, midnight(), bans.Notes{})
|
||||||
|
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 0, third: 1})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
|
||||||
|
limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
||||||
|
bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"})
|
||||||
|
byteLimit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.3/32"),
|
||||||
|
midnight(), bans.Notes{Kind: "bytes", Limit: 10 << 30, Window: "hour"})
|
||||||
|
attack, _ := ledger.BanForAttack(netip.MustParsePrefix("203.0.113.2/32"), midnight(),
|
||||||
|
bans.Notes{RuleID: "git-dir", Target: "path"})
|
||||||
|
|
||||||
|
for _, tc := range []struct{ got, want string }{
|
||||||
|
{limit.Reason, "requests per minute over the limit of 1000"},
|
||||||
|
{byteLimit.Reason, "bytes per hour over the limit of 10737418240"},
|
||||||
|
{attack.Reason, "matched the rule git-dir"},
|
||||||
|
} {
|
||||||
|
if tc.got != tc.want {
|
||||||
|
t.Errorf("the reason is %q, want %q", tc.got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiftedBanForALimitRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// An hour's ban lifted ten minutes after it started.
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
lifted := bans.Ban{
|
||||||
|
Netblock: netblock,
|
||||||
|
Start: midnight(),
|
||||||
|
Expires: midnight().Add(time.Hour),
|
||||||
|
Cause: bans.CauseLimit,
|
||||||
|
Lifted: midnight().Add(10 * time.Minute),
|
||||||
|
}
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
ledger.Load([]bans.Ban{lifted})
|
||||||
|
|
||||||
|
// While it would still last, it refuses nothing, and a limit broken
|
||||||
|
// bans for an hour, as a first broken limit does; the lifted ban is
|
||||||
|
// kept, and counted among the earlier bans.
|
||||||
|
now := midnight().Add(30 * time.Minute)
|
||||||
|
|
||||||
|
_, banned, _ := ledger.Check(netblock.Addr(), now)
|
||||||
|
if banned {
|
||||||
|
t.Error("the lifted ban refuses")
|
||||||
|
}
|
||||||
|
|
||||||
|
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||||
|
if ban.Expires.Sub(ban.Start) != time.Hour ||
|
||||||
|
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||||
|
t.Errorf("the next ban lasts %s with earlier bans %+v, want 1h and 1 for a limit",
|
||||||
|
ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans)
|
||||||
|
}
|
||||||
|
|
||||||
|
held := ledger.Bans(netblock)
|
||||||
|
if len(held) != 2 || held[0] != lifted {
|
||||||
|
t.Errorf("the ledger holds %+v, want the lifted ban and the new one", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A permanent ban for a clear sign of attack, lifted.
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
ledger.Load([]bans.Ban{{
|
||||||
|
Netblock: netblock,
|
||||||
|
Start: midnight(),
|
||||||
|
Cause: bans.CauseAttack,
|
||||||
|
Lifted: midnight().Add(time.Hour),
|
||||||
|
}})
|
||||||
|
|
||||||
|
now := midnight().Add(2 * time.Hour)
|
||||||
|
|
||||||
|
_, banned, _ := ledger.Find(netblock.Addr(), now)
|
||||||
|
if banned {
|
||||||
|
t.Error("the lifted ban refuses")
|
||||||
|
}
|
||||||
|
|
||||||
|
active, permanent := ledger.Count(now)
|
||||||
|
if active != 0 || permanent != 0 {
|
||||||
|
t.Errorf("%d bans are active and %d permanent, want none", active, permanent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next clear sign of attack bans for seven days, as a first does.
|
||||||
|
ban, _ := ledger.BanForAttack(netblock, now, bans.Notes{})
|
||||||
|
if ban.Expires.Sub(ban.Start) != 7*day {
|
||||||
|
t.Errorf("the next ban for an attack ends at %s, want seven days on", ban.Expires)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
||||||
|
bans.Notes{})
|
||||||
|
atStart := bans.Ban{
|
||||||
|
Netblock: netip.MustParsePrefix("203.0.113.2/32"),
|
||||||
|
Start: midnight(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bans read at the start were made before it.
|
||||||
|
ledger.Load([]bans.Ban{made, atStart})
|
||||||
|
|
||||||
|
if got := ledger.Made(bans.CauseAdmin); got != 0 {
|
||||||
|
t.Fatalf("%d bans made by an admin after the start's, want none", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin keeps the ban smallwebwaf made, keeps the one read at the
|
||||||
|
// start, and adds one without a cause: that one alone is made.
|
||||||
|
kept := made
|
||||||
|
kept.Cause = bans.CauseAdmin
|
||||||
|
added := bans.Ban{
|
||||||
|
Netblock: netip.MustParsePrefix("203.0.113.3/32"),
|
||||||
|
Start: midnight(),
|
||||||
|
}
|
||||||
|
ledger.LoadEdit([]bans.Ban{kept, atStart, added})
|
||||||
|
|
||||||
|
if ledger.Made(bans.CauseAdmin) != 1 || ledger.Made(bans.CauseLimit) != 1 {
|
||||||
|
t.Errorf("%d bans made by an admin and %d for a limit, want 1 of each",
|
||||||
|
ledger.Made(bans.CauseAdmin), ledger.Made(bans.CauseLimit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
|
||||||
|
// An hour's ban for a broken limit.
|
||||||
|
ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
|
wantChanged(t, ledger, true)
|
||||||
|
|
||||||
|
// A minute later an admin bans the netblock for good, named by an
|
||||||
|
// address in it: that ban is made, and counts the other among the
|
||||||
|
// earlier bans.
|
||||||
|
now := midnight().Add(time.Minute)
|
||||||
|
want := bans.Ban{
|
||||||
|
Netblock: netblock,
|
||||||
|
Start: now,
|
||||||
|
Cause: bans.CauseAdmin,
|
||||||
|
Reason: "probes for logins",
|
||||||
|
Notes: bans.Notes{EarlierBans: bans.EarlierBans{Limit: 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.9/24"), now, time.Time{},
|
||||||
|
"probes for logins")
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("the admin's ban is\n%+v\nwant\n%+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantChanged(t, ledger, true)
|
||||||
|
|
||||||
|
if made := ledger.Made(bans.CauseAdmin); made != 1 {
|
||||||
|
t.Errorf("%d bans made by an admin, want 1", made)
|
||||||
|
}
|
||||||
|
|
||||||
|
// It refuses once the ban for the limit has ended.
|
||||||
|
ban, banned, _ := ledger.Find(netblock.Addr(), midnight().Add(2*time.Hour))
|
||||||
|
if !banned || ban != want {
|
||||||
|
t.Errorf("after the limit's ban the netblock is under %+v (%t), want %+v",
|
||||||
|
ban, banned, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := netip.MustParseAddr("203.0.113.9")
|
||||||
|
own := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
wide := netip.MustParsePrefix("203.0.113.0/24")
|
||||||
|
other := netip.MustParsePrefix("203.0.113.10/32")
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
ledger.Load([]bans.Ban{
|
||||||
|
// Ended an hour ago.
|
||||||
|
{
|
||||||
|
Netblock: own, Start: midnight().Add(-2 * time.Hour),
|
||||||
|
Expires: midnight().Add(-time.Hour), Cause: bans.CauseLimit,
|
||||||
|
},
|
||||||
|
// Active, on the client's address and on its /24.
|
||||||
|
{
|
||||||
|
Netblock: own, Start: midnight(), Expires: midnight().Add(time.Hour),
|
||||||
|
Cause: bans.CauseLimit,
|
||||||
|
},
|
||||||
|
{Netblock: wide, Start: midnight(), Cause: bans.CauseAdmin},
|
||||||
|
// Another client's.
|
||||||
|
{Netblock: other, Start: midnight(), Cause: bans.CauseAdmin},
|
||||||
|
})
|
||||||
|
|
||||||
|
now := midnight().Add(time.Minute)
|
||||||
|
|
||||||
|
lifted := ledger.Lift(client, now)
|
||||||
|
if len(lifted) != 2 || lifted[0].Lifted != now || lifted[1].Lifted != now {
|
||||||
|
t.Errorf("lifted %+v, want the two active bans covering the client", lifted)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantChanged(t, ledger, true)
|
||||||
|
|
||||||
|
if _, banned, _ := ledger.Check(client, now); banned {
|
||||||
|
t.Error("the client is still banned")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, banned, _ := ledger.Check(other.Addr(), now); !banned {
|
||||||
|
t.Error("the other client's ban was lifted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lifted bans are kept, and the one that had ended is not lifted.
|
||||||
|
covering := ledger.Covering(client)
|
||||||
|
if len(covering) != 3 || covering[0].Netblock != wide ||
|
||||||
|
!covering[1].Lifted.IsZero() || covering[2].Lifted != now {
|
||||||
|
t.Errorf("the bans covering the client are %+v, want the /24's and both "+
|
||||||
|
"of its own, the earlier not lifted", covering)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With none active, nothing is lifted or changed.
|
||||||
|
if lifted = ledger.Lift(client, now); len(lifted) != 0 {
|
||||||
|
t.Errorf("lifted %+v again", lifted)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantChanged(t, ledger, false)
|
||||||
|
}
|
||||||
+544
-106
@@ -1,10 +1,13 @@
|
|||||||
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
||||||
// netblocks of clients that break a rate limit, with their notes, as the
|
// netblocks of clients that break a rate limit or a byte limit or show a
|
||||||
// "Bans" section of SPEC.md describes. The bans are kept in memory, and
|
// clear sign of attack, and those an admin makes, with their notes, as
|
||||||
// written to bans.json and read from it by the state package.
|
// the "Bans" section of SPEC.md describes. The bans are kept in memory,
|
||||||
|
// and written to bans.json and read from it by the state package.
|
||||||
package bans
|
package bans
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,6 +17,17 @@ import (
|
|||||||
"github.com/hashicorp/golang-lru/v2/simplelru"
|
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The causes of bans.
|
||||||
|
const (
|
||||||
|
// CauseLimit is a ban smallwebwaf made for a broken limit.
|
||||||
|
CauseLimit = "limit"
|
||||||
|
// CauseAttack is a ban smallwebwaf made for a clear sign of attack.
|
||||||
|
CauseAttack = "attack"
|
||||||
|
// CauseAdmin is a ban an admin made, or one smallwebwaf made that an
|
||||||
|
// admin keeps. It is never dropped.
|
||||||
|
CauseAdmin = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
// repeatFactor is how many times as long as the netblock's last ban a ban
|
// repeatFactor is how many times as long as the netblock's last ban a ban
|
||||||
// for a limit broken again within the repeat window lasts.
|
// for a limit broken again within the repeat window lasts.
|
||||||
const repeatFactor = 3
|
const repeatFactor = 3
|
||||||
@@ -21,31 +35,43 @@ const repeatFactor = 3
|
|||||||
// maxTextBytes is how much of each text in a ban's notes is kept.
|
// maxTextBytes is how much of each text in a ban's notes is kept.
|
||||||
const maxTextBytes = 256
|
const maxTextBytes = 256
|
||||||
|
|
||||||
// Rules are how long a ban for a broken limit lasts, and how many bans
|
// Rules are how long a ban lasts, and how many bans are held.
|
||||||
// are held.
|
|
||||||
type Rules struct {
|
type Rules struct {
|
||||||
// LimitBanDuration is how long a first ban lasts.
|
// LimitBanDuration is how long a first ban for a broken limit lasts.
|
||||||
LimitBanDuration time.Duration
|
LimitBanDuration time.Duration
|
||||||
// LimitBanRepeatWindow is how soon after the netblock's last ban
|
// LimitBanRepeatWindow is how soon after the end of the netblock's
|
||||||
// ended a broken limit counts as a repeat, which bans for
|
// ban that ended last, other than one for a clear sign of attack, a
|
||||||
// repeatFactor times as long as that ban.
|
// broken limit counts as a repeat, which bans for repeatFactor times as
|
||||||
|
// long as that ban.
|
||||||
LimitBanRepeatWindow time.Duration
|
LimitBanRepeatWindow time.Duration
|
||||||
// MaxBanDuration is the longest ban; a ban that would be longer is
|
// MaxBanDuration is the longest ban for a broken limit; one that would
|
||||||
// permanent instead.
|
// be longer is permanent instead.
|
||||||
MaxBanDuration time.Duration
|
MaxBanDuration time.Duration
|
||||||
// MaxBans is the most bans held, at least one. Past it, the earliest
|
// AttackBanDuration is how long a first ban for a clear sign of attack
|
||||||
// ban of the netblock that has gone longest without a request is
|
// lasts.
|
||||||
// dropped.
|
AttackBanDuration time.Duration
|
||||||
|
// MaxBans is the most bans held whose cause is not CauseAdmin, at
|
||||||
|
// least one. Past it, the earliest such ban of the netblock that has
|
||||||
|
// gone longest without a request is dropped. Bans whose cause is
|
||||||
|
// CauseAdmin are held besides, and never dropped.
|
||||||
MaxBans int
|
MaxBans int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ban is a ban on a netblock for a broken limit, the only kind of ban
|
// Ban is a ban on a netblock.
|
||||||
// smallwebwaf makes so far.
|
|
||||||
type Ban struct {
|
type Ban struct {
|
||||||
Netblock netip.Prefix
|
Netblock netip.Prefix
|
||||||
Start time.Time
|
Start time.Time
|
||||||
// Expires is when the ban ends, zero for a permanent ban.
|
// Expires is when the ban ends, zero for a permanent ban.
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
|
// Cause is CauseLimit, CauseAttack or CauseAdmin.
|
||||||
|
Cause string
|
||||||
|
// Reason is a short text: for a ban smallwebwaf made, the limit broken
|
||||||
|
// or the rule that matched; for an admin's, what the admin wrote.
|
||||||
|
Reason string
|
||||||
|
// Lifted is when an admin lifted the ban, zero while no admin has. A
|
||||||
|
// lifted ban refuses nothing, and does not make the netblock's next
|
||||||
|
// ban longer.
|
||||||
|
Lifted time.Time
|
||||||
Notes Notes
|
Notes Notes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +80,10 @@ func (b Ban) Permanent() bool {
|
|||||||
return b.Expires.IsZero()
|
return b.Expires.IsZero()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActiveAt reports whether the ban refuses requests at now.
|
// ActiveAt reports whether the ban refuses requests at now: it has not
|
||||||
|
// been lifted, and has not run out.
|
||||||
func (b Ban) ActiveAt(now time.Time) bool {
|
func (b Ban) ActiveAt(now time.Time) bool {
|
||||||
return b.Permanent() || now.Before(b.Expires)
|
return b.Lifted.IsZero() && (b.Permanent() || now.Before(b.Expires))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notes are what an admin needs to decide whether to lift a ban. The
|
// Notes are what an admin needs to decide whether to lift a ban. The
|
||||||
@@ -64,25 +91,51 @@ func (b Ban) ActiveAt(now time.Time) bool {
|
|||||||
//
|
//
|
||||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||||
type Notes struct {
|
type Notes struct {
|
||||||
// Country is the client's country, when it was looked up.
|
// ASN, ASName and Country are the client's AS number, AS name and
|
||||||
|
// country, when they were looked up: when the request that caused the
|
||||||
|
// ban was made, or when GeoJS answered about the client afterwards.
|
||||||
|
ASN string `json:"asn"`
|
||||||
|
ASName string `json:"as_name"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
// Limit, Window and Count are the limit that was broken, its window,
|
// Kind, Limit, Window and Count are, for a ban for a broken limit,
|
||||||
// "minute", "hour" or "day", and the count reached: the client's
|
// what the limit was on, "requests" for a rate limit or "bytes" for a
|
||||||
// requests in the window, the one that broke the limit included.
|
// byte limit, the limit that was broken, its window, "minute", "hour"
|
||||||
// These are the requests that counted toward the ban, and the window
|
// or "day", and the count reached: the client's requests, or bytes, in
|
||||||
// is the time over which they came.
|
// the window, those of the request that broke the limit included.
|
||||||
Limit int64 `json:"limit"`
|
// These are what counted toward the ban, and the window is the time
|
||||||
Window string `json:"window"`
|
// over which they came.
|
||||||
Count float64 `json:"count"`
|
Kind string `json:"kind,omitempty"`
|
||||||
// Request is the request that broke the limit.
|
Limit int64 `json:"limit,omitempty"`
|
||||||
|
Window string `json:"window,omitempty"`
|
||||||
|
Count float64 `json:"count,omitempty"`
|
||||||
|
// LimitPercent and LimitPercentSetting are, for a ban for a limit a
|
||||||
|
// biased threshold lowered, the client's percentage of that kind of
|
||||||
|
// limit, of which Limit is the result, and the setting that gave it.
|
||||||
|
// Both are left out for a limit that was not lowered.
|
||||||
|
LimitPercent *int64 `json:"limit_percent,omitempty"`
|
||||||
|
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
||||||
|
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
||||||
|
// of the rule file rule that matched, and its target.
|
||||||
|
RuleID string `json:"rule_id,omitempty"`
|
||||||
|
Target string `json:"target,omitempty"`
|
||||||
|
// Request is the request that broke the limit, or whose bytes broke
|
||||||
|
// it, or that was the clear sign of attack.
|
||||||
Request Request `json:"request"`
|
Request Request `json:"request"`
|
||||||
// Requests is how many requests the netblock has sent since it was
|
// Requests is how many requests the netblock has sent since it was
|
||||||
// first seen, and Refused how many of them the ban has refused so
|
// first seen, and Refused how many of them the ban has refused so
|
||||||
// far. Both go up with each request the ban refuses.
|
// far. Both go up with each request the ban refuses.
|
||||||
Requests int64 `json:"requests"`
|
Requests int64 `json:"requests"`
|
||||||
Refused int64 `json:"refused"`
|
Refused int64 `json:"refused"`
|
||||||
// EarlierBans is how many bans the netblock had before this one.
|
// EarlierBans is how many bans the netblock had before this one, by
|
||||||
EarlierBans int `json:"earlier_bans"`
|
// cause.
|
||||||
|
EarlierBans EarlierBans `json:"earlier_bans"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EarlierBans counts a netblock's bans before a ban, by cause.
|
||||||
|
type EarlierBans struct {
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Attack int `json:"attack"`
|
||||||
|
Admin int `json:"admin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request is a request in a ban's notes. Each text is cut to 256 bytes.
|
// Request is a request in a ban's notes. Each text is cut to 256 bytes.
|
||||||
@@ -107,13 +160,16 @@ type Ledger struct {
|
|||||||
changed chan struct{}
|
changed chan struct{}
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
// netblocks holds each banned netblock's bans, oldest first. Check
|
// netblocks holds each banned netblock's bans, oldest first. Check and
|
||||||
// makes each netblock it finds the most recently seen.
|
// Find make each netblock they find the most recently seen.
|
||||||
netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
|
netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
|
||||||
// held is how many bans netblocks holds, at most rules.MaxBans.
|
// held is how many bans netblocks holds whose cause is not CauseAdmin,
|
||||||
|
// at most rules.MaxBans.
|
||||||
held int
|
held int
|
||||||
// made is how many bans BanForLimit has made since the start.
|
// made is how many bans have been made since the start, by cause: by
|
||||||
made int
|
// the ledger, and by an admin, through BanForAdmin or in an edit of
|
||||||
|
// bans.json.
|
||||||
|
made map[string]int
|
||||||
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
// 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
|
||||||
@@ -124,9 +180,10 @@ type Ledger struct {
|
|||||||
|
|
||||||
// New returns a Ledger with no ban yet.
|
// New returns a Ledger with no ban yet.
|
||||||
func New(rules Rules) *Ledger {
|
func New(rules Rules) *Ledger {
|
||||||
// Every netblock held has a ban, so there are never more netblocks
|
// The ledger drops bans itself, and never those whose cause is
|
||||||
// than rules.MaxBans, and the LRU never drops one itself.
|
// CauseAdmin, however many there are, so the LRU has no limit of its
|
||||||
netblocks, err := simplelru.NewLRU[netip.Prefix, *[]Ban](rules.MaxBans, nil)
|
// own: it keeps the netblocks in the order they were last seen.
|
||||||
|
netblocks, err := simplelru.NewLRU[netip.Prefix, *[]Ban](math.MaxInt, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err) // NewLRU fails only for a size below one
|
panic(err) // NewLRU fails only for a size below one
|
||||||
}
|
}
|
||||||
@@ -135,89 +192,226 @@ func New(rules Rules) *Ledger {
|
|||||||
rules: rules,
|
rules: rules,
|
||||||
changed: make(chan struct{}, 1),
|
changed: make(chan struct{}, 1),
|
||||||
netblocks: netblocks,
|
netblocks: netblocks,
|
||||||
|
made: map[string]int{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Changed receives a value after a ban is made, so that bans.json can be
|
// Changed receives a value after a ban is made, lifted or made permanent,
|
||||||
// written. Several bans made before it is read leave one value.
|
// so that bans.json can be written. Several changes before it is read
|
||||||
|
// leave one value.
|
||||||
func (l *Ledger) Changed() <-chan struct{} {
|
func (l *Ledger) Changed() <-chan struct{} {
|
||||||
return l.changed
|
return l.changed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check is called for each request from client, at now. It reports
|
// Check is called for a request from client, at now. It reports whether
|
||||||
// whether a ban on a netblock client is in is active, and returns that
|
// a ban on a netblock client is in is active, and returns that ban, with
|
||||||
// ban, with the request counted among those it refused.
|
// the request counted among those it refused. A ban for a clear sign of
|
||||||
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
|
// attack is made permanent by the request: the netblock is malicious.
|
||||||
|
// The last result reports whether the request made the ban permanent.
|
||||||
|
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool, bool) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
lengths := l.v6Lengths
|
ban := l.active(client, now)
|
||||||
if client.Is4() {
|
if ban == nil {
|
||||||
lengths = l.v4Lengths
|
return Ban{}, false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, length := range lengths {
|
ban.Notes.Requests++
|
||||||
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
|
ban.Notes.Refused++
|
||||||
if !found {
|
|
||||||
continue
|
madePermanent := ban.Cause == CauseAttack && !ban.Permanent()
|
||||||
|
if madePermanent {
|
||||||
|
ban.Expires = time.Time{}
|
||||||
|
|
||||||
|
l.markChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// A ban is made only once the one before has ended, so only the
|
return *ban, true, madePermanent
|
||||||
// last can be active.
|
}
|
||||||
last := &(*bans)[len(*bans)-1]
|
|
||||||
if last.ActiveAt(now) {
|
|
||||||
last.Notes.Requests++
|
|
||||||
last.Notes.Refused++
|
|
||||||
|
|
||||||
return *last, true
|
// Find is Check without counting the request among those the ban
|
||||||
|
// refused, and without making the ban permanent: in observe mode a ban
|
||||||
|
// refuses nothing. The last result reports whether Check would have made
|
||||||
|
// the ban permanent.
|
||||||
|
func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool, bool) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
ban := l.active(client, now)
|
||||||
|
if ban == nil {
|
||||||
|
return Ban{}, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return *ban, true, ban.Cause == CauseAttack && !ban.Permanent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeBan returns the ban in bans, a netblock's bans oldest first, that
|
||||||
|
// is active at now, or nil when none is. If several are, it returns the
|
||||||
|
// one that started last. Every ban is looked at, since a ban an admin adds
|
||||||
|
// to bans.json can start before the netblock's others and outlast them.
|
||||||
|
func activeBan(bans []Ban, now time.Time) *Ban {
|
||||||
|
for i := len(bans) - 1; i >= 0; i-- {
|
||||||
|
if bans[i].ActiveAt(now) {
|
||||||
|
return &bans[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ban{}, false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BanForLimit bans netblock at now for a broken limit, with notes, and
|
// BanForLimit bans netblock at now for a broken limit, with notes, and
|
||||||
// returns the ban. A first ban lasts LimitBanDuration. A ban made within
|
// returns the ban, and true. A first ban lasts LimitBanDuration. A ban
|
||||||
// LimitBanRepeatWindow after the netblock's last ban ended lasts
|
// made within LimitBanRepeatWindow after the netblock's ban that ended
|
||||||
|
// last, other than one for a clear sign of attack or a lifted one, lasts
|
||||||
// repeatFactor times as long as that one. A ban that would be longer
|
// repeatFactor times as long as that one. A ban that would be longer
|
||||||
// than MaxBanDuration is permanent instead. If a ban on netblock is still
|
// than MaxBanDuration is permanent instead. If a ban on netblock is still
|
||||||
// active, as when two of its requests break a limit at once, that ban is
|
// active, as when two of its requests break a limit at once, that ban is
|
||||||
// returned and no other is made. The ledger fills in the notes' Refused
|
// returned with false, and no other is made. The ledger fills in the
|
||||||
// and EarlierBans itself.
|
// notes' Refused and EarlierBans itself, and gives the ban the reason
|
||||||
func (l *Ledger) BanForLimit(netblock netip.Prefix, now time.Time, notes Notes) Ban {
|
// "<Kind> per <Window> over the limit of <Limit>", from the notes, such
|
||||||
|
// as "requests per minute over the limit of 1000".
|
||||||
|
func (l *Ledger) BanForLimit(
|
||||||
|
netblock netip.Prefix, now time.Time, notes Notes,
|
||||||
|
) (Ban, bool) {
|
||||||
|
return l.ban(netblock, now, CauseLimit, limitReason(notes), notes, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WouldBanForLimit returns what BanForLimit would, without making the ban:
|
||||||
|
// what observe mode would have done.
|
||||||
|
func (l *Ledger) WouldBanForLimit(
|
||||||
|
netblock netip.Prefix, now time.Time, notes Notes,
|
||||||
|
) (Ban, bool) {
|
||||||
|
return l.ban(netblock, now, CauseLimit, limitReason(notes), notes, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BanForAttack bans netblock at now for a clear sign of attack, with
|
||||||
|
// notes, and returns the ban, and whether it made it, as BanForLimit
|
||||||
|
// does. A first ban lasts AttackBanDuration; once the netblock has had
|
||||||
|
// one that was not lifted, the next is permanent. Its reason is "matched
|
||||||
|
// the rule <RuleID>".
|
||||||
|
func (l *Ledger) BanForAttack(
|
||||||
|
netblock netip.Prefix, now time.Time, notes Notes,
|
||||||
|
) (Ban, bool) {
|
||||||
|
return l.ban(netblock, now, CauseAttack, attackReason(notes), notes, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WouldBanForAttack returns what BanForAttack would, without making the
|
||||||
|
// ban: what observe mode would have done.
|
||||||
|
func (l *Ledger) WouldBanForAttack(
|
||||||
|
netblock netip.Prefix, now time.Time, notes Notes,
|
||||||
|
) (Ban, bool) {
|
||||||
|
return l.ban(netblock, now, CauseAttack, attackReason(notes), notes, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WouldBePermanent reports whether a ban on netblock for cause, CauseLimit
|
||||||
|
// or CauseAttack, made at now would be permanent, as BanForLimit or
|
||||||
|
// BanForAttack would make it. It works out nothing else of the ban.
|
||||||
|
func (l *Ledger) WouldBePermanent(
|
||||||
|
netblock netip.Prefix, now time.Time, cause string,
|
||||||
|
) bool {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
var last *Ban
|
var held []Ban
|
||||||
|
if bans, found := l.netblocks.Peek(netblock); found {
|
||||||
bans, found := l.netblocks.Get(netblock)
|
held = *bans
|
||||||
if found {
|
|
||||||
last = &(*bans)[len(*bans)-1]
|
|
||||||
if last.ActiveAt(now) {
|
|
||||||
return *last
|
|
||||||
}
|
}
|
||||||
|
|
||||||
notes.EarlierBans = last.Notes.EarlierBans + 1
|
if cause == CauseAttack {
|
||||||
|
return l.attackExpiry(held, now).IsZero()
|
||||||
}
|
}
|
||||||
|
|
||||||
notes.Request = notes.Request.cut()
|
return l.limitExpiry(held, now).IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitReason is the reason of a ban for a broken limit, with notes.
|
||||||
|
func limitReason(notes Notes) string {
|
||||||
|
return fmt.Sprintf("%s per %s over the limit of %d",
|
||||||
|
notes.Kind, notes.Window, notes.Limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// attackReason is the reason of a ban for a clear sign of attack, with
|
||||||
|
// notes.
|
||||||
|
func attackReason(notes Notes) string {
|
||||||
|
return "matched the rule " + notes.RuleID
|
||||||
|
}
|
||||||
|
|
||||||
|
// BanForAdmin bans netblock at now for an admin, with reason, until
|
||||||
|
// expires, or for good when expires is zero, and returns the ban, whose
|
||||||
|
// cause is CauseAdmin. Unlike BanForLimit and BanForAttack, it makes the
|
||||||
|
// ban even while another on netblock is active, since the admin asked
|
||||||
|
// for this one. The ledger fills in the notes' EarlierBans, and counts
|
||||||
|
// the ban among those made.
|
||||||
|
func (l *Ledger) BanForAdmin(
|
||||||
|
netblock netip.Prefix, now, expires time.Time, reason string,
|
||||||
|
) Ban {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
ban := Ban{
|
ban := Ban{
|
||||||
Netblock: netblock,
|
Netblock: netblock.Masked(), Start: now, Expires: expires, Cause: CauseAdmin,
|
||||||
Start: now,
|
Reason: reason,
|
||||||
Expires: l.expiry(last, now),
|
|
||||||
Notes: notes,
|
|
||||||
}
|
}
|
||||||
l.add(ban)
|
|
||||||
l.made++
|
|
||||||
|
|
||||||
select {
|
held, found := l.netblocks.Get(ban.Netblock)
|
||||||
case l.changed <- struct{}{}:
|
if found {
|
||||||
default: // a value is waiting already
|
ban.Notes.EarlierBans = earlierBans(*held)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
l.add(ban)
|
||||||
|
l.made[CauseAdmin]++
|
||||||
|
l.markChanged()
|
||||||
|
|
||||||
return ban
|
return ban
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lift lifts, at now, every ban active then on a netblock client is in,
|
||||||
|
// as an admin does, and returns those bans. A lifted ban is kept, refuses
|
||||||
|
// nothing, and does not make the netblock's next ban longer.
|
||||||
|
func (l *Ledger) Lift(client netip.Addr, now time.Time) []Ban {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
var lifted []Ban
|
||||||
|
|
||||||
|
for _, bans := range l.covering(client) {
|
||||||
|
for i := range *bans {
|
||||||
|
ban := &(*bans)[i]
|
||||||
|
if ban.ActiveAt(now) {
|
||||||
|
ban.Lifted = now
|
||||||
|
lifted = append(lifted, *ban)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lifted) > 0 {
|
||||||
|
l.markChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
return lifted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Covering returns every ban held on a netblock client is in, active or
|
||||||
|
// not, sorted by netblock, and each netblock's bans oldest first. It is
|
||||||
|
// not a request from client, and leaves when the netblocks were last seen
|
||||||
|
// unchanged.
|
||||||
|
func (l *Ledger) Covering(client netip.Addr) []Ban {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
var held []Ban
|
||||||
|
for _, bans := range l.covering(client) {
|
||||||
|
held = append(held, *bans...)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortStableFunc(held, func(a, b Ban) int {
|
||||||
|
return a.Netblock.Compare(b.Netblock)
|
||||||
|
})
|
||||||
|
|
||||||
|
return held
|
||||||
|
}
|
||||||
|
|
||||||
// Bans returns the bans held on netblock, oldest first. It is not a
|
// Bans returns the bans held on netblock, oldest first. It is not a
|
||||||
// request from netblock, and leaves when it was last seen unchanged.
|
// request from netblock, and leaves when it was last seen unchanged.
|
||||||
func (l *Ledger) Bans(netblock netip.Prefix) []Ban {
|
func (l *Ledger) Bans(netblock netip.Prefix) []Ban {
|
||||||
@@ -232,17 +426,41 @@ func (l *Ledger) Bans(netblock netip.Prefix) []Ban {
|
|||||||
return slices.Clone(*bans)
|
return slices.Clone(*bans)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Made returns how many bans the ledger has made since the start; bans
|
// AddLookup gives the notes of netblock's bans that have no AS number, AS
|
||||||
// read from bans.json are not among them.
|
// name or country yet those of a client in it, as the lookup answered
|
||||||
func (l *Ledger) Made() int {
|
// about it. It is not a request from netblock, and leaves when it was last
|
||||||
|
// seen unchanged. It does not have bans.json written at once: the notes
|
||||||
|
// are written with its next write, as the counts in them are.
|
||||||
|
func (l *Ledger) AddLookup(netblock netip.Prefix, asn, asName, country string) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
return l.made
|
bans, found := l.netblocks.Peek(netblock)
|
||||||
|
if !found {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range *bans {
|
||||||
|
notes := &(*bans)[i].Notes
|
||||||
|
if notes.ASN == "" && notes.ASName == "" && notes.Country == "" {
|
||||||
|
notes.ASN, notes.ASName, notes.Country = asn, asName, country
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Made returns how many bans for cause have been made since the start:
|
||||||
|
// for CauseLimit and CauseAttack, by the ledger; for CauseAdmin, by an
|
||||||
|
// admin, with BanForAdmin or in an edit of bans.json, as LoadEdit counts
|
||||||
|
// them. The bans read from bans.json at the start are not among them.
|
||||||
|
func (l *Ledger) Made(cause string) int {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
return l.made[cause]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count returns how many of the bans held are active at now, and how many
|
// Count returns how many of the bans held are active at now, and how many
|
||||||
// are permanent.
|
// of those are permanent. A lifted ban is neither.
|
||||||
func (l *Ledger) Count(now time.Time) (int, int) {
|
func (l *Ledger) Count(now time.Time) (int, int) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
@@ -251,10 +469,12 @@ func (l *Ledger) Count(now time.Time) (int, int) {
|
|||||||
|
|
||||||
for _, bans := range l.netblocks.Values() {
|
for _, bans := range l.netblocks.Values() {
|
||||||
for _, ban := range *bans {
|
for _, ban := range *bans {
|
||||||
if ban.ActiveAt(now) {
|
if !ban.ActiveAt(now) {
|
||||||
active++
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
active++
|
||||||
|
|
||||||
if ban.Permanent() {
|
if ban.Permanent() {
|
||||||
permanent++
|
permanent++
|
||||||
}
|
}
|
||||||
@@ -282,32 +502,199 @@ func (l *Ledger) Snapshot() []Ban {
|
|||||||
return held
|
return held
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load puts bans read from bans.json into a ledger that holds none yet,
|
// Load puts bans read from bans.json at the start into the ledger, in
|
||||||
// in the order they started, so that a netblock whose last ban started
|
// place of the bans it holds, in the order they started, so that a
|
||||||
// latest counts as the most recently seen. Each netblock is masked to its
|
// netblock whose last ban started latest counts as the most recently
|
||||||
// length, so that 203.0.113.9/24 is 203.0.113.0/24, and each text in the
|
// seen. A ban without a cause is an admin's, and gets CauseAdmin. Each
|
||||||
// notes is cut to 256 bytes. Past MaxBans the earliest bans are dropped,
|
// netblock is masked to its length, so that 203.0.113.9/24 is
|
||||||
// as when they are made.
|
// 203.0.113.0/24, and each text in the notes is cut to 256 bytes. Past
|
||||||
|
// MaxBans the earliest bans whose cause is not CauseAdmin are dropped, as
|
||||||
|
// when they are made.
|
||||||
func (l *Ledger) Load(bans []Ban) {
|
func (l *Ledger) Load(bans []Ban) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
l.load(bans)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// the ledger did not hold, with the same netblock and start, is one the
|
||||||
|
// admin made, and is counted among the bans made.
|
||||||
|
func (l *Ledger) LoadEdit(bans []Ban) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
l.made[CauseAdmin] += l.load(bans)
|
||||||
|
}
|
||||||
|
|
||||||
|
// load does what Load describes, and returns how many of bans are bans
|
||||||
|
// whose cause is CauseAdmin that the ledger did not hold before.
|
||||||
|
func (l *Ledger) load(bans []Ban) int {
|
||||||
bans = slices.Clone(bans)
|
bans = slices.Clone(bans)
|
||||||
|
added := 0
|
||||||
|
|
||||||
|
for i := range bans {
|
||||||
|
ban := &bans[i]
|
||||||
|
ban.Netblock = ban.Netblock.Masked()
|
||||||
|
ban.Notes.Request = ban.Notes.Request.cut()
|
||||||
|
|
||||||
|
if ban.Cause == "" {
|
||||||
|
ban.Cause = CauseAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
if ban.Cause == CauseAdmin && !l.holds(ban.Netblock, ban.Start) {
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
slices.SortStableFunc(bans, func(a, b Ban) int {
|
slices.SortStableFunc(bans, func(a, b Ban) int {
|
||||||
return a.Start.Compare(b.Start)
|
return a.Start.Compare(b.Start)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
l.netblocks.Purge()
|
||||||
|
l.held = 0
|
||||||
|
l.v4Lengths, l.v6Lengths = nil, nil
|
||||||
|
|
||||||
for _, ban := range bans {
|
for _, ban := range bans {
|
||||||
ban.Netblock = ban.Netblock.Masked()
|
|
||||||
ban.Notes.Request = ban.Notes.Request.cut()
|
|
||||||
l.add(ban)
|
l.add(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return added
|
||||||
|
}
|
||||||
|
|
||||||
|
// holds reports whether the ledger holds a ban on netblock that started
|
||||||
|
// at start.
|
||||||
|
func (l *Ledger) holds(netblock netip.Prefix, start time.Time) bool {
|
||||||
|
bans, found := l.netblocks.Peek(netblock)
|
||||||
|
|
||||||
|
return found && slices.ContainsFunc(*bans, func(ban Ban) bool {
|
||||||
|
return ban.Start.Equal(start)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ban bans netblock at now for cause, with reason and notes, as
|
||||||
|
// BanForLimit and BanForAttack describe, and returns the ban, and whether
|
||||||
|
// it made it. Unless keep is true, the ban is not made, only returned: it
|
||||||
|
// is the ban that would have been made.
|
||||||
|
func (l *Ledger) ban(
|
||||||
|
netblock netip.Prefix, now time.Time, cause, reason string, notes Notes, keep bool,
|
||||||
|
) (Ban, bool) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
// held are the netblock's bans, none of them active.
|
||||||
|
var held []Ban
|
||||||
|
|
||||||
|
bans, found := l.netblocks.Get(netblock)
|
||||||
|
if found {
|
||||||
|
active := activeBan(*bans, now)
|
||||||
|
if active != nil {
|
||||||
|
return *active, false
|
||||||
|
}
|
||||||
|
|
||||||
|
held = *bans
|
||||||
|
notes.EarlierBans = earlierBans(held)
|
||||||
|
}
|
||||||
|
|
||||||
|
notes.Request = notes.Request.cut()
|
||||||
|
ban := Ban{Netblock: netblock, Start: now, Cause: cause, Reason: reason, Notes: notes}
|
||||||
|
|
||||||
|
if cause == CauseAttack {
|
||||||
|
ban.Expires = l.attackExpiry(held, now)
|
||||||
|
} else {
|
||||||
|
ban.Expires = l.limitExpiry(held, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !keep {
|
||||||
|
return ban, true
|
||||||
|
}
|
||||||
|
|
||||||
|
l.add(ban)
|
||||||
|
l.made[cause]++
|
||||||
|
l.markChanged()
|
||||||
|
|
||||||
|
return ban, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// earlierBans returns how many bans a netblock with the bans held, oldest
|
||||||
|
// first, has had, by cause: the first ban held counts the bans the
|
||||||
|
// netblock had before that one, since dropped to make room, and each ban
|
||||||
|
// held adds one.
|
||||||
|
func earlierBans(held []Ban) EarlierBans {
|
||||||
|
earlier := held[0].Notes.EarlierBans
|
||||||
|
|
||||||
|
for _, ban := range held {
|
||||||
|
switch ban.Cause {
|
||||||
|
case CauseLimit:
|
||||||
|
earlier.Limit++
|
||||||
|
case CauseAttack:
|
||||||
|
earlier.Attack++
|
||||||
|
case CauseAdmin:
|
||||||
|
earlier.Admin++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return earlier
|
||||||
|
}
|
||||||
|
|
||||||
|
// markChanged has Changed receive a value, unless one is waiting already.
|
||||||
|
func (l *Ledger) markChanged() {
|
||||||
|
select {
|
||||||
|
case l.changed <- struct{}{}:
|
||||||
|
default: // a value is waiting already
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// active returns the ban active at now on a netblock client is in, or
|
||||||
|
// nil.
|
||||||
|
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
||||||
|
lengths := l.v6Lengths
|
||||||
|
if client.Is4() {
|
||||||
|
lengths = l.v4Lengths
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, length := range lengths {
|
||||||
|
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ban := activeBan(*bans, now)
|
||||||
|
if ban != nil {
|
||||||
|
return ban
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// covering returns the bans of each netblock held that client is in,
|
||||||
|
// leaving when the netblocks were last seen unchanged.
|
||||||
|
func (l *Ledger) covering(client netip.Addr) []*[]Ban {
|
||||||
|
lengths := l.v6Lengths
|
||||||
|
if client.Is4() {
|
||||||
|
lengths = l.v4Lengths
|
||||||
|
}
|
||||||
|
|
||||||
|
var found []*[]Ban
|
||||||
|
|
||||||
|
for _, length := range lengths {
|
||||||
|
bans, ok := l.netblocks.Peek(netip.PrefixFrom(client, length).Masked())
|
||||||
|
if ok {
|
||||||
|
found = append(found, bans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
// add adds ban to its netblock's bans, after the last, and makes its
|
// add adds ban to its netblock's bans, after the last, and makes its
|
||||||
// netblock the most recently seen. With MaxBans held, it drops one first.
|
// netblock the most recently seen. With MaxBans held, it drops one first,
|
||||||
|
// unless ban's cause is CauseAdmin, which does not count toward MaxBans.
|
||||||
func (l *Ledger) add(ban Ban) {
|
func (l *Ledger) add(ban Ban) {
|
||||||
if l.held == l.rules.MaxBans {
|
counted := ban.Cause != CauseAdmin
|
||||||
|
if counted && l.held == l.rules.MaxBans {
|
||||||
l.dropOne()
|
l.dropOne()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +707,10 @@ func (l *Ledger) add(ban Ban) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
*bans = append(*bans, ban)
|
*bans = append(*bans, ban)
|
||||||
|
|
||||||
|
if counted {
|
||||||
l.held++
|
l.held++
|
||||||
|
}
|
||||||
|
|
||||||
lengths := &l.v6Lengths
|
lengths := &l.v6Lengths
|
||||||
if ban.Netblock.Addr().Is4() {
|
if ban.Netblock.Addr().Is4() {
|
||||||
@@ -332,12 +722,24 @@ func (l *Ledger) add(ban Ban) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// expiry returns when a ban for a broken limit made at now ends, or zero
|
// limitExpiry returns when a ban for a broken limit made at now ends, or
|
||||||
// when it is permanent. last is the netblock's last ban, which has ended,
|
// zero when it is permanent. held are the netblock's bans, none of them
|
||||||
// or nil when it has none.
|
// active, of which the one that ended last, other than a ban for a clear
|
||||||
func (l *Ledger) expiry(last *Ban, now time.Time) time.Time {
|
// sign of attack or a lifted one, can make the new ban longer. A ban an
|
||||||
|
// admin adds to bans.json can start after another and end before it, so
|
||||||
|
// that one is looked for among them all.
|
||||||
|
func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
|
||||||
length := l.rules.LimitBanDuration
|
length := l.rules.LimitBanDuration
|
||||||
|
|
||||||
|
var last *Ban
|
||||||
|
|
||||||
|
for i, ban := range held {
|
||||||
|
if ban.Cause != CauseAttack && ban.Lifted.IsZero() &&
|
||||||
|
(last == nil || ban.Expires.After(last.Expires)) {
|
||||||
|
last = &held[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow {
|
if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow {
|
||||||
lastLength := last.Expires.Sub(last.Start)
|
lastLength := last.Expires.Sub(last.Start)
|
||||||
// This is repeatFactor * lastLength > MaxBanDuration, written so
|
// This is repeatFactor * lastLength > MaxBanDuration, written so
|
||||||
@@ -356,17 +758,53 @@ func (l *Ledger) expiry(last *Ban, now time.Time) time.Time {
|
|||||||
return now.Add(length)
|
return now.Add(length)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dropOne drops the earliest ban of the netblock that has gone longest
|
// attackExpiry returns when a ban for a clear sign of attack made at now
|
||||||
// without a request, and the netblock with it if that was its only ban.
|
// ends. held are the netblock's bans, none of them active: if one of them
|
||||||
|
// is for a clear sign of attack too, and was not lifted, the new ban is
|
||||||
|
// permanent, and its end zero; otherwise it ends AttackBanDuration later.
|
||||||
|
func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time {
|
||||||
|
for _, ban := range held {
|
||||||
|
if ban.Cause == CauseAttack && ban.Lifted.IsZero() {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return now.Add(l.rules.AttackBanDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropOne drops the earliest ban whose cause is not CauseAdmin of the
|
||||||
|
// netblock that has gone longest without a request, of those that hold
|
||||||
|
// such a ban, and the netblock with it if that was its only ban. It is
|
||||||
|
// called with at least one such ban held. It looks at each netblock once
|
||||||
|
// at most, and drops nothing when none holds such a ban.
|
||||||
func (l *Ledger) dropOne() {
|
func (l *Ledger) dropOne() {
|
||||||
|
for range l.netblocks.Len() {
|
||||||
netblock, bans, _ := l.netblocks.GetOldest()
|
netblock, bans, _ := l.netblocks.GetOldest()
|
||||||
|
|
||||||
|
i := slices.IndexFunc(*bans, func(ban Ban) bool {
|
||||||
|
return ban.Cause != CauseAdmin
|
||||||
|
})
|
||||||
|
if i < 0 {
|
||||||
|
// Its bans are all an admin's, and never dropped. Get makes
|
||||||
|
// it the most recently seen, so that the next netblock is
|
||||||
|
// looked at; when it was seen matters only for dropping a
|
||||||
|
// ban, and a ban added to it makes it the most recently seen
|
||||||
|
// anyway.
|
||||||
|
l.netblocks.Get(netblock)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if len(*bans) == 1 {
|
if len(*bans) == 1 {
|
||||||
l.netblocks.Remove(netblock)
|
l.netblocks.Remove(netblock)
|
||||||
} else {
|
} else {
|
||||||
*bans = slices.Delete(*bans, 0, 1)
|
*bans = slices.Delete(*bans, i, i+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
l.held--
|
l.held--
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cut returns r with each text cut to maxTextBytes and copied, so that
|
// cut returns r with each text cut to maxTextBytes and copied, so that
|
||||||
|
|||||||
+281
-28
@@ -21,11 +21,12 @@ func TestRepeatsTripleUntilPermanent(t *testing.T) {
|
|||||||
// Each ban is followed by another as soon as it ends: 1, 3, 9, 27 and
|
// Each ban is followed by another as soon as it ends: 1, 3, 9, 27 and
|
||||||
// 81 hours.
|
// 81 hours.
|
||||||
for i, hours := range []int{1, 3, 9, 27, 81} {
|
for i, hours := range []int{1, 3, 9, 27, 81} {
|
||||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||||
|
|
||||||
length := time.Duration(hours) * time.Hour
|
length := time.Duration(hours) * time.Hour
|
||||||
if !ban.Expires.Equal(now.Add(length)) || ban.Notes.EarlierBans != i {
|
if !ban.Expires.Equal(now.Add(length)) ||
|
||||||
t.Fatalf("ban %d lasts %s with %d earlier bans, want %d hours and %d",
|
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: i}) {
|
||||||
|
t.Fatalf("ban %d lasts %s with earlier bans %+v, want %d hours and %d for a limit",
|
||||||
i+1, ban.Expires.Sub(now), ban.Notes.EarlierBans, hours, i)
|
i+1, ban.Expires.Sub(now), ban.Notes.EarlierBans, hours, i)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,12 +35,12 @@ func TestRepeatsTripleUntilPermanent(t *testing.T) {
|
|||||||
|
|
||||||
// The sixth would last 243 hours, more than seven days: it is
|
// The sixth would last 243 hours, more than seven days: it is
|
||||||
// permanent, and never ends.
|
// permanent, and never ends.
|
||||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||||
if !ban.Permanent() {
|
if !ban.Permanent() {
|
||||||
t.Fatalf("sixth ban ends at %s, want a permanent one", ban.Expires)
|
t.Fatalf("sixth ban ends at %s, want a permanent one", ban.Expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, banned := ledger.Check(netblock.Addr(), now.Add(100*365*day))
|
_, banned, _ := ledger.Check(netblock.Addr(), now.Add(100*365*day))
|
||||||
if !banned {
|
if !banned {
|
||||||
t.Error("a permanent ban ended")
|
t.Error("a permanent ban ended")
|
||||||
}
|
}
|
||||||
@@ -63,11 +64,12 @@ func TestRepeatWindowRunsOut(t *testing.T) {
|
|||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultRules())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
second := ledger.BanForLimit(netblock, first.Expires.Add(tc.gap), bans.Notes{})
|
second, _ := ledger.BanForLimit(netblock, first.Expires.Add(tc.gap), bans.Notes{})
|
||||||
|
|
||||||
if second.Expires.Sub(second.Start) != tc.want || second.Notes.EarlierBans != 1 {
|
if second.Expires.Sub(second.Start) != tc.want ||
|
||||||
t.Errorf("second ban lasts %s with %d earlier bans, want %s and 1",
|
second.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||||
|
t.Errorf("second ban lasts %s with earlier bans %+v, want %s and 1 for a limit",
|
||||||
second.Expires.Sub(second.Start), second.Notes.EarlierBans, tc.want)
|
second.Expires.Sub(second.Start), second.Notes.EarlierBans, tc.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -81,7 +83,7 @@ func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
|
|||||||
rules.LimitBanDuration = rules.MaxBanDuration + time.Hour
|
rules.LimitBanDuration = rules.MaxBanDuration + time.Hour
|
||||||
ledger := bans.New(rules)
|
ledger := bans.New(rules)
|
||||||
|
|
||||||
ban := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
|
ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
|
||||||
bans.Notes{})
|
bans.Notes{})
|
||||||
if !ban.Permanent() {
|
if !ban.Permanent() {
|
||||||
t.Errorf("first ban ends at %s, want a permanent one", ban.Expires)
|
t.Errorf("first ban ends at %s, want a permanent one", ban.Expires)
|
||||||
@@ -101,7 +103,7 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
|||||||
now := midnight()
|
now := midnight()
|
||||||
|
|
||||||
for i := range 14 {
|
for i := range 14 {
|
||||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||||
if !ban.Expires.After(ban.Start) {
|
if !ban.Expires.After(ban.Start) {
|
||||||
t.Fatalf("ban %d starts at %s and ends at %s", i+1, ban.Start, ban.Expires)
|
t.Fatalf("ban %d starts at %s and ends at %s", i+1, ban.Start, ban.Expires)
|
||||||
}
|
}
|
||||||
@@ -109,7 +111,7 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
|||||||
now = ban.Expires
|
now = ban.Expires
|
||||||
}
|
}
|
||||||
|
|
||||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||||
if !ban.Permanent() {
|
if !ban.Permanent() {
|
||||||
t.Errorf("15th ban ends at %s, want a permanent one", ban.Expires)
|
t.Errorf("15th ban ends at %s, want a permanent one", ban.Expires)
|
||||||
}
|
}
|
||||||
@@ -121,12 +123,22 @@ func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
|
|||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultRules())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
first, made := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
again := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
if !made {
|
||||||
|
t.Error("the first ban was not made")
|
||||||
|
}
|
||||||
|
|
||||||
if again != first || len(ledger.Bans(netblock)) != 1 {
|
again, made := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
||||||
t.Errorf("a limit broken during a ban gave %+v and %d bans, want %+v and 1",
|
|
||||||
again, len(ledger.Bans(netblock)), first)
|
if made || again != first || len(ledger.Bans(netblock)) != 1 {
|
||||||
|
t.Errorf("a limit broken during a ban gave %+v, made %t, and %d bans, "+
|
||||||
|
"want %+v, not made, and 1", again, made, len(ledger.Bans(netblock)), first)
|
||||||
|
}
|
||||||
|
|
||||||
|
again, made = ledger.BanForAttack(netblock, midnight().Add(time.Minute), bans.Notes{})
|
||||||
|
if made || again != first {
|
||||||
|
t.Errorf("an attack during a ban gave %+v, made %t, want %+v, not made",
|
||||||
|
again, made, first)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,21 +147,21 @@ func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
|
|||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultRules())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||||
|
|
||||||
for range 3 {
|
for range 3 {
|
||||||
got, banned := ledger.Check(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
got, banned, _ := ledger.Check(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
||||||
if !banned || got.Start != ban.Start {
|
if !banned || got.Start != ban.Start {
|
||||||
t.Fatalf("check during the ban gives %+v and %t", got, banned)
|
t.Fatalf("check during the ban gives %+v and %t", got, banned)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, banned := ledger.Check(netip.MustParseAddr("203.0.113.10"), midnight())
|
_, banned, _ := ledger.Check(netip.MustParseAddr("203.0.113.10"), midnight())
|
||||||
if banned {
|
if banned {
|
||||||
t.Error("another netblock is banned")
|
t.Error("another netblock is banned")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, banned = ledger.Check(netblock.Addr(), ban.Expires)
|
_, banned, _ = ledger.Check(netblock.Addr(), ban.Expires)
|
||||||
if banned {
|
if banned {
|
||||||
t.Error("the ban did not end")
|
t.Error("the ban did not end")
|
||||||
}
|
}
|
||||||
@@ -162,6 +174,28 @@ func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFindCountsNothing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||||
|
|
||||||
|
got, banned, _ := ledger.Find(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
||||||
|
if !banned || got != ban {
|
||||||
|
t.Errorf("find during the ban gives %+v and %t, want %+v", got, banned, ban)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, banned, _ = ledger.Find(netblock.Addr(), ban.Expires)
|
||||||
|
if banned {
|
||||||
|
t.Error("the ban did not end")
|
||||||
|
}
|
||||||
|
|
||||||
|
if notes := ledger.Bans(netblock)[0].Notes; notes != ban.Notes {
|
||||||
|
t.Errorf("the notes are %+v, want them unchanged, %+v", notes, ban.Notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -174,7 +208,7 @@ func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
|||||||
d := netip.MustParsePrefix("2001:db8::/64")
|
d := netip.MustParsePrefix("2001:db8::/64")
|
||||||
now := midnight()
|
now := midnight()
|
||||||
|
|
||||||
first := ledger.BanForLimit(a, now, bans.Notes{})
|
first, _ := ledger.BanForLimit(a, now, bans.Notes{})
|
||||||
ledger.BanForLimit(b, now, bans.Notes{})
|
ledger.BanForLimit(b, now, bans.Notes{})
|
||||||
ledger.BanForLimit(c, now, bans.Notes{})
|
ledger.BanForLimit(c, now, bans.Notes{})
|
||||||
|
|
||||||
@@ -209,13 +243,192 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
|
|||||||
ledger := bans.New(rules)
|
ledger := bans.New(rules)
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
second := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
second, _ := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
||||||
|
|
||||||
held := ledger.Bans(netblock)
|
held := ledger.Bans(netblock)
|
||||||
if len(held) != 1 || held[0] != second || held[0].Notes.EarlierBans != 1 {
|
if len(held) != 1 || held[0] != second ||
|
||||||
t.Errorf("the ledger holds %+v, want only the second ban, with 1 earlier ban",
|
held[0].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||||
held)
|
t.Errorf("the ledger holds %+v, want only the second ban, "+
|
||||||
|
"with 1 earlier ban for a limit", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
notes := bans.Notes{RuleID: "env-file", Target: "path"}
|
||||||
|
|
||||||
|
ban, _ := ledger.BanForAttack(netblock, midnight(), notes)
|
||||||
|
if !ban.Expires.Equal(midnight().Add(7*day)) || ban.Cause != bans.CauseAttack ||
|
||||||
|
ban.Notes.RuleID != "env-file" || ledger.Made(bans.CauseAttack) != 1 ||
|
||||||
|
ledger.Made(bans.CauseLimit) != 0 {
|
||||||
|
t.Fatalf("the ban is %+v, with %d made for an attack and %d for a limit, "+
|
||||||
|
"want one for an attack, of seven days", ban,
|
||||||
|
ledger.Made(bans.CauseAttack), ledger.Made(bans.CauseLimit))
|
||||||
|
}
|
||||||
|
|
||||||
|
wantChanged(t, ledger, true)
|
||||||
|
|
||||||
|
// In observe mode the ban refuses nothing, and stays as it is, while
|
||||||
|
// Find tells that the request would have made it permanent.
|
||||||
|
got, _, wouldMakePermanent := ledger.Find(netblock.Addr(), midnight().Add(time.Hour))
|
||||||
|
if got.Permanent() || ledger.Bans(netblock)[0].Permanent() || !wouldMakePermanent {
|
||||||
|
t.Fatalf("a request found under the ban left it %+v, would have made it "+
|
||||||
|
"permanent %t, want it as it was, and true", got, wouldMakePermanent)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantChanged(t, ledger, false)
|
||||||
|
|
||||||
|
// A request it refuses makes it permanent, says so, and makes
|
||||||
|
// bans.json due.
|
||||||
|
got, _, madePermanent := ledger.Check(netblock.Addr(), midnight().Add(time.Hour))
|
||||||
|
if !madePermanent || !got.Permanent() || !ledger.Bans(netblock)[0].Permanent() {
|
||||||
|
t.Fatalf("after a request during the ban, it is %+v, made permanent %t, "+
|
||||||
|
"want it made permanent", got, madePermanent)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantChanged(t, ledger, true)
|
||||||
|
|
||||||
|
// The next request finds it permanent already.
|
||||||
|
_, banned, madePermanent := ledger.Check(netblock.Addr(), midnight().Add(100*365*day))
|
||||||
|
if !banned || madePermanent {
|
||||||
|
t.Errorf("a later request is banned %t, and made the ban permanent %t, "+
|
||||||
|
"want banned by the permanent ban", banned, madePermanent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
|
// A ban for a broken limit before does not count.
|
||||||
|
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
|
second, _ := ledger.BanForAttack(netblock, first.Expires, bans.Notes{})
|
||||||
|
|
||||||
|
if second.Expires.Sub(second.Start) != 7*day {
|
||||||
|
t.Fatalf("the first ban for an attack lasts %s, want 7 days",
|
||||||
|
second.Expires.Sub(second.Start))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once that has run out without a request, the netblock is served, and
|
||||||
|
// its next clear sign of attack bans it for good.
|
||||||
|
_, banned, _ := ledger.Check(netblock.Addr(), second.Expires)
|
||||||
|
if banned {
|
||||||
|
t.Fatal("the ban did not end")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Its notes show the earlier ban for an attack that makes it permanent,
|
||||||
|
// beside the one for a limit.
|
||||||
|
third, _ := ledger.BanForAttack(netblock, second.Expires.Add(30*day), bans.Notes{})
|
||||||
|
if !third.Permanent() ||
|
||||||
|
third.Notes.EarlierBans != (bans.EarlierBans{Limit: 1, Attack: 1}) {
|
||||||
|
t.Errorf("the next ban for an attack is %+v, want a permanent one, "+
|
||||||
|
"with 1 earlier ban for a limit and 1 for an attack", third)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
|
wantChanged(t, ledger, true)
|
||||||
|
|
||||||
|
// While the first ban lasts, none would be made.
|
||||||
|
during, would := ledger.WouldBanForAttack(netblock, midnight(), bans.Notes{})
|
||||||
|
if would || during != first {
|
||||||
|
t.Errorf("during the first ban, would ban %t with %+v, want false with %+v",
|
||||||
|
would, during, first)
|
||||||
|
}
|
||||||
|
|
||||||
|
// As it ends, a clear sign of attack would ban for seven days, and a
|
||||||
|
// limit broken again for three hours, but neither is made.
|
||||||
|
limitNotes := bans.Notes{Kind: "requests", Limit: 1, Window: "minute"}
|
||||||
|
attack, wouldAttack := ledger.WouldBanForAttack(netblock, first.Expires,
|
||||||
|
bans.Notes{RuleID: "git-dir"})
|
||||||
|
limit, wouldLimit := ledger.WouldBanForLimit(netblock, first.Expires, limitNotes)
|
||||||
|
|
||||||
|
if !wouldAttack || !attack.Expires.Equal(first.Expires.Add(7*day)) ||
|
||||||
|
attack.Reason != "matched the rule git-dir" || !wouldLimit ||
|
||||||
|
!limit.Expires.Equal(first.Expires.Add(3*time.Hour)) ||
|
||||||
|
limit.Reason != "requests per minute over the limit of 1" {
|
||||||
|
t.Errorf("would ban with %+v and %+v, want seven days for the attack and "+
|
||||||
|
"three hours for the limit", attack, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ledger.Bans(netblock)) != 1 || ledger.Made(bans.CauseLimit) != 1 ||
|
||||||
|
ledger.Made(bans.CauseAttack) != 0 {
|
||||||
|
t.Errorf("the ledger holds %+v, want the first ban alone", ledger.Bans(netblock))
|
||||||
|
}
|
||||||
|
|
||||||
|
wantChanged(t, ledger, false)
|
||||||
|
|
||||||
|
// The ban made is the one that would have been.
|
||||||
|
made, _ := ledger.BanForLimit(netblock, first.Expires, limitNotes)
|
||||||
|
if made != limit {
|
||||||
|
t.Errorf("the ban made is %+v, want %+v", made, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
now := midnight()
|
||||||
|
|
||||||
|
// Five bans for a limit in a row, of 1, 3, 9, 27 and 81 hours, are not
|
||||||
|
// permanent. The sixth, of 243 hours, would be, while a first ban for
|
||||||
|
// an attack would not.
|
||||||
|
for i := range 5 {
|
||||||
|
if ledger.WouldBePermanent(netblock, now, bans.CauseLimit) {
|
||||||
|
t.Fatalf("ban %d for a limit would be permanent", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||||
|
now = ban.Expires
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ledger.WouldBePermanent(netblock, now, bans.CauseLimit) {
|
||||||
|
t.Error("the sixth ban for a limit would not be permanent")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ledger.WouldBePermanent(netblock, now, bans.CauseAttack) {
|
||||||
|
t.Error("a first ban for an attack would be permanent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once a first ban for an attack has ended, the next would be permanent.
|
||||||
|
attack, _ := ledger.BanForAttack(netblock, now, bans.Notes{})
|
||||||
|
if !ledger.WouldBePermanent(netblock, attack.Expires, bans.CauseAttack) {
|
||||||
|
t.Error("a second ban for an attack would not be permanent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
|
// Three times the seven days would be permanent; a limit broken as the
|
||||||
|
// ban for an attack ends bans for an hour, as a first broken limit does.
|
||||||
|
attack, _ := ledger.BanForAttack(netblock, midnight(), bans.Notes{})
|
||||||
|
limit, _ := ledger.BanForLimit(netblock, attack.Expires, bans.Notes{})
|
||||||
|
|
||||||
|
if limit.Expires.Sub(limit.Start) != time.Hour || limit.Cause != bans.CauseLimit {
|
||||||
|
t.Errorf("the ban for a limit is %+v, want one of an hour", limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a request during the ban for a limit leaves it as it is.
|
||||||
|
got, _, madePermanent := ledger.Check(netblock.Addr(), limit.Start)
|
||||||
|
if got.Permanent() || madePermanent {
|
||||||
|
t.Error("a request during a ban for a limit made it permanent")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +442,7 @@ func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
|
|||||||
Time: midnight(), Method: long, Host: long, Path: long, Status: 403, UserAgent: long,
|
Time: midnight(), Method: long, Host: long, Path: long, Status: 403, UserAgent: long,
|
||||||
}
|
}
|
||||||
|
|
||||||
ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Request: request})
|
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Request: request})
|
||||||
|
|
||||||
cut := long[:256]
|
cut := long[:256]
|
||||||
want := bans.Request{
|
want := bans.Request{
|
||||||
@@ -241,12 +454,52 @@ func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
other := netip.MustParsePrefix("198.51.100.7/32")
|
||||||
|
|
||||||
|
// A ban made with the client's lookup, one made before it came, after
|
||||||
|
// the first ended, and one on another netblock.
|
||||||
|
ledger.BanForLimit(netblock, midnight(), bans.Notes{
|
||||||
|
ASN: "AS64497", ASName: "Other Net", Country: "FR",
|
||||||
|
})
|
||||||
|
ledger.BanForLimit(netblock, midnight().Add(time.Hour), bans.Notes{})
|
||||||
|
ledger.BanForLimit(other, midnight(), bans.Notes{})
|
||||||
|
|
||||||
|
ledger.AddLookup(netblock, "AS64496", "Example Net", "DE")
|
||||||
|
|
||||||
|
held := ledger.Bans(netblock)
|
||||||
|
if len(held) != 2 {
|
||||||
|
t.Fatalf("%s has %d bans, want 2", netblock, len(held))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, want := range []bans.Notes{
|
||||||
|
{ASN: "AS64497", ASName: "Other Net", Country: "FR"},
|
||||||
|
{ASN: "AS64496", ASName: "Example Net", Country: "DE"},
|
||||||
|
} {
|
||||||
|
got := held[i].Notes
|
||||||
|
if got.ASN != want.ASN || got.ASName != want.ASName || got.Country != want.Country {
|
||||||
|
t.Errorf("ban %d's notes give %q, %q and %q, want %q, %q and %q", i+1,
|
||||||
|
got.ASN, got.ASName, got.Country, want.ASN, want.ASName, want.Country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if notes := ledger.Bans(other)[0].Notes; notes.ASN != "" || notes.Country != "" {
|
||||||
|
t.Errorf("the ban on %s has %q and %q, want neither",
|
||||||
|
other, notes.ASN, notes.Country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// defaultRules are the rules at the settings' defaults.
|
// defaultRules are the rules at the settings' defaults.
|
||||||
func defaultRules() bans.Rules {
|
func defaultRules() bans.Rules {
|
||||||
return bans.Rules{
|
return bans.Rules{
|
||||||
LimitBanDuration: time.Hour,
|
LimitBanDuration: time.Hour,
|
||||||
LimitBanRepeatWindow: day,
|
LimitBanRepeatWindow: day,
|
||||||
MaxBanDuration: 7 * day,
|
MaxBanDuration: 7 * day,
|
||||||
|
AttackBanDuration: 7 * day,
|
||||||
MaxBans: 5000,
|
MaxBans: 5000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
|
|||||||
high := netip.MustParsePrefix("203.0.113.10/32")
|
high := netip.MustParsePrefix("203.0.113.10/32")
|
||||||
low := netip.MustParsePrefix("203.0.113.9/32")
|
low := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
first := ledger.BanForLimit(v6, midnight(), bans.Notes{})
|
first, _ := ledger.BanForLimit(v6, midnight(), bans.Notes{})
|
||||||
ledger.BanForLimit(high, midnight(), bans.Notes{})
|
ledger.BanForLimit(high, midnight(), bans.Notes{})
|
||||||
ledger.BanForLimit(low, midnight(), bans.Notes{})
|
ledger.BanForLimit(low, midnight(), bans.Notes{})
|
||||||
ledger.BanForLimit(v6, first.Expires, bans.Notes{})
|
ledger.BanForLimit(v6, first.Expires, bans.Notes{})
|
||||||
@@ -68,7 +68,7 @@ func TestLoadedBansCarryOn(t *testing.T) {
|
|||||||
|
|
||||||
before := bans.New(defaultRules())
|
before := bans.New(defaultRules())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
ban := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1})
|
ban, _ := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1})
|
||||||
|
|
||||||
// Loaded into a new ledger, as across a restart, the ban still refuses
|
// Loaded into a new ledger, as across a restart, the ban still refuses
|
||||||
// while it lasts, and once it has ended a broken limit bans for three
|
// while it lasts, and once it has ended a broken limit bans for three
|
||||||
@@ -76,14 +76,15 @@ func TestLoadedBansCarryOn(t *testing.T) {
|
|||||||
after := bans.New(defaultRules())
|
after := bans.New(defaultRules())
|
||||||
after.Load(before.Snapshot())
|
after.Load(before.Snapshot())
|
||||||
|
|
||||||
_, banned := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
|
_, banned, _ := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
|
||||||
if !banned {
|
if !banned {
|
||||||
t.Error("the loaded ban does not refuse")
|
t.Error("the loaded ban does not refuse")
|
||||||
}
|
}
|
||||||
|
|
||||||
again := after.BanForLimit(netblock, ban.Expires, bans.Notes{})
|
again, _ := after.BanForLimit(netblock, ban.Expires, bans.Notes{})
|
||||||
if again.Expires.Sub(again.Start) != 3*time.Hour || again.Notes.EarlierBans != 1 {
|
if again.Expires.Sub(again.Start) != 3*time.Hour ||
|
||||||
t.Errorf("the next ban lasts %s with %d earlier bans, want 3h and 1",
|
again.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||||
|
t.Errorf("the next ban lasts %s with earlier bans %+v, want 3h and 1 for a limit",
|
||||||
again.Expires.Sub(again.Start), again.Notes.EarlierBans)
|
again.Expires.Sub(again.Start), again.Notes.EarlierBans)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,7 +111,7 @@ func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) {
|
|||||||
"198.51.100.7": true,
|
"198.51.100.7": true,
|
||||||
"198.51.100.8": false,
|
"198.51.100.8": false,
|
||||||
} {
|
} {
|
||||||
_, banned := ledger.Check(netip.MustParseAddr(client), midnight())
|
_, banned, _ := ledger.Check(netip.MustParseAddr(client), midnight())
|
||||||
if banned != want {
|
if banned != want {
|
||||||
t.Errorf("%s is refused: %t, want %t", client, banned, want)
|
t.Errorf("%s is refused: %t, want %t", client, banned, want)
|
||||||
}
|
}
|
||||||
@@ -130,14 +131,91 @@ func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// As when an admin adds a permanent ban to bans.json with a start
|
||||||
|
// before that of the netblock's ban that has ended.
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||||
|
permanent := bans.Ban{Netblock: netblock, Start: midnight().Add(-time.Hour)}
|
||||||
|
ended := bans.Ban{
|
||||||
|
Netblock: netblock,
|
||||||
|
Start: midnight(),
|
||||||
|
Expires: midnight().Add(time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
ledger.Load([]bans.Ban{permanent, ended})
|
||||||
|
|
||||||
|
now := midnight().Add(2 * time.Hour)
|
||||||
|
client := netip.MustParseAddr("203.0.113.9")
|
||||||
|
|
||||||
|
ban, banned, _ := ledger.Find(client, now)
|
||||||
|
if !banned || !ban.Permanent() {
|
||||||
|
t.Errorf("find gives %+v and %t, want the permanent ban", ban, banned)
|
||||||
|
}
|
||||||
|
|
||||||
|
ban, banned, _ = ledger.Check(client, now)
|
||||||
|
if !banned || !ban.Permanent() {
|
||||||
|
t.Errorf("the client is refused: %t, under %+v, want under the permanent ban",
|
||||||
|
banned, ban)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A limit broken now makes no shorter ban over the permanent one.
|
||||||
|
ban, _ = ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||||
|
if !ban.Permanent() || len(ledger.Bans(netblock)) != 2 {
|
||||||
|
t.Errorf("a broken limit returned %+v and left the netblock %d bans, "+
|
||||||
|
"want the permanent ban and 2", ban, len(ledger.Bans(netblock)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A 9-hour ban smallwebwaf made, the third in a row, and an admin's
|
||||||
|
// 1-hour ban added to bans.json over it, with no cause and no notes.
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
nineHours := bans.Ban{
|
||||||
|
Netblock: netblock,
|
||||||
|
Start: midnight(),
|
||||||
|
Expires: midnight().Add(9 * time.Hour),
|
||||||
|
Cause: bans.CauseLimit,
|
||||||
|
Notes: bans.Notes{EarlierBans: bans.EarlierBans{Limit: 2}},
|
||||||
|
}
|
||||||
|
admins := bans.Ban{
|
||||||
|
Netblock: netblock,
|
||||||
|
Start: midnight().Add(time.Hour),
|
||||||
|
Expires: midnight().Add(2 * time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
ledger.Load([]bans.Ban{nineHours, admins})
|
||||||
|
|
||||||
|
// Once both have ended, a limit broken within the repeat window bans
|
||||||
|
// for three times the 9 hours, and the notes count the two bans
|
||||||
|
// before the 9-hour one and it, for a limit, and the admin's.
|
||||||
|
ban, _ := ledger.BanForLimit(netblock, nineHours.Expires.Add(time.Hour), bans.Notes{})
|
||||||
|
if ban.Expires.Sub(ban.Start) != 27*time.Hour ||
|
||||||
|
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 3, Admin: 1}) {
|
||||||
|
t.Errorf("the next ban lasts %s with earlier bans %+v, "+
|
||||||
|
"want 27h, 3 for a limit and 1 an admin's",
|
||||||
|
ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
|
func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// bans.json lists the bans by netblock, not in the order they began.
|
// bans.json lists the bans by netblock, not in the order they began.
|
||||||
later := bans.Ban{Netblock: netip.MustParsePrefix("203.0.113.1/32"), Start: midnight()}
|
later := bans.Ban{
|
||||||
|
Netblock: netip.MustParsePrefix("203.0.113.1/32"),
|
||||||
|
Start: midnight(),
|
||||||
|
Cause: bans.CauseLimit,
|
||||||
|
}
|
||||||
earlier := bans.Ban{
|
earlier := bans.Ban{
|
||||||
Netblock: netip.MustParsePrefix("203.0.113.2/32"),
|
Netblock: netip.MustParsePrefix("203.0.113.2/32"),
|
||||||
Start: midnight().Add(-time.Hour),
|
Start: midnight().Add(-time.Hour),
|
||||||
|
Cause: bans.CauseLimit,
|
||||||
}
|
}
|
||||||
|
|
||||||
rules := defaultRules()
|
rules := defaultRules()
|
||||||
@@ -151,6 +229,49 @@ func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadReplacesTheBansHeld(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Room for three bans, so that the second load, were it added to the
|
||||||
|
// two bans held, would drop none of them to make room.
|
||||||
|
rules := defaultRules()
|
||||||
|
rules.MaxBans = 3
|
||||||
|
ledger := bans.New(rules)
|
||||||
|
kept := bans.Ban{
|
||||||
|
Netblock: netip.MustParsePrefix("2001:db8::/64"),
|
||||||
|
Start: midnight(),
|
||||||
|
Cause: bans.CauseLimit,
|
||||||
|
}
|
||||||
|
ledger.Load([]bans.Ban{
|
||||||
|
{
|
||||||
|
Netblock: netip.MustParsePrefix("203.0.113.0/24"),
|
||||||
|
Start: midnight(),
|
||||||
|
Cause: bans.CauseLimit,
|
||||||
|
},
|
||||||
|
kept,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Loaded again without the first ban, as when an admin's edit of
|
||||||
|
// bans.json is taken in, that ban is lifted.
|
||||||
|
ledger.Load([]bans.Ban{kept})
|
||||||
|
|
||||||
|
_, banned, _ := ledger.Check(netip.MustParseAddr("203.0.113.9"), midnight())
|
||||||
|
if banned {
|
||||||
|
t.Error("a ban left out of the second load still refuses")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ledger holds one ban, so it makes two more without dropping any.
|
||||||
|
first, _ := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(),
|
||||||
|
bans.Notes{})
|
||||||
|
second, _ := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.8/32"), midnight(),
|
||||||
|
bans.Notes{})
|
||||||
|
|
||||||
|
want := []bans.Ban{first, second, kept}
|
||||||
|
if got := ledger.Snapshot(); !slices.Equal(got, want) {
|
||||||
|
t.Errorf("the ledger holds %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadCutsTheTextsTo256Bytes(t *testing.T) {
|
func TestLoadCutsTheTextsTo256Bytes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+1287
-33
File diff suppressed because it is too large
Load Diff
+1694
-27
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
|||||||
|
package lookup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
"github.com/oschwald/maxminddb-golang/v2"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// quietTime is how long the lookup database must go without a change
|
||||||
|
// before it is read again, so that a file still being copied in is read
|
||||||
|
// only once whole.
|
||||||
|
const quietTime = 2 * time.Second
|
||||||
|
|
||||||
|
// FileParams are what OpenFile needs.
|
||||||
|
type FileParams struct {
|
||||||
|
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
||||||
|
// (SWWAF_LOOKUP_DB_PATH).
|
||||||
|
Path string
|
||||||
|
// Now tells the time, normally time.Now.
|
||||||
|
Now func() time.Time
|
||||||
|
// ProcessLog receives each reading of the file, and why a replacement
|
||||||
|
// of it cannot be read.
|
||||||
|
ProcessLog *slog.Logger
|
||||||
|
// Alerts receive a file_error alert for each replacement that cannot
|
||||||
|
// be read.
|
||||||
|
Alerts *alerts.Queue
|
||||||
|
}
|
||||||
|
|
||||||
|
// File looks up clients' AS numbers and countries in the lookup database,
|
||||||
|
// held in memory, and reads it again when it is replaced. It is safe for
|
||||||
|
// concurrent use.
|
||||||
|
type File struct {
|
||||||
|
params FileParams
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
// reader is the database in use, and lastRead when it was read.
|
||||||
|
// readFailures are the replacements that could not be read.
|
||||||
|
reader *maxminddb.Reader
|
||||||
|
lastRead time.Time
|
||||||
|
readFailures int
|
||||||
|
}
|
||||||
|
|
||||||
|
// record is what the lookup database holds about a network, of the fields
|
||||||
|
// smallwebwaf reads.
|
||||||
|
type record struct {
|
||||||
|
ASN string `maxminddb:"asn"`
|
||||||
|
ASName string `maxminddb:"as_name"`
|
||||||
|
CountryCode string `maxminddb:"country_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenFile reads the lookup database. A file that cannot be read, or that
|
||||||
|
// is not a .mmdb file, is an error.
|
||||||
|
func OpenFile(params FileParams) (*File, error) {
|
||||||
|
reader, err := read(params.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
f := &File{params: params}
|
||||||
|
f.use(reader)
|
||||||
|
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookUp returns what the lookup database says about client: its AS
|
||||||
|
// number, such as AS64496, the AS's name, and its country, such as DE,
|
||||||
|
// each "" when the database does not give it, as for an address missing
|
||||||
|
// from it. The database is asked about the client's first address, as
|
||||||
|
// GeoJS is.
|
||||||
|
func (f *File) LookUp(client netip.Prefix) Answer {
|
||||||
|
f.mu.Lock()
|
||||||
|
reader := f.reader
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
var found record
|
||||||
|
|
||||||
|
// A record that cannot be decoded places the client nowhere, as a
|
||||||
|
// missing one does.
|
||||||
|
err := reader.Lookup(client.Addr()).Decode(&found)
|
||||||
|
if err != nil {
|
||||||
|
found = record{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Answer{
|
||||||
|
Client: client,
|
||||||
|
ASN: found.ASN,
|
||||||
|
ASName: found.ASName,
|
||||||
|
Country: found.CountryCode,
|
||||||
|
Answered: f.params.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastRead returns when the lookup database in use was read.
|
||||||
|
func (f *File) LastRead() time.Time {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
return f.lastRead
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFailures returns how many replacements of the lookup database could
|
||||||
|
// not be read.
|
||||||
|
func (f *File) ReadFailures() int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
return f.readFailures
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch watches the directory of the lookup database until ctx is done,
|
||||||
|
// and reads the file again once it has gone without a change for
|
||||||
|
// quietTime, after it is replaced, written or removed, and after Watch
|
||||||
|
// starts watching. If the directory cannot be watched, that is logged, and
|
||||||
|
// the database read at start stays in use.
|
||||||
|
func (f *File) Watch(ctx context.Context) {
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err == nil {
|
||||||
|
defer func() {
|
||||||
|
_ = watcher.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
err = watcher.Add(filepath.Dir(f.params.Path))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
f.params.ProcessLog.Error("cannot watch the lookup database for replacements",
|
||||||
|
"error", err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f.params.ProcessLog.Info("watching the lookup database for replacements",
|
||||||
|
"file", f.params.Path)
|
||||||
|
|
||||||
|
f.readAfterChanges(ctx, watcher.Events, watcher.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAfterChanges reads the lookup database again once quietTime has
|
||||||
|
// passed without a change to it from events, until ctx is done, and logs
|
||||||
|
// the errors from errs. A change to another file in its directory does not
|
||||||
|
// count. The wait starts at once, as if for a change, so that a file
|
||||||
|
// replaced after OpenFile read it, and before its directory was watched,
|
||||||
|
// is read too.
|
||||||
|
func (f *File) readAfterChanges(
|
||||||
|
ctx context.Context, events <-chan fsnotify.Event, errs <-chan error,
|
||||||
|
) {
|
||||||
|
path := filepath.Clean(f.params.Path)
|
||||||
|
|
||||||
|
quiet := time.NewTimer(quietTime)
|
||||||
|
defer quiet.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case event := <-events:
|
||||||
|
if filepath.Clean(event.Name) == path {
|
||||||
|
quiet.Reset(quietTime)
|
||||||
|
}
|
||||||
|
case <-quiet.C:
|
||||||
|
f.readAgain()
|
||||||
|
case err := <-errs:
|
||||||
|
f.params.ProcessLog.Warn("watching the lookup database failed",
|
||||||
|
"error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAgain reads the lookup database again, in place of the one in use,
|
||||||
|
// or, if it cannot be read, counts that, raises a file_error alert for it
|
||||||
|
// and logs it, and the one in use stays in use.
|
||||||
|
func (f *File) readAgain() {
|
||||||
|
reader, err := read(f.params.Path)
|
||||||
|
if err != nil {
|
||||||
|
const kept = "the lookup database cannot be read, " +
|
||||||
|
"and the one read before stays in use"
|
||||||
|
|
||||||
|
f.mu.Lock()
|
||||||
|
f.readFailures++
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
// Raised before it is logged, so that the alert is there once the
|
||||||
|
// log line is.
|
||||||
|
f.params.Alerts.Raise(alerts.Alert{
|
||||||
|
Event: alerts.EventFileError,
|
||||||
|
Reason: kept,
|
||||||
|
Detail: map[string]any{"file": f.params.Path, "error": err.Error()},
|
||||||
|
})
|
||||||
|
f.params.ProcessLog.Error(kept, "error", err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f.use(reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// use puts reader in use, in place of the database read before, and logs
|
||||||
|
// that the file was read.
|
||||||
|
func (f *File) use(reader *maxminddb.Reader) {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.reader = reader
|
||||||
|
f.lastRead = f.params.Now()
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
f.params.ProcessLog.Info("read the lookup database", "file", f.params.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// read reads the lookup database at path. The whole file is read into
|
||||||
|
// memory, rather than mapped into it as the reader can, so that a file
|
||||||
|
// overwritten in place cannot change, or end, under a lookup.
|
||||||
|
func read(path string) (*maxminddb.Reader, error) {
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH cannot be read: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := maxminddb.OpenBytes(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return reader, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package lookup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
"github.com/maxmind/mmdbwriter/mmdbtype"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testNetblock is the netblock the tests' lookup databases place, and
|
||||||
|
// testClient a client in it.
|
||||||
|
const (
|
||||||
|
testNetblock = "203.0.113.0/24"
|
||||||
|
testClient = "203.0.113.9/32"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilePlacesClientsAndCountsAnAddressMissingFromItAsUnknown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
germany := lookuptest.Network{ASN: "AS64496", ASName: "Example Net", Country: "DE"}
|
||||||
|
northKorea := lookuptest.Network{ASN: "AS64511", ASName: "Other Net", Country: "KP"}
|
||||||
|
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||||
|
lookuptest.Write(t, path, map[string]lookuptest.Network{
|
||||||
|
testNetblock: germany,
|
||||||
|
"2001:db8::/32": northKorea,
|
||||||
|
})
|
||||||
|
|
||||||
|
now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
f, err := OpenFile(FileParams{
|
||||||
|
Path: path,
|
||||||
|
Now: func() time.Time { return now },
|
||||||
|
ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
|
Alerts: newQueue(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for client, want := range map[string]lookuptest.Network{
|
||||||
|
testClient: germany,
|
||||||
|
// An IPv6 client is its /64.
|
||||||
|
"2001:db8:1:2::/64": northKorea,
|
||||||
|
"198.51.100.7/32": {},
|
||||||
|
} {
|
||||||
|
prefix := netip.MustParsePrefix(client)
|
||||||
|
|
||||||
|
got := f.LookUp(prefix)
|
||||||
|
if got != (Answer{
|
||||||
|
Client: prefix, ASN: want.ASN, ASName: want.ASName, Country: want.Country,
|
||||||
|
Answered: now,
|
||||||
|
}) {
|
||||||
|
t.Errorf("%s has the answer %+v, want %+v, answered %s", client, got, want, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordThatCannotBeReadPlacesTheClientNowhere(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The AS number is a number, where a string belongs. The writer writes
|
||||||
|
// a record's fields in the order of their names, so as_name is read
|
||||||
|
// before the AS number fails.
|
||||||
|
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||||
|
lookuptest.WriteRecords(t, path, map[string]mmdbtype.Map{
|
||||||
|
testNetblock: {
|
||||||
|
"asn": mmdbtype.Uint32(64496),
|
||||||
|
"as_name": mmdbtype.String("Example Net"),
|
||||||
|
"country_code": mmdbtype.String("DE"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
f := openFile(t, path, newQueue())
|
||||||
|
|
||||||
|
answer := f.LookUp(netip.MustParsePrefix(testClient))
|
||||||
|
if answer.ASN != "" || answer.ASName != "" || answer.Country != "" {
|
||||||
|
t.Errorf("%s is placed %+v, want nowhere", testClient, answer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileThatCannotBeReadIsAnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
missing := filepath.Join(dir, "missing.mmdb")
|
||||||
|
notDatabase := filepath.Join(dir, "not.mmdb")
|
||||||
|
writeFile(t, notDatabase, "not a lookup database\n")
|
||||||
|
|
||||||
|
for path, want := range map[string]string{
|
||||||
|
missing: "SWWAF_LOOKUP_DB_PATH cannot be read: open " + missing +
|
||||||
|
": no such file or directory",
|
||||||
|
notDatabase: "SWWAF_LOOKUP_DB_PATH " + notDatabase +
|
||||||
|
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
||||||
|
} {
|
||||||
|
_, err := OpenFile(FileParams{
|
||||||
|
Path: path,
|
||||||
|
Now: time.Now,
|
||||||
|
ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
|
Alerts: newQueue(),
|
||||||
|
})
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("opening %s failed with %v, want %s", path, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tests below run readAfterChanges in a synctest bubble, where time is
|
||||||
|
// a clock of the test's own: time.Sleep moves it on at once, and
|
||||||
|
// synctest.Wait returns once readAfterChanges waits again, so that every
|
||||||
|
// reading due by then is done. The test sends the changes itself, as the
|
||||||
|
// watch of a directory cannot run in a bubble.
|
||||||
|
|
||||||
|
func TestReplacementCopiedOverTheFileInTwoPartsIsReadOnlyWhole(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "ipinfo_lite.mmdb")
|
||||||
|
writeDatabase(t, path, "DE")
|
||||||
|
|
||||||
|
queue := newQueue()
|
||||||
|
f := openFile(t, path, queue)
|
||||||
|
changes := watch(t, f)
|
||||||
|
|
||||||
|
other := filepath.Join(dir, "replacement.mmdb")
|
||||||
|
writeDatabase(t, other, "KP")
|
||||||
|
|
||||||
|
replacement, err := os.ReadFile(other) //nolint:gosec // a file the test wrote
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", other, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file in use is overwritten in place, and keeps giving what
|
||||||
|
// it gave. Its first part alone is not a .mmdb file.
|
||||||
|
file, err := os.Create(path) //nolint:gosec // a file the test wrote
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = file.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
half := len(replacement) / 2
|
||||||
|
write(t, file, replacement[:half])
|
||||||
|
|
||||||
|
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
||||||
|
|
||||||
|
time.Sleep(quietTime - time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantCountry(t, f, "DE")
|
||||||
|
|
||||||
|
// The second part starts the wait again.
|
||||||
|
write(t, file, replacement[half:])
|
||||||
|
|
||||||
|
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
||||||
|
|
||||||
|
time.Sleep(quietTime - time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantCountry(t, f, "DE")
|
||||||
|
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantCountry(t, f, "KP")
|
||||||
|
|
||||||
|
if !f.LastRead().Equal(time.Now()) || f.ReadFailures() != 0 {
|
||||||
|
t.Errorf("read at %s, with %d failures; want read now, with none",
|
||||||
|
f.LastRead(), f.ReadFailures())
|
||||||
|
}
|
||||||
|
|
||||||
|
wantAlerts(t, queue)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||||
|
writeDatabase(t, path, "DE")
|
||||||
|
|
||||||
|
queue := newQueue()
|
||||||
|
f := openFile(t, path, queue)
|
||||||
|
read := f.LastRead()
|
||||||
|
changes := watch(t, f)
|
||||||
|
|
||||||
|
writeFile(t, path, "not a lookup database\n")
|
||||||
|
|
||||||
|
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
||||||
|
|
||||||
|
// Long after, the replacement has been read once.
|
||||||
|
time.Sleep(time.Hour)
|
||||||
|
synctest.Wait()
|
||||||
|
wantCountry(t, f, "DE")
|
||||||
|
|
||||||
|
if !f.LastRead().Equal(read) || f.ReadFailures() != 1 {
|
||||||
|
t.Errorf("read at %s, with %d failures; want read at %s, with one",
|
||||||
|
f.LastRead(), f.ReadFailures(), read)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantAlerts(t, queue, alerts.Alert{
|
||||||
|
Time: read.Add(quietTime),
|
||||||
|
Event: alerts.EventFileError,
|
||||||
|
Reason: "the lookup database cannot be read, and the one read before stays in use",
|
||||||
|
Detail: map[string]any{
|
||||||
|
"file": path,
|
||||||
|
"error": "SWWAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
||||||
|
"error opening database: invalid MaxMind DB file",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangeOfAnotherFileInTheDirectoryIsNoReplacement(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "ipinfo_lite.mmdb")
|
||||||
|
writeDatabase(t, path, "DE")
|
||||||
|
|
||||||
|
f := openFile(t, path, newQueue())
|
||||||
|
changes := watch(t, f)
|
||||||
|
|
||||||
|
// The wait that starts with the watch ends with a reading.
|
||||||
|
time.Sleep(quietTime)
|
||||||
|
synctest.Wait()
|
||||||
|
writeDatabase(t, path, "KP")
|
||||||
|
|
||||||
|
changes <- fsnotify.Event{Name: filepath.Join(dir, "other.mmdb"), Op: fsnotify.Create}
|
||||||
|
|
||||||
|
time.Sleep(quietTime)
|
||||||
|
synctest.Wait()
|
||||||
|
wantCountry(t, f, "DE")
|
||||||
|
|
||||||
|
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
||||||
|
|
||||||
|
time.Sleep(quietTime)
|
||||||
|
synctest.Wait()
|
||||||
|
wantCountry(t, f, "KP")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplacementSavedBeforeTheWatchStartsIsRead(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||||
|
writeDatabase(t, path, "DE")
|
||||||
|
|
||||||
|
f := openFile(t, path, newQueue())
|
||||||
|
|
||||||
|
// Saved after OpenFile read the file, and before its directory was
|
||||||
|
// watched, so that no change is seen for it.
|
||||||
|
writeDatabase(t, path, "KP")
|
||||||
|
watch(t, f)
|
||||||
|
time.Sleep(quietTime)
|
||||||
|
synctest.Wait()
|
||||||
|
wantCountry(t, f, "KP")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newQueue returns a queue of alerts for a webhook that is never sent
|
||||||
|
// them, so that they wait in it for the test to look at.
|
||||||
|
func newQueue() *alerts.Queue {
|
||||||
|
return alerts.New(alerts.Params{
|
||||||
|
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
||||||
|
Events: alerts.Events(),
|
||||||
|
Cooldown: 15 * time.Minute,
|
||||||
|
Now: time.Now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeDatabase writes a lookup database at path that places testNetblock
|
||||||
|
// in country, and no other address.
|
||||||
|
func writeDatabase(t *testing.T, path, country string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
lookuptest.Write(t, path, map[string]lookuptest.Network{
|
||||||
|
testNetblock: {ASN: "AS64496", ASName: "Example Net", Country: country},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// openFile opens the lookup database at path, which raises its alerts to
|
||||||
|
// queue.
|
||||||
|
func openFile(t *testing.T, path string, queue *alerts.Queue) *File {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
f, err := OpenFile(FileParams{
|
||||||
|
Path: path,
|
||||||
|
Now: time.Now,
|
||||||
|
ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
|
Alerts: queue,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// watch runs f's readAfterChanges until the test ends, and returns the
|
||||||
|
// channel that sends it changes.
|
||||||
|
func watch(t *testing.T, f *File) chan<- fsnotify.Event {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
changes := make(chan fsnotify.Event)
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
f.readAfterChanges(ctx, changes, nil)
|
||||||
|
close(stopped)
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
stop()
|
||||||
|
<-stopped
|
||||||
|
})
|
||||||
|
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantCountry checks the country f gives testClient.
|
||||||
|
func wantCountry(t *testing.T, f *File, want string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got := f.LookUp(netip.MustParsePrefix(testClient)).Country
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s is in %q, want %q", testClient, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantAlerts checks the alerts waiting in queue, and that it held none
|
||||||
|
// back.
|
||||||
|
func wantAlerts(t *testing.T, queue *alerts.Queue, want ...alerts.Alert) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != len(want) || (len(want) > 0 && !reflect.DeepEqual(waiting, want)) {
|
||||||
|
t.Errorf("alerts waiting %+v, want %+v", waiting, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if queue.Suppressed() != 0 {
|
||||||
|
t.Errorf("%d alerts held back, want none", queue.Suppressed())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFile writes content to the file at path.
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(content), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write writes data to the end of file.
|
||||||
|
func write(t *testing.T, file *os.File, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := file.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
-70
@@ -1,7 +1,8 @@
|
|||||||
// Package lookup looks up each client's country through the GeoJS web
|
// Package lookup looks up each client's AS number and country, through
|
||||||
// service, and keeps the answers in memory, for at most 100,000 clients
|
// the GeoJS web service or in the lookup database, the IPinfo Lite file
|
||||||
// and for 7 days each. The answers are written to lookups.json and read
|
// SWWAF_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
||||||
// from it by the state package.
|
// most 100,000 clients and for 7 days each, and are written to
|
||||||
|
// lookups.json and read from it by the state package.
|
||||||
package lookup
|
package lookup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -14,17 +15,20 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hashicorp/golang-lru/v2/simplelru"
|
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
// URL is GeoJS's country endpoint. Asked about several addresses at once,
|
// URL is GeoJS's endpoint for an address's place and network. Asked about
|
||||||
// comma separated in its ip parameter, it answers with a list.
|
// several addresses at once, comma separated in its ip parameter, it
|
||||||
const URL = "https://get.geojs.io/v1/ip/country.json"
|
// answers with a list.
|
||||||
|
const URL = "https://get.geojs.io/v1/ip/geo.json"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// keepFor is how long an answer is used instead of asking GeoJS again.
|
// keepFor is how long an answer is used instead of asking GeoJS again.
|
||||||
@@ -39,9 +43,8 @@ const (
|
|||||||
maxWaiting = 10000
|
maxWaiting = 10000
|
||||||
// maxPerRequest is how many addresses one request to GeoJS asks about.
|
// maxPerRequest is how many addresses one request to GeoJS asks about.
|
||||||
maxPerRequest = 200
|
maxPerRequest = 200
|
||||||
// timeout is how long a new client waits for its answer, and how long
|
// unknownASN is the AS number GeoJS gives when it knows none.
|
||||||
// a request to GeoJS may take before it is abandoned.
|
unknownASN = 64512
|
||||||
timeout = time.Second
|
|
||||||
// After a failure GeoJS is not asked again for a second, and for
|
// After a failure GeoJS is not asked again for a second, and for
|
||||||
// retryDelayFactor times as long after each further failure in a row,
|
// retryDelayFactor times as long after each further failure in a row,
|
||||||
// up to five minutes.
|
// up to five minutes.
|
||||||
@@ -61,6 +64,16 @@ var (
|
|||||||
type Params struct {
|
type Params struct {
|
||||||
// URL is where GeoJS is asked, normally URL.
|
// URL is where GeoJS is asked, normally URL.
|
||||||
URL string
|
URL string
|
||||||
|
// Timeout is how long a request waits for its client's first answer,
|
||||||
|
// and how long a request to GeoJS may take before it is abandoned
|
||||||
|
// (SWWAF_LOOKUP_TIMEOUT).
|
||||||
|
Timeout time.Duration
|
||||||
|
// Wait is true when a setting needs each request's answer before the
|
||||||
|
// request goes on. Otherwise no request waits for one.
|
||||||
|
Wait bool
|
||||||
|
// Answered, unless nil, is given each answer GeoJS gives, once it is
|
||||||
|
// kept.
|
||||||
|
Answered func(Answer)
|
||||||
// Now tells the time, normally time.Now.
|
// Now tells the time, normally time.Now.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
// ProcessLog receives GeoJS's failures.
|
// ProcessLog receives GeoJS's failures.
|
||||||
@@ -68,16 +81,22 @@ type Params struct {
|
|||||||
// Metrics count the requests to GeoJS, those that failed, and the
|
// Metrics count the requests to GeoJS, those that failed, and the
|
||||||
// clients that go without an answer.
|
// clients that go without an answer.
|
||||||
Metrics *metrics.Metrics
|
Metrics *metrics.Metrics
|
||||||
|
// Alerts receive a source_failure alert each time GeoJS fails.
|
||||||
|
Alerts *alerts.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
// GeoJS looks up clients' countries through GeoJS. At most one request
|
// GeoJS looks up clients' AS numbers and countries through GeoJS. At most
|
||||||
// to GeoJS is under way at a time, and it asks about every client waiting,
|
// one request to GeoJS is under way at a time, and it asks about every
|
||||||
// up to maxPerRequest. It is safe for concurrent use.
|
// client waiting, up to maxPerRequest. It is safe for concurrent use.
|
||||||
type GeoJS struct {
|
type GeoJS struct {
|
||||||
url string
|
url string
|
||||||
|
timeout time.Duration
|
||||||
|
wait bool
|
||||||
|
answered func(Answer)
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
processLog *slog.Logger
|
processLog *slog.Logger
|
||||||
metrics *metrics.Metrics
|
metrics *metrics.Metrics
|
||||||
|
alerts *alerts.Queue
|
||||||
// httpClient follows no redirect, so that visitors' addresses go to
|
// httpClient follows no redirect, so that visitors' addresses go to
|
||||||
// GeoJS alone: a redirect is a failure.
|
// GeoJS alone: a redirect is a failure.
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -95,11 +114,18 @@ type GeoJS struct {
|
|||||||
retryAt time.Time
|
retryAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Answer is what GeoJS said about a client, as lookups.json holds it: its
|
// Answer is what GeoJS or the lookup database said about a client: its AS
|
||||||
// country, "" when GeoJS cannot place it, when GeoJS said so, and when
|
// number, such as AS64496, and the AS's name, both "" when the source knows
|
||||||
// the answer was last used.
|
// no AS number for it; its country, "" when the source cannot place it;
|
||||||
|
// when the source said so; and, for GeoJS's answers, which lookups.json
|
||||||
|
// holds, when the answer was last used. The zero Answer is that of a
|
||||||
|
// client with no answer.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||||
type Answer struct {
|
type Answer struct {
|
||||||
Client netip.Prefix `json:"client"`
|
Client netip.Prefix `json:"client"`
|
||||||
|
ASN string `json:"asn"`
|
||||||
|
ASName string `json:"as_name"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
Answered time.Time `json:"answered"`
|
Answered time.Time `json:"answered"`
|
||||||
Used time.Time `json:"used"`
|
Used time.Time `json:"used"`
|
||||||
@@ -124,9 +150,13 @@ func New(params Params) *GeoJS {
|
|||||||
|
|
||||||
return &GeoJS{
|
return &GeoJS{
|
||||||
url: params.URL,
|
url: params.URL,
|
||||||
|
timeout: params.Timeout,
|
||||||
|
wait: params.Wait,
|
||||||
|
answered: params.Answered,
|
||||||
now: params.Now,
|
now: params.Now,
|
||||||
processLog: params.ProcessLog,
|
processLog: params.ProcessLog,
|
||||||
metrics: params.Metrics,
|
metrics: params.Metrics,
|
||||||
|
alerts: params.Alerts,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
@@ -137,23 +167,23 @@ func New(params Params) *GeoJS {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Country returns the country GeoJS places client in, as a two-letter
|
// LookUp returns the answer GeoJS gave about client, with its country as
|
||||||
// code in capitals, or "" when the country cannot be found: GeoJS cannot
|
// a two-letter code in capitals, or the zero Answer when there is none
|
||||||
// place the client, or has not answered in time. An answer is kept for 7
|
// yet. An answer is kept for 7 days. Without one, the client is asked
|
||||||
// days. Without one, a client waits up to timeout for it, unless it has
|
// about in the background, and, while Wait is set, the request waits up
|
||||||
// gone without one before; until GeoJS answers, the client is asked about
|
// to Timeout for the answer, unless the client has gone without one
|
||||||
// again in the background. ctx is the context of the client's request,
|
// before. ctx is the context of the client's request, and ends the wait
|
||||||
// and ends the wait when it ends.
|
// when it ends.
|
||||||
//
|
//
|
||||||
// GeoJS is asked about the client's first address, which is the client's
|
// GeoJS is asked about the client's first address, which is the client's
|
||||||
// own address for IPv4, and an address in the same place for an IPv6 /64.
|
// own address for IPv4, and an address in the same place for an IPv6 /64.
|
||||||
func (g *GeoJS) Country(ctx context.Context, client netip.Prefix) string {
|
func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
|
||||||
country, asked := g.answerOrWait(ctx, client)
|
answer, asked := g.answerOrWait(ctx, client)
|
||||||
if asked == nil {
|
if asked == nil {
|
||||||
return country
|
return answer
|
||||||
}
|
}
|
||||||
|
|
||||||
timer := time.NewTimer(timeout)
|
timer := time.NewTimer(g.timeout)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -165,7 +195,7 @@ func (g *GeoJS) Country(ctx context.Context, client netip.Prefix) string {
|
|||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
country, found := g.kept(client)
|
answer, found := g.kept(client)
|
||||||
if !found {
|
if !found {
|
||||||
g.metrics.GeoJSUnanswered.Inc()
|
g.metrics.GeoJSUnanswered.Inc()
|
||||||
}
|
}
|
||||||
@@ -175,7 +205,15 @@ func (g *GeoJS) Country(ctx context.Context, client netip.Prefix) string {
|
|||||||
w.late = true
|
w.late = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return country
|
return answer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept returns client's answer, if one is kept, without asking GeoJS.
|
||||||
|
func (g *GeoJS) Kept(client netip.Prefix) (Answer, bool) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
return g.kept(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot returns every answer kept, sorted by client, as lookups.json
|
// Snapshot returns every answer kept, sorted by client, as lookups.json
|
||||||
@@ -197,19 +235,21 @@ func (g *GeoJS) Snapshot() []Answer {
|
|||||||
return answers
|
return answers
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load keeps answers read from lookups.json, in a GeoJS that keeps none
|
// Load keeps answers read from lookups.json, in place of the answers it
|
||||||
// yet, in the order they were last used, so that the one used longest
|
// keeps, in the order they were last used, so that the one used longest
|
||||||
// ago is dropped first. Answers GeoJS gave keepFor ago or more are
|
// ago is dropped first. Answers GeoJS gave keepFor ago or more are
|
||||||
// dropped.
|
// dropped.
|
||||||
func (g *GeoJS) Load(answers []Answer) {
|
func (g *GeoJS) Load(answers []Answer) {
|
||||||
g.mu.Lock()
|
|
||||||
defer g.mu.Unlock()
|
|
||||||
|
|
||||||
answers = slices.Clone(answers)
|
answers = slices.Clone(answers)
|
||||||
slices.SortStableFunc(answers, func(a, b Answer) int {
|
slices.SortStableFunc(answers, func(a, b Answer) int {
|
||||||
return a.Used.Compare(b.Used)
|
return a.Used.Compare(b.Used)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
g.answers.Purge()
|
||||||
|
|
||||||
now := g.now()
|
now := g.now()
|
||||||
|
|
||||||
for _, answer := range answers {
|
for _, answer := range answers {
|
||||||
@@ -225,13 +265,13 @@ func (g *GeoJS) Load(answers []Answer) {
|
|||||||
// nil when there is nothing to wait for.
|
// nil when there is nothing to wait for.
|
||||||
func (g *GeoJS) answerOrWait(
|
func (g *GeoJS) answerOrWait(
|
||||||
ctx context.Context, client netip.Prefix,
|
ctx context.Context, client netip.Prefix,
|
||||||
) (string, <-chan struct{}) {
|
) (Answer, <-chan struct{}) {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
country, found := g.kept(client)
|
answer, found := g.kept(client)
|
||||||
if found {
|
if found {
|
||||||
return country, nil
|
return answer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
w, waiting := g.waiting[client]
|
w, waiting := g.waiting[client]
|
||||||
@@ -242,10 +282,14 @@ func (g *GeoJS) answerOrWait(
|
|||||||
|
|
||||||
g.ask(ctx)
|
g.ask(ctx)
|
||||||
|
|
||||||
|
if !g.wait {
|
||||||
|
return Answer{}, nil // the answer is not needed before the request goes on
|
||||||
|
}
|
||||||
|
|
||||||
if w == nil {
|
if w == nil {
|
||||||
g.metrics.GeoJSUnanswered.Inc()
|
g.metrics.GeoJSUnanswered.Inc()
|
||||||
|
|
||||||
return "", nil // too many clients wait already
|
return Answer{}, nil // too many clients wait already
|
||||||
}
|
}
|
||||||
|
|
||||||
if !g.asking {
|
if !g.asking {
|
||||||
@@ -256,25 +300,25 @@ func (g *GeoJS) answerOrWait(
|
|||||||
if w.late {
|
if w.late {
|
||||||
g.metrics.GeoJSUnanswered.Inc()
|
g.metrics.GeoJSUnanswered.Inc()
|
||||||
|
|
||||||
return "", nil
|
return Answer{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", w.asked
|
return Answer{}, w.asked
|
||||||
}
|
}
|
||||||
|
|
||||||
// kept returns client's answer, if GeoJS gave it less than keepFor ago,
|
// kept returns client's answer, if GeoJS gave it less than keepFor ago,
|
||||||
// and notes that it was used.
|
// and notes that it was used.
|
||||||
func (g *GeoJS) kept(client netip.Prefix) (string, bool) {
|
func (g *GeoJS) kept(client netip.Prefix) (Answer, bool) {
|
||||||
now := g.now()
|
now := g.now()
|
||||||
|
|
||||||
kept, found := g.answers.Get(client)
|
kept, found := g.answers.Get(client)
|
||||||
if !found || now.Sub(kept.Answered) >= keepFor {
|
if !found || now.Sub(kept.Answered) >= keepFor {
|
||||||
return "", false
|
return Answer{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
kept.Used = now
|
kept.Used = now
|
||||||
|
|
||||||
return kept.Country, true
|
return *kept, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ask starts asking GeoJS about the waiting clients, unless a request to
|
// ask starts asking GeoJS about the waiting clients, unless a request to
|
||||||
@@ -292,7 +336,8 @@ func (g *GeoJS) ask(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// askAboutWaiting asks GeoJS about the waiting clients, one request at a
|
// askAboutWaiting asks GeoJS about the waiting clients, one request at a
|
||||||
// time, until none is left or GeoJS fails.
|
// time, until none is left or GeoJS fails. Each answer kept is given to
|
||||||
|
// Answered, outside the lock, since Answered takes locks of its own.
|
||||||
func (g *GeoJS) askAboutWaiting(ctx context.Context) {
|
func (g *GeoJS) askAboutWaiting(ctx context.Context) {
|
||||||
for {
|
for {
|
||||||
clients := g.nextClients()
|
clients := g.nextClients()
|
||||||
@@ -300,8 +345,16 @@ func (g *GeoJS) askAboutWaiting(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
countries, err := g.request(ctx, clients)
|
given, err := g.request(ctx, clients)
|
||||||
if !g.keep(clients, countries, err) {
|
kept, answered := g.keep(clients, given, err)
|
||||||
|
|
||||||
|
if g.answered != nil {
|
||||||
|
for _, answer := range kept {
|
||||||
|
g.answered(answer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !answered {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,32 +386,35 @@ func (g *GeoJS) nextClients() []netip.Prefix {
|
|||||||
return clients
|
return clients
|
||||||
}
|
}
|
||||||
|
|
||||||
// keep notes how a request to GeoJS about clients ended, and reports
|
// keep notes how a request to GeoJS about clients ended, given being the
|
||||||
// whether GeoJS answered about all of them. Each client whose address
|
// answer for each address GeoJS's answer names. It returns the answers it
|
||||||
// GeoJS's answer names gets its answer, with no country when GeoJS gave
|
// kept, and reports whether GeoJS answered about all of the clients. Each
|
||||||
// none. An answer that leaves an address out is a failure. After a
|
// client whose address GeoJS's answer names gets its answer. An answer
|
||||||
// failure GeoJS is left alone for a while, and every client still waiting
|
// that leaves an address out is a failure. After a failure GeoJS is left
|
||||||
// stops waiting and is asked about once GeoJS is asked again.
|
// alone for a while, and every client still waiting stops waiting and is
|
||||||
|
// asked about once GeoJS is asked again.
|
||||||
func (g *GeoJS) keep(
|
func (g *GeoJS) keep(
|
||||||
clients []netip.Prefix, countries map[netip.Addr]string, err error,
|
clients []netip.Prefix, given map[netip.Addr]Answer, err error,
|
||||||
) bool {
|
) ([]Answer, bool) {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
now := g.now()
|
now := g.now()
|
||||||
|
kept := make([]Answer, 0, len(clients))
|
||||||
leftOut := 0
|
leftOut := 0
|
||||||
|
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
country, named := countries[client.Addr()]
|
answer, named := given[client.Addr()]
|
||||||
if !named {
|
if !named {
|
||||||
leftOut++
|
leftOut++
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
g.answers.Add(client, &Answer{
|
answer.Client, answer.Answered, answer.Used = client, now, now
|
||||||
Client: client, Country: country, Answered: now, Used: now,
|
g.answers.Add(client, &answer)
|
||||||
})
|
kept = append(kept, answer)
|
||||||
|
|
||||||
close(g.waiting[client].asked)
|
close(g.waiting[client].asked)
|
||||||
delete(g.waiting, client)
|
delete(g.waiting, client)
|
||||||
}
|
}
|
||||||
@@ -384,27 +440,37 @@ func (g *GeoJS) keep(
|
|||||||
|
|
||||||
g.processLog.Warn("asking GeoJS failed",
|
g.processLog.Warn("asking GeoJS failed",
|
||||||
"error", err.Error(), "asking_again_in", g.retryDelay.String())
|
"error", err.Error(), "asking_again_in", g.retryDelay.String())
|
||||||
|
g.alerts.Raise(alerts.Alert{
|
||||||
|
Event: alerts.EventSourceFailure,
|
||||||
|
Reason: "asking GeoJS failed",
|
||||||
|
Detail: map[string]any{
|
||||||
|
"source": "geojs", "error": err.Error(),
|
||||||
|
"asking_again_in": g.retryDelay.String(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return false
|
return kept, false
|
||||||
}
|
}
|
||||||
|
|
||||||
g.retryDelay = 0
|
g.retryDelay = 0
|
||||||
|
|
||||||
return true
|
return kept, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// request asks GeoJS about clients in one request, and returns the
|
// request asks GeoJS about clients in one request, and returns the answer
|
||||||
// country it gave, in capitals, for each address its answer names.
|
// for each address GeoJS's answer names: its AS number and the AS's name,
|
||||||
|
// both "" for the AS number 64512, which GeoJS gives when it knows none,
|
||||||
|
// and its country, in capitals.
|
||||||
func (g *GeoJS) request(
|
func (g *GeoJS) request(
|
||||||
ctx context.Context, clients []netip.Prefix,
|
ctx context.Context, clients []netip.Prefix,
|
||||||
) (map[netip.Addr]string, error) {
|
) (map[netip.Addr]Answer, error) {
|
||||||
addrs := make([]string, 0, len(clients))
|
addrs := make([]string, 0, len(clients))
|
||||||
|
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
addrs = append(addrs, client.Addr().String())
|
addrs = append(addrs, client.Addr().String())
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
ctx, cancel := context.WithTimeout(ctx, g.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.url, http.NoBody)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.url, http.NoBody)
|
||||||
@@ -431,9 +497,12 @@ func (g *GeoJS) request(
|
|||||||
return nil, fmt.Errorf("%w %s", errStatus, res.Status)
|
return nil, fmt.Errorf("%w %s", errStatus, res.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:tagliatelle // GeoJS's own names
|
||||||
var answers []struct {
|
var answers []struct {
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Country string `json:"country"`
|
ASN int64 `json:"asn"`
|
||||||
|
ASName string `json:"organization_name"`
|
||||||
|
CountryCode string `json:"country_code"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.NewDecoder(io.LimitReader(res.Body, maxResponseBytes)).Decode(&answers)
|
err = json.NewDecoder(io.LimitReader(res.Body, maxResponseBytes)).Decode(&answers)
|
||||||
@@ -441,14 +510,22 @@ func (g *GeoJS) request(
|
|||||||
return nil, fmt.Errorf("read GeoJS's answer: %w", err)
|
return nil, fmt.Errorf("read GeoJS's answer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
countries := make(map[netip.Addr]string, len(answers))
|
given := make(map[netip.Addr]Answer, len(answers))
|
||||||
|
|
||||||
for _, item := range answers {
|
for _, item := range answers {
|
||||||
addr, err := netip.ParseAddr(item.IP)
|
addr, err := netip.ParseAddr(item.IP)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
countries[addr] = strings.ToUpper(item.Country)
|
continue
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return countries, nil
|
answer := Answer{Country: strings.ToUpper(item.CountryCode)}
|
||||||
|
if item.ASN != 0 && item.ASN != unknownASN {
|
||||||
|
answer.ASN = "AS" + strconv.FormatInt(item.ASN, 10)
|
||||||
|
answer.ASName = item.ASName
|
||||||
|
}
|
||||||
|
|
||||||
|
given[addr] = answer
|
||||||
|
}
|
||||||
|
|
||||||
|
return given, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+246
-15
@@ -6,6 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -14,15 +16,20 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// germany is where the stand-in for GeoJS places every address but
|
// germany is where the stand-in for GeoJS places every address but
|
||||||
// unplaced.
|
// unplaced, and asNumber, kept as asn, and asName the AS it gives them.
|
||||||
germany = "DE"
|
germany = "DE"
|
||||||
// unplaced is the address it cannot place.
|
asNumber = 64496
|
||||||
|
asn = "AS64496"
|
||||||
|
asName = "Example Net"
|
||||||
|
// unplaced is the address it cannot place, for which it gives the AS
|
||||||
|
// number 64512 and the AS name Unknown, as GeoJS does.
|
||||||
unplaced = "192.0.2.1"
|
unplaced = "192.0.2.1"
|
||||||
// leftOut is the address it leaves out of its answer when
|
// leftOut is the address it leaves out of its answer when
|
||||||
// answeringWithoutLeftOut.
|
// answeringWithoutLeftOut.
|
||||||
@@ -80,7 +87,7 @@ func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) {
|
|||||||
|
|
||||||
var earlier sync.WaitGroup
|
var earlier sync.WaitGroup
|
||||||
|
|
||||||
earlier.Go(func() { g.Country(t.Context(), netip.MustParsePrefix("203.0.113.1/32")) })
|
earlier.Go(func() { g.LookUp(t.Context(), netip.MustParsePrefix("203.0.113.1/32")) })
|
||||||
defer earlier.Wait()
|
defer earlier.Wait()
|
||||||
|
|
||||||
waitForRequests(t, geojs, 1)
|
waitForRequests(t, geojs, 1)
|
||||||
@@ -112,6 +119,58 @@ func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequestWaitsAsLongAsTheTimeoutSaysAndGeoJSIsAbandonedAfterIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
// A timeout longer than the default second, and a GeoJS that does
|
||||||
|
// not answer.
|
||||||
|
const longerTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
m := metrics.New(1, "app")
|
||||||
|
g := lookup.New(lookup.Params{
|
||||||
|
URL: lookup.URL,
|
||||||
|
Timeout: longerTimeout,
|
||||||
|
Wait: true,
|
||||||
|
Now: time.Now,
|
||||||
|
ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
|
Metrics: m,
|
||||||
|
Alerts: alerts.New(alerts.Params{}),
|
||||||
|
})
|
||||||
|
g.SetTransport(&standIn{answers: hanging})
|
||||||
|
|
||||||
|
var (
|
||||||
|
request sync.WaitGroup
|
||||||
|
waited time.Duration
|
||||||
|
)
|
||||||
|
|
||||||
|
request.Go(func() {
|
||||||
|
began := time.Now()
|
||||||
|
|
||||||
|
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), "")
|
||||||
|
|
||||||
|
waited = time.Since(began)
|
||||||
|
})
|
||||||
|
|
||||||
|
// A moment before the timeout runs out, GeoJS is still being asked:
|
||||||
|
// the request to it has not failed.
|
||||||
|
time.Sleep(longerTimeout - time.Millisecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantFailures(t, m, 0)
|
||||||
|
|
||||||
|
// As it runs out, the client's request goes on, and the request to
|
||||||
|
// GeoJS is abandoned, which counts as a failure.
|
||||||
|
request.Wait()
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
if waited != longerTimeout {
|
||||||
|
t.Errorf("waited %s for the answer, want %s", waited, longerTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantFailures(t, m, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestAddressLeftOutOfAnAnswerIsAskedAboutAgain(t *testing.T) {
|
func TestAddressLeftOutOfAnAnswerIsAskedAboutAgain(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -184,6 +243,96 @@ func TestCountryIsKeptInCapitals(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnswerHoldsTheASNumberTheASNameAndTheCountry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
_, clock, g := start()
|
||||||
|
placed := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
notPlaced := netip.MustParsePrefix(unplaced + "/32")
|
||||||
|
now := clock.Now()
|
||||||
|
|
||||||
|
// For the client it cannot place, GeoJS gives the AS number 64512
|
||||||
|
// and the AS name Unknown, which count as unknown.
|
||||||
|
for client, want := range map[netip.Prefix]lookup.Answer{
|
||||||
|
placed: {
|
||||||
|
Client: placed, ASN: asn, ASName: asName, Country: germany,
|
||||||
|
Answered: now, Used: now,
|
||||||
|
},
|
||||||
|
notPlaced: {Client: notPlaced, Answered: now, Used: now},
|
||||||
|
} {
|
||||||
|
got := g.LookUp(t.Context(), client)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("answer for %s\n%+v\nwant\n%+v", client, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithoutWaitTheRequestGoesOnAtOnceAndTheAnswerIsGivenWhenItComes(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
given []lookup.Answer
|
||||||
|
)
|
||||||
|
|
||||||
|
geojs := &standIn{answers: answeringSlowly}
|
||||||
|
clock := newClock()
|
||||||
|
m := metrics.New(1, "app")
|
||||||
|
g := lookup.New(lookup.Params{
|
||||||
|
URL: lookup.URL,
|
||||||
|
Timeout: timeout,
|
||||||
|
Answered: func(answer lookup.Answer) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
given = append(given, answer)
|
||||||
|
},
|
||||||
|
Now: clock.Now,
|
||||||
|
ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
|
Metrics: m,
|
||||||
|
Alerts: alerts.New(alerts.Params{}),
|
||||||
|
})
|
||||||
|
g.SetTransport(geojs)
|
||||||
|
|
||||||
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
|
// The request goes on at once, without an answer, and GeoJS is asked
|
||||||
|
// about the client, which it answers most of a second later.
|
||||||
|
began := time.Now()
|
||||||
|
|
||||||
|
got := g.LookUp(t.Context(), client)
|
||||||
|
if took := time.Since(began); took != 0 || got != (lookup.Answer{}) {
|
||||||
|
t.Errorf("waited %s for %+v, want no wait and no answer", took, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForRequests(t, geojs, 1)
|
||||||
|
wantAsked(t, geojs, 0, "203.0.113.9")
|
||||||
|
|
||||||
|
time.Sleep(timeout)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
now := clock.Now()
|
||||||
|
want := lookup.Answer{
|
||||||
|
Client: client, ASN: asn, ASName: asName, Country: germany,
|
||||||
|
Answered: now, Used: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
if !slices.Equal(given, []lookup.Answer{want}) {
|
||||||
|
t.Errorf("answers given %+v, want only %+v", given, want)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
wantCountry(t, g, client, germany)
|
||||||
|
wantUnanswered(t, m, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestFailureIsLoggedWithoutTheAddressesAskedAbout(t *testing.T) {
|
func TestFailureIsLoggedWithoutTheAddressesAskedAbout(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -194,9 +343,12 @@ func TestFailureIsLoggedWithoutTheAddressesAskedAbout(t *testing.T) {
|
|||||||
geojs := &standIn{answers: hanging}
|
geojs := &standIn{answers: hanging}
|
||||||
g := lookup.New(lookup.Params{
|
g := lookup.New(lookup.Params{
|
||||||
URL: lookup.URL,
|
URL: lookup.URL,
|
||||||
|
Timeout: timeout,
|
||||||
|
Wait: true,
|
||||||
Now: time.Now,
|
Now: time.Now,
|
||||||
ProcessLog: slog.New(slog.NewTextHandler(&log, nil)),
|
ProcessLog: slog.New(slog.NewTextHandler(&log, nil)),
|
||||||
Metrics: metrics.New(1),
|
Metrics: metrics.New(1, "app"),
|
||||||
|
Alerts: alerts.New(alerts.Params{}),
|
||||||
})
|
})
|
||||||
g.SetTransport(geojs)
|
g.SetTransport(geojs)
|
||||||
|
|
||||||
@@ -211,6 +363,45 @@ func TestFailureIsLoggedWithoutTheAddressesAskedAbout(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFailureRaisesASourceFailureAlertOncePerCooldown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
geojs, clock, g, queue := startWithAlerts()
|
||||||
|
clients := newClients()
|
||||||
|
|
||||||
|
geojs.set(failing)
|
||||||
|
|
||||||
|
wantCountry(t, g, clients(), "")
|
||||||
|
|
||||||
|
want := alerts.Alert{
|
||||||
|
Time: clock.Now(),
|
||||||
|
Event: alerts.EventSourceFailure,
|
||||||
|
Reason: "asking GeoJS failed",
|
||||||
|
Detail: map[string]any{
|
||||||
|
"source": "geojs",
|
||||||
|
"error": "GeoJS answered 503 Service Unavailable",
|
||||||
|
"asking_again_in": "1s",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next failure, a second later, is a repeat within the
|
||||||
|
// cooldown.
|
||||||
|
clock.advance(time.Second)
|
||||||
|
wantCountry(t, g, clients(), "")
|
||||||
|
wantRequests(t, geojs, 2)
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 1 || !reflect.DeepEqual(waiting[0], want) {
|
||||||
|
t.Errorf("alerts waiting %+v, want only %+v", waiting, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if queue.Suppressed() != 1 {
|
||||||
|
t.Errorf("%d alerts held back, want the repeat", queue.Suppressed())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestWaitingClientsAreAskedAboutInOneRequest(t *testing.T) {
|
func TestWaitingClientsAreAskedAboutInOneRequest(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -355,12 +546,15 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
m := metrics.New(1)
|
m := metrics.New(1, "app")
|
||||||
g := lookup.New(lookup.Params{
|
g := lookup.New(lookup.Params{
|
||||||
URL: lookup.URL,
|
URL: lookup.URL,
|
||||||
|
Timeout: timeout,
|
||||||
|
Wait: true,
|
||||||
Now: time.Now,
|
Now: time.Now,
|
||||||
ProcessLog: slog.New(slog.DiscardHandler),
|
ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
Metrics: m,
|
Metrics: m,
|
||||||
|
Alerts: alerts.New(alerts.Params{}),
|
||||||
})
|
})
|
||||||
g.SetTransport(&standIn{answers: failing})
|
g.SetTransport(&standIn{answers: failing})
|
||||||
|
|
||||||
@@ -450,21 +644,24 @@ func (s *standIn) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
list := make([]map[string]string, 0, len(addrs))
|
list := make([]map[string]any, 0, len(addrs))
|
||||||
|
|
||||||
for _, addr := range addrs {
|
for _, addr := range addrs {
|
||||||
country := germany
|
item := map[string]any{
|
||||||
|
"ip": addr, "asn": asNumber, "organization_name": asName,
|
||||||
|
"country_code": germany,
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case addr == unplaced:
|
case addr == unplaced:
|
||||||
country = ""
|
item = map[string]any{"ip": addr, "asn": 64512, "organization_name": "Unknown"}
|
||||||
case addr == leftOut && answers == answeringWithoutLeftOut:
|
case addr == leftOut && answers == answeringWithoutLeftOut:
|
||||||
continue
|
continue
|
||||||
case answers == answeringInLowerCase:
|
case answers == answeringInLowerCase:
|
||||||
country = strings.ToLower(germany)
|
item["country_code"] = strings.ToLower(germany)
|
||||||
}
|
}
|
||||||
|
|
||||||
list = append(list, map[string]string{"ip": addr, "country": country})
|
list = append(list, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
var answer any = list
|
var answer any = list
|
||||||
@@ -522,19 +719,43 @@ func (c *testClock) advance(d time.Duration) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// start returns a stand-in for GeoJS that answers, a clock, and a GeoJS
|
// start returns a stand-in for GeoJS that answers, a clock, and a GeoJS
|
||||||
// asking the stand-in by that clock.
|
// asking the stand-in by that clock, for which a request waits for its
|
||||||
|
// client's first answer.
|
||||||
func start() (*standIn, *testClock, *lookup.GeoJS) {
|
func start() (*standIn, *testClock, *lookup.GeoJS) {
|
||||||
|
geojs, clock, g, _ := startWithAlerts()
|
||||||
|
|
||||||
|
return geojs, clock, g
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWithAlerts is start, and returns the queue of the alerts GeoJS
|
||||||
|
// raises as well, for a webhook that is never sent them, with the default
|
||||||
|
// cooldown, by the same clock.
|
||||||
|
func startWithAlerts() (*standIn, *testClock, *lookup.GeoJS, *alerts.Queue) {
|
||||||
geojs := &standIn{}
|
geojs := &standIn{}
|
||||||
clock := &testClock{now: time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC)}
|
clock := newClock()
|
||||||
|
queue := alerts.New(alerts.Params{
|
||||||
|
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
||||||
|
Events: alerts.Events(),
|
||||||
|
Cooldown: 15 * time.Minute,
|
||||||
|
Now: clock.Now,
|
||||||
|
})
|
||||||
g := lookup.New(lookup.Params{
|
g := lookup.New(lookup.Params{
|
||||||
URL: lookup.URL,
|
URL: lookup.URL,
|
||||||
|
Timeout: timeout,
|
||||||
|
Wait: true,
|
||||||
Now: clock.Now,
|
Now: clock.Now,
|
||||||
ProcessLog: slog.New(slog.DiscardHandler),
|
ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
Metrics: metrics.New(1),
|
Metrics: metrics.New(1, "app"),
|
||||||
|
Alerts: queue,
|
||||||
})
|
})
|
||||||
g.SetTransport(geojs)
|
g.SetTransport(geojs)
|
||||||
|
|
||||||
return geojs, clock, g
|
return geojs, clock, g, queue
|
||||||
|
}
|
||||||
|
|
||||||
|
// newClock returns a clock set to the start of a day.
|
||||||
|
func newClock() *testClock {
|
||||||
|
return &testClock{now: time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newClients returns what returns a new IPv4 client each time it is
|
// newClients returns what returns a new IPv4 client each time it is
|
||||||
@@ -553,7 +774,7 @@ func newClients() func() netip.Prefix {
|
|||||||
func wantCountry(t *testing.T, g *lookup.GeoJS, client netip.Prefix, want string) {
|
func wantCountry(t *testing.T, g *lookup.GeoJS, client netip.Prefix, want string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
got := g.Country(t.Context(), client)
|
got := g.LookUp(t.Context(), client).Country
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("%s is in %q, want %q", client, got, want)
|
t.Errorf("%s is in %q, want %q", client, got, want)
|
||||||
}
|
}
|
||||||
@@ -598,6 +819,16 @@ func wantUnanswered(t *testing.T, m *metrics.Metrics, want float64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wantFailures checks how many requests to GeoJS m counts as failed.
|
||||||
|
func wantFailures(t *testing.T, m *metrics.Metrics, want float64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got := testutil.ToFloat64(m.GeoJSFailures)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%v requests to GeoJS failed, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// waitForRequests waits until g has done all it can before time passes,
|
// waitForRequests waits until g has done all it can before time passes,
|
||||||
// checks that GeoJS has had count requests, and returns the addresses each
|
// checks that GeoJS has had count requests, and returns the addresses each
|
||||||
// asked about.
|
// asked about.
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Package lookuptest writes lookup databases, IPinfo Lite files in their
|
||||||
|
// .mmdb form, for the tests of the packages that read them.
|
||||||
|
package lookuptest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/maxmind/mmdbwriter"
|
||||||
|
"github.com/maxmind/mmdbwriter/mmdbtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fileMode is the mode of the files written: read and written by their
|
||||||
|
// owner alone.
|
||||||
|
const fileMode = 0o600
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// its country, such as DE.
|
||||||
|
type Network struct {
|
||||||
|
ASN string
|
||||||
|
ASName string
|
||||||
|
Country string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write writes a lookup database at path that places each netblock in
|
||||||
|
// networks, such as 203.0.113.0/24, as its Network says, and no other
|
||||||
|
// address.
|
||||||
|
func Write(tb testing.TB, path string, networks map[string]Network) {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
records := make(map[string]mmdbtype.Map, len(networks))
|
||||||
|
for netblock, network := range networks {
|
||||||
|
records[netblock] = mmdbtype.Map{
|
||||||
|
"asn": mmdbtype.String(network.ASN),
|
||||||
|
"as_name": mmdbtype.String(network.ASName),
|
||||||
|
"country_code": mmdbtype.String(network.Country),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteRecords(tb, path, records)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteRecords writes a lookup database at path that holds each record in
|
||||||
|
// records for its netblock, and nothing for any other address.
|
||||||
|
func WriteRecords(tb testing.TB, path string, records map[string]mmdbtype.Map) {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
tree, err := mmdbwriter.New(mmdbwriter.Options{
|
||||||
|
DatabaseType: "ipinfo_lite",
|
||||||
|
// The tests' clients are in the netblocks kept for documentation.
|
||||||
|
IncludeReservedNetworks: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("new lookup database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for netblock, record := range records {
|
||||||
|
_, network, err := net.ParseCIDR(netblock)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("netblock %q: %v", netblock, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tree.Insert(network, record)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("insert %s: %v", netblock, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var database bytes.Buffer
|
||||||
|
|
||||||
|
_, err = tree.WriteTo(&database)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("write the lookup database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(path, database.Bytes(), fileMode)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,8 +26,11 @@ func TestSnapshotHoldsEachAnswerAndWhenItWasLastUsed(t *testing.T) {
|
|||||||
wantCountry(t, g, placed, germany)
|
wantCountry(t, g, placed, germany)
|
||||||
|
|
||||||
want := []lookup.Answer{
|
want := []lookup.Answer{
|
||||||
{Client: notPlaced, Country: "", Answered: asked, Used: asked},
|
{Client: notPlaced, Answered: asked, Used: asked},
|
||||||
{Client: placed, Country: germany, Answered: asked, Used: asked.Add(time.Hour)},
|
{
|
||||||
|
Client: placed, ASN: asn, ASName: asName, Country: germany,
|
||||||
|
Answered: asked, Used: asked.Add(time.Hour),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if got := g.Snapshot(); !slices.Equal(got, want) {
|
if got := g.Snapshot(); !slices.Equal(got, want) {
|
||||||
t.Errorf("snapshot\n%+v\nwant\n%+v", got, want)
|
t.Errorf("snapshot\n%+v\nwant\n%+v", got, want)
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// other is the label value under which the countries or AS numbers
|
||||||
|
// outside the busiest are counted.
|
||||||
|
const other = "other"
|
||||||
|
|
||||||
|
// busiest are the metrics by one thing the lookup finds of the client,
|
||||||
|
// its country or its AS number, for requests whose client's is known.
|
||||||
|
// The topN busiest countries or AS numbers, by their requests since the
|
||||||
|
// start, have series of their own, and the others are counted under
|
||||||
|
// other, so that there are never more than topN + 1 series. One that
|
||||||
|
// drops out of the busiest loses its series, and its next requests are
|
||||||
|
// counted under other; one that becomes one of them gets a series that
|
||||||
|
// counts from then on. Each series therefore only ever goes up.
|
||||||
|
type busiest struct {
|
||||||
|
topN int
|
||||||
|
|
||||||
|
requests *prometheus.CounterVec
|
||||||
|
requestBytes *prometheus.CounterVec
|
||||||
|
responseBytes *prometheus.CounterVec
|
||||||
|
// refused are, by country, the requests the country lists refused; nil
|
||||||
|
// by AS number.
|
||||||
|
refused *prometheus.CounterVec
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
// seen is each country's or AS number's requests since the start, by
|
||||||
|
// which they are ranked.
|
||||||
|
seen map[string]int64
|
||||||
|
// top are those with series of their own.
|
||||||
|
top map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCountries returns the metrics by country, with series of their own
|
||||||
|
// for the topN busiest countries.
|
||||||
|
func newCountries(topN int) *busiest {
|
||||||
|
countries := newBusiest(topN, "country", "the client's country")
|
||||||
|
countries.refused = counterVec("smallwebwaf_country_list_refusals_total",
|
||||||
|
"Requests the country lists refused, by the client's country.",
|
||||||
|
[]string{"country"})
|
||||||
|
|
||||||
|
return countries
|
||||||
|
}
|
||||||
|
|
||||||
|
// newASNs returns the metrics by AS number, with series of their own for
|
||||||
|
// the topN busiest AS numbers.
|
||||||
|
func newASNs(topN int) *busiest {
|
||||||
|
return newBusiest(topN, "asn", "the client's AS number")
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBusiest returns the metrics by label, which is described as
|
||||||
|
// description, with series of their own for the topN busiest values.
|
||||||
|
func newBusiest(topN int, label, description string) *busiest {
|
||||||
|
by := []string{label}
|
||||||
|
|
||||||
|
return &busiest{
|
||||||
|
topN: topN,
|
||||||
|
requests: counterVec("smallwebwaf_"+label+"_requests_total",
|
||||||
|
"Requests, by "+description+".", by),
|
||||||
|
requestBytes: counterVec("smallwebwaf_"+label+"_request_bytes_total",
|
||||||
|
"Request body bytes, by "+description+".", by),
|
||||||
|
responseBytes: counterVec("smallwebwaf_"+label+"_response_bytes_total",
|
||||||
|
"Response body bytes, by "+description+".", by),
|
||||||
|
seen: map[string]int64{},
|
||||||
|
top: map[string]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Describe and Collect make the metrics a prometheus.Collector, so that
|
||||||
|
// they are registered together.
|
||||||
|
func (b *busiest) Describe(ch chan<- *prometheus.Desc) {
|
||||||
|
for _, vec := range b.vecs() {
|
||||||
|
vec.Describe(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect is the other half of prometheus.Collector, with Describe.
|
||||||
|
func (b *busiest) Collect(ch chan<- prometheus.Metric) {
|
||||||
|
for _, vec := range b.vecs() {
|
||||||
|
vec.Collect(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// vecs returns the metrics: by AS number, those of requests and bytes; by
|
||||||
|
// country, the refusals by the country lists as well.
|
||||||
|
func (b *busiest) vecs() []*prometheus.CounterVec {
|
||||||
|
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
|
||||||
|
if b.refused != nil {
|
||||||
|
vecs = append(vecs, b.refused)
|
||||||
|
}
|
||||||
|
|
||||||
|
return vecs
|
||||||
|
}
|
||||||
|
|
||||||
|
// add counts a request from its log line, whose client's country or AS
|
||||||
|
// number, value, is known.
|
||||||
|
func (b *busiest) add(value string, line *requestlog.Line) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
b.seen[value]++
|
||||||
|
|
||||||
|
label := b.label(value)
|
||||||
|
b.requests.WithLabelValues(label).Inc()
|
||||||
|
b.requestBytes.WithLabelValues(label).Add(float64(line.RequestBytes))
|
||||||
|
b.responseBytes.WithLabelValues(label).Add(float64(line.ResponseBytes))
|
||||||
|
|
||||||
|
if b.refused != nil && line.Action == requestlog.ActionCountryDenied {
|
||||||
|
b.refused.WithLabelValues(label).Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// label returns the label a request from value is counted under: value
|
||||||
|
// while it is one of the busiest, other while it is not. A value busier
|
||||||
|
// than the least busy of them takes its place, and that one's series are
|
||||||
|
// dropped.
|
||||||
|
func (b *busiest) label(value string) string {
|
||||||
|
if b.top[value] {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(b.top) < b.topN {
|
||||||
|
b.top[value] = true
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
least := ""
|
||||||
|
|
||||||
|
for top := range b.top {
|
||||||
|
if least == "" || b.seen[top] < b.seen[least] {
|
||||||
|
least = top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.seen[value] <= b.seen[least] {
|
||||||
|
return other
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(b.top, least)
|
||||||
|
|
||||||
|
for _, vec := range b.vecs() {
|
||||||
|
vec.DeleteLabelValues(least)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.top[value] = true
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package metrics
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// other is the label under which the countries outside the busiest are
|
|
||||||
// counted.
|
|
||||||
const other = "other"
|
|
||||||
|
|
||||||
// countries are the metrics by the client's country, for requests whose
|
|
||||||
// client's country is known. The topN busiest countries, by their requests
|
|
||||||
// since the start, have series of their own, and the others are counted
|
|
||||||
// under other, so that there are never more than topN + 1 series. A
|
|
||||||
// country that drops out of the busiest loses its series, and its next
|
|
||||||
// requests are counted under other; one that becomes one of them gets a
|
|
||||||
// series that counts from then on. Each series therefore only ever goes
|
|
||||||
// up.
|
|
||||||
type countries struct {
|
|
||||||
topN int
|
|
||||||
|
|
||||||
requests *prometheus.CounterVec
|
|
||||||
requestBytes *prometheus.CounterVec
|
|
||||||
responseBytes *prometheus.CounterVec
|
|
||||||
// refused are the requests the country lists refused.
|
|
||||||
refused *prometheus.CounterVec
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
// seen is each country's requests since the start, by which the
|
|
||||||
// countries are ranked. GeoJS gives two-letter codes, so it holds at
|
|
||||||
// most a few hundred.
|
|
||||||
seen map[string]int64
|
|
||||||
// top are the countries with series of their own.
|
|
||||||
top map[string]bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// newCountries returns the metrics by country, with series of their own
|
|
||||||
// for the topN busiest countries.
|
|
||||||
func newCountries(topN int) *countries {
|
|
||||||
byCountry := []string{"country"}
|
|
||||||
|
|
||||||
return &countries{
|
|
||||||
topN: topN,
|
|
||||||
requests: counterVec("smallwebwaf_country_requests_total",
|
|
||||||
"Requests, by the client's country.", byCountry),
|
|
||||||
requestBytes: counterVec("smallwebwaf_country_request_bytes_total",
|
|
||||||
"Request body bytes, by the client's country.", byCountry),
|
|
||||||
responseBytes: counterVec("smallwebwaf_country_response_bytes_total",
|
|
||||||
"Response body bytes, by the client's country.", byCountry),
|
|
||||||
refused: counterVec("smallwebwaf_country_list_refusals_total",
|
|
||||||
"Requests the country lists refused, by the client's country.",
|
|
||||||
byCountry),
|
|
||||||
seen: map[string]int64{},
|
|
||||||
top: map[string]bool{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add counts a request from its log line, whose country is known.
|
|
||||||
func (c *countries) add(line *requestlog.Line) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
c.seen[line.Country]++
|
|
||||||
|
|
||||||
label := c.label(line.Country)
|
|
||||||
c.requests.WithLabelValues(label).Inc()
|
|
||||||
c.requestBytes.WithLabelValues(label).Add(float64(line.RequestBytes))
|
|
||||||
c.responseBytes.WithLabelValues(label).Add(float64(line.ResponseBytes))
|
|
||||||
|
|
||||||
if line.Action == requestlog.ActionCountryDenied {
|
|
||||||
c.refused.WithLabelValues(label).Inc()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// label returns the label a request from country is counted under: the
|
|
||||||
// country while it is one of the busiest, other while it is not. A
|
|
||||||
// country busier than the least busy of them takes its place, and that
|
|
||||||
// country's series are dropped.
|
|
||||||
func (c *countries) label(country string) string {
|
|
||||||
if c.top[country] {
|
|
||||||
return country
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(c.top) < c.topN {
|
|
||||||
c.top[country] = true
|
|
||||||
|
|
||||||
return country
|
|
||||||
}
|
|
||||||
|
|
||||||
least := ""
|
|
||||||
|
|
||||||
for top := range c.top {
|
|
||||||
if least == "" || c.seen[top] < c.seen[least] {
|
|
||||||
least = top
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.seen[country] <= c.seen[least] {
|
|
||||||
return other
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(c.top, least)
|
|
||||||
|
|
||||||
for _, vec := range []*prometheus.CounterVec{
|
|
||||||
c.requests, c.requestBytes, c.responseBytes, c.refused,
|
|
||||||
} {
|
|
||||||
vec.DeleteLabelValues(least)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.top[country] = true
|
|
||||||
|
|
||||||
return country
|
|
||||||
}
|
|
||||||
+294
-28
@@ -6,19 +6,26 @@ package metrics
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metrics are smallwebwaf's metrics. They are safe for concurrent use.
|
// Metrics are smallwebwaf's metrics. They are safe for concurrent use.
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
registry *prometheus.Registry
|
// registry gives every metric registered with it the label instance.
|
||||||
|
registry prometheus.Registerer
|
||||||
handler http.Handler
|
handler http.Handler
|
||||||
|
|
||||||
inFlight prometheus.Gauge
|
inFlight prometheus.Gauge
|
||||||
@@ -30,12 +37,17 @@ type Metrics struct {
|
|||||||
rateLimitHits *prometheus.CounterVec
|
rateLimitHits *prometheus.CounterVec
|
||||||
sizeAndTimeLimitHits *prometheus.CounterVec
|
sizeAndTimeLimitHits *prometheus.CounterVec
|
||||||
offences *prometheus.CounterVec
|
offences *prometheus.CounterVec
|
||||||
countries *countries
|
// ruleMatches are made by AddRules, and reputationHits by
|
||||||
|
// AddReputation.
|
||||||
|
ruleMatches *prometheus.CounterVec
|
||||||
|
reputationHits *prometheus.CounterVec
|
||||||
|
countries *busiest
|
||||||
|
asns *busiest
|
||||||
|
|
||||||
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
|
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
|
||||||
// that failed. GeoJSUnanswered are the requests whose client counted
|
// that failed. GeoJSUnanswered are the requests that needed their
|
||||||
// as coming from an unknown country because GeoJS had not answered
|
// client's answer, for a setting that acts on it, and went on without
|
||||||
// about it in time.
|
// it because GeoJS had not given it in time.
|
||||||
GeoJSRequests prometheus.Counter
|
GeoJSRequests prometheus.Counter
|
||||||
GeoJSFailures prometheus.Counter
|
GeoJSFailures prometheus.Counter
|
||||||
GeoJSUnanswered prometheus.Counter
|
GeoJSUnanswered prometheus.Counter
|
||||||
@@ -44,17 +56,23 @@ type Metrics struct {
|
|||||||
stateFileWriteFailures *prometheus.CounterVec
|
stateFileWriteFailures *prometheus.CounterVec
|
||||||
stateFileLastWrite *prometheus.GaugeVec
|
stateFileLastWrite *prometheus.GaugeVec
|
||||||
stateFileSize *prometheus.GaugeVec
|
stateFileSize *prometheus.GaugeVec
|
||||||
|
stateFileEditsTakenIn *prometheus.CounterVec
|
||||||
|
stateFileEditsSetAside *prometheus.CounterVec
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 get series of their own
|
// topN is how many countries and how many AS numbers get series of their
|
||||||
// (SWWAF_METRICS_TOP_N).
|
// own (SWWAF_METRICS_TOP_N). Every metric carries instanceName
|
||||||
func New(topN int) *Metrics {
|
// (SWWAF_INSTANCE_NAME) as its label instance.
|
||||||
|
func New(topN int, instanceName string) *Metrics {
|
||||||
byStatus := []string{"status_class", "action"}
|
byStatus := []string{"status_class", "action"}
|
||||||
byFile := []string{"file"}
|
byFile := []string{"file"}
|
||||||
|
registry := prometheus.NewRegistry()
|
||||||
|
|
||||||
m := &Metrics{
|
m := &Metrics{
|
||||||
registry: prometheus.NewRegistry(),
|
registry: prometheus.WrapRegistererWith(
|
||||||
|
prometheus.Labels{"instance": instanceName}, registry),
|
||||||
|
handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
|
||||||
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
|
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_requests_in_flight",
|
Name: "smallwebwaf_requests_in_flight",
|
||||||
Help: "Requests under way.",
|
Help: "Requests under way.",
|
||||||
@@ -76,14 +94,16 @@ func New(topN int) *Metrics {
|
|||||||
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("smallwebwaf_rate_limit_hits_total",
|
||||||
"Requests that broke a rate limit, by its window.",
|
"Requests that broke a rate limit or a byte limit, by its window and "+
|
||||||
[]string{"window"}),
|
"its kind, requests or bytes.",
|
||||||
|
[]string{"window", "kind"}),
|
||||||
sizeAndTimeLimitHits: counterVec("smallwebwaf_size_and_time_limit_hits_total",
|
sizeAndTimeLimitHits: counterVec("smallwebwaf_size_and_time_limit_hits_total",
|
||||||
"Requests that passed a size or time limit, by its setting.",
|
"Requests that passed a size or time limit, by its setting.",
|
||||||
[]string{"limit"}),
|
[]string{"limit"}),
|
||||||
offences: counterVec("smallwebwaf_offences_total",
|
offences: counterVec("smallwebwaf_offences_total",
|
||||||
"Offences, by kind.", []string{"kind"}),
|
"Offences, by kind.", []string{"kind"}),
|
||||||
countries: newCountries(topN),
|
countries: newCountries(topN),
|
||||||
|
asns: newASNs(topN),
|
||||||
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_geojs_requests_total",
|
Name: "smallwebwaf_geojs_requests_total",
|
||||||
Help: "Requests to GeoJS.",
|
Help: "Requests to GeoJS.",
|
||||||
@@ -94,8 +114,8 @@ func New(topN int) *Metrics {
|
|||||||
}),
|
}),
|
||||||
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_geojs_unanswered_total",
|
Name: "smallwebwaf_geojs_unanswered_total",
|
||||||
Help: "Requests whose client counted as coming from an unknown " +
|
Help: "Requests that needed their client's answer from GeoJS and " +
|
||||||
"country because GeoJS had not answered about it in time.",
|
"went on without it, because GeoJS had not given it in time.",
|
||||||
}),
|
}),
|
||||||
stateFileWrites: counterVec("smallwebwaf_state_file_writes_total",
|
stateFileWrites: counterVec("smallwebwaf_state_file_writes_total",
|
||||||
"Writes of each state file.", byFile),
|
"Writes of each state file.", byFile),
|
||||||
@@ -105,21 +125,23 @@ func New(topN int) *Metrics {
|
|||||||
"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("smallwebwaf_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",
|
||||||
|
"Edits of each state file taken in while running.", byFile),
|
||||||
|
stateFileEditsSetAside: counterVec("smallwebwaf_state_file_edits_set_aside_total",
|
||||||
|
"Edits of each state file renamed to <name>.bad because they did not parse.",
|
||||||
|
byFile),
|
||||||
}
|
}
|
||||||
|
|
||||||
m.handler = promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{})
|
|
||||||
|
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
collectors.NewGoCollector(),
|
collectors.NewGoCollector(),
|
||||||
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.rateLimitHits, m.sizeAndTimeLimitHits, m.offences, m.countries, m.asns,
|
||||||
m.countries.requests, m.countries.requestBytes, m.countries.responseBytes,
|
|
||||||
m.countries.refused,
|
|
||||||
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
|
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
|
||||||
m.stateFileWrites, m.stateFileWriteFailures,
|
m.stateFileWrites, m.stateFileWriteFailures,
|
||||||
m.stateFileLastWrite, m.stateFileSize,
|
m.stateFileLastWrite, m.stateFileSize,
|
||||||
|
m.stateFileEditsTakenIn, m.stateFileEditsSetAside,
|
||||||
)
|
)
|
||||||
|
|
||||||
return m
|
return m
|
||||||
@@ -127,19 +149,22 @@ func New(topN int) *Metrics {
|
|||||||
|
|
||||||
// AddBansAndClients adds the metrics read from the ledger and the table
|
// AddBansAndClients adds the metrics read from the ledger and the table
|
||||||
// of clients as the metrics are asked for: the bans made since the start,
|
// of clients as the metrics are asked for: the bans made since the start,
|
||||||
// the bans active and permanent at now, and the clients in the table.
|
// by cause, the bans active and permanent at now, and the clients in the
|
||||||
|
// table.
|
||||||
func (m *Metrics) AddBansAndClients(
|
func (m *Metrics) AddBansAndClients(
|
||||||
ledger *bans.Ledger, limiter *ratelimit.Limiter, now func() time.Time,
|
ledger *bans.Ledger, limiter *ratelimit.Limiter, now func() time.Time,
|
||||||
) {
|
) {
|
||||||
m.registry.MustRegister(
|
for _, cause := range []string{bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin} {
|
||||||
// Every ban smallwebwaf makes so far is for a broken limit.
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
|
||||||
Name: "smallwebwaf_bans_made_total",
|
Name: "smallwebwaf_bans_made_total",
|
||||||
Help: "Bans made, by cause.",
|
Help: "Bans made, by cause.",
|
||||||
ConstLabels: prometheus.Labels{"cause": "limit"},
|
ConstLabels: prometheus.Labels{"cause": cause},
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(ledger.Made())
|
return float64(ledger.Made(cause))
|
||||||
}),
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_active_bans",
|
Name: "smallwebwaf_active_bans",
|
||||||
Help: "Bans active now, the permanent ones included.",
|
Help: "Bans active now, the permanent ones included.",
|
||||||
@@ -150,7 +175,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
}),
|
}),
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_permanent_bans",
|
Name: "smallwebwaf_permanent_bans",
|
||||||
Help: "Permanent bans.",
|
Help: "Permanent bans not lifted.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
_, permanent := ledger.Count(now())
|
_, permanent := ledger.Count(now())
|
||||||
|
|
||||||
@@ -165,6 +190,191 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddRules adds the metrics of the rule files: the requests that matched
|
||||||
|
// each rule, which RuleMatched counts, and the rules loaded from
|
||||||
|
// ruleFiles, read as the metrics are asked for. It is called once, before
|
||||||
|
// RuleMatched.
|
||||||
|
func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
||||||
|
m.ruleMatches = counterVec("smallwebwaf_rule_matches_total",
|
||||||
|
"Requests that matched a rule of the rule files, by its id and action.",
|
||||||
|
[]string{"rule_id", "action"})
|
||||||
|
|
||||||
|
m.registry.MustRegister(m.ruleMatches,
|
||||||
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
|
Name: "smallwebwaf_rules_loaded",
|
||||||
|
Help: "Rules loaded from the rule files.",
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(ruleFiles.Len())
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRemoteLog adds the metrics of sending the log lines to
|
||||||
|
// SWWAF_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
||||||
|
// lines sent, those dropped, and those waiting in the buffer.
|
||||||
|
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
||||||
|
m.registry.MustRegister(
|
||||||
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
|
Name: "smallwebwaf_remote_log_lines_sent_total",
|
||||||
|
Help: "Log lines sent to SWWAF_LOG_REMOTE_URL.",
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(remote.Sent())
|
||||||
|
}),
|
||||||
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
|
Name: "smallwebwaf_remote_log_lines_dropped_total",
|
||||||
|
Help: "Log lines dropped: the oldest in a full buffer, and those " +
|
||||||
|
"whose sending failed.",
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(remote.Dropped())
|
||||||
|
}),
|
||||||
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
|
Name: "smallwebwaf_remote_log_buffer_depth",
|
||||||
|
Help: "Log lines in the buffer, waiting to be sent.",
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(remote.Depth())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddLookupFile adds the metrics of the lookup database, read as the
|
||||||
|
// metrics are asked for: when the file in use was read, which lastRead
|
||||||
|
// returns, and the replacements of it that could not be read, which
|
||||||
|
// readFailures returns. The lookup package's File, which has both, cannot
|
||||||
|
// be named here: that package counts GeoJS's requests in these metrics.
|
||||||
|
func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) {
|
||||||
|
m.registry.MustRegister(
|
||||||
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
|
Name: "smallwebwaf_lookup_database_last_read_timestamp_seconds",
|
||||||
|
Help: "When the lookup database in use was read, in seconds since 1970.",
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(lastRead().Unix())
|
||||||
|
}),
|
||||||
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
|
Name: "smallwebwaf_lookup_database_read_failures_total",
|
||||||
|
Help: "Replacements of the lookup database that could not be read.",
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(readFailures())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceLabel is the label of the reputation metrics: a list's URL, a
|
||||||
|
// DNSBL zone, its key masked, or abuseipdb.
|
||||||
|
const sourceLabel = "source"
|
||||||
|
|
||||||
|
// AddReputation adds the metrics of the lists fetched from URLs and of the
|
||||||
|
// DNSBL zones, by source, each list's URL or each zone, its key masked as
|
||||||
|
// config.MaskZoneKey masks it: the requests whose client a blocklist, a
|
||||||
|
// zone's verdict or AbuseIPDB's score lists, which ReputationHit counts,
|
||||||
|
// and, read from lists and dnsbl as the metrics are asked for, for a list,
|
||||||
|
// the fetches that failed and when the copy in use was fetched, and for a
|
||||||
|
// zone, the queries made and those that failed. It is called once, before
|
||||||
|
// ReputationHit.
|
||||||
|
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
|
||||||
|
m.reputationHits = counterVec("smallwebwaf_reputation_hits_total",
|
||||||
|
"Requests whose client a blocklist, a DNSBL zone or AbuseIPDB lists, by "+
|
||||||
|
"the blocklist's URL, the zone, or abuseipdb.",
|
||||||
|
[]string{sourceLabel})
|
||||||
|
m.registry.MustRegister(m.reputationHits)
|
||||||
|
|
||||||
|
for _, zone := range dnsbl.Zones() {
|
||||||
|
source := prometheus.Labels{sourceLabel: config.MaskZoneKey(zone)}
|
||||||
|
|
||||||
|
m.addReputationQueries(source, func() int { return dnsbl.Queries(zone) })
|
||||||
|
m.addReputationFailures(source, func() int { return dnsbl.Failures(zone) })
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, listURL := range lists.URLs() {
|
||||||
|
source := prometheus.Labels{sourceLabel: listURL}
|
||||||
|
|
||||||
|
m.addReputationFailures(source, func() int { return lists.Failures(listURL) })
|
||||||
|
m.registry.MustRegister(
|
||||||
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
|
Name: "smallwebwaf_reputation_last_fetch_timestamp_seconds",
|
||||||
|
Help: "When the copy of the list in use was fetched, in seconds since " +
|
||||||
|
"1970, or 0 while there is none.",
|
||||||
|
ConstLabels: source,
|
||||||
|
}, func() float64 {
|
||||||
|
fetched := lists.Fetched(listURL)
|
||||||
|
if fetched.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return float64(fetched.Unix())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
|
||||||
|
// read from abuseIPDB as the metrics are asked for: the checks made, those
|
||||||
|
// that failed, and how many checks the day's budget has left. It is
|
||||||
|
// called once, after AddReputation, while SWWAF_ABUSEIPDB_KEY is set.
|
||||||
|
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
||||||
|
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
|
||||||
|
|
||||||
|
m.addReputationQueries(source, abuseIPDB.Checked)
|
||||||
|
m.addReputationFailures(source, abuseIPDB.Failures)
|
||||||
|
m.registry.MustRegister(
|
||||||
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
|
Name: "smallwebwaf_reputation_daily_budget_remaining",
|
||||||
|
Help: "Checks of the day's SWWAF_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
||||||
|
ConstLabels: source,
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(abuseIPDB.BudgetLeft())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReputationHit counts a request whose client source lists: a blocklist,
|
||||||
|
// by its URL, a DNSBL zone, its key masked, or AbuseIPDB, abuseipdb.
|
||||||
|
func (m *Metrics) ReputationHit(source string) {
|
||||||
|
m.reputationHits.WithLabelValues(source).Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddAlerts adds the metrics of the alerts sent to each destination set,
|
||||||
|
// read from queue as the metrics are asked for, by destination: the
|
||||||
|
// alerts sent, the requests to the destination that failed, the alerts
|
||||||
|
// held back, which are the same for every destination, and those
|
||||||
|
// dropped. With no destination set, it adds none.
|
||||||
|
func (m *Metrics) AddAlerts(queue *alerts.Queue) {
|
||||||
|
for _, name := range queue.DestinationsSet() {
|
||||||
|
destination := prometheus.Labels{"destination": name}
|
||||||
|
|
||||||
|
m.registry.MustRegister(
|
||||||
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
|
Name: "smallwebwaf_alerts_sent_total",
|
||||||
|
Help: "Alerts the destination took.",
|
||||||
|
ConstLabels: destination,
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(queue.Counts(name).Sent)
|
||||||
|
}),
|
||||||
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
|
Name: "smallwebwaf_alerts_failed_total",
|
||||||
|
Help: "Requests to the destination that failed.",
|
||||||
|
ConstLabels: destination,
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(queue.Counts(name).Failed)
|
||||||
|
}),
|
||||||
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
|
Name: "smallwebwaf_alerts_suppressed_total",
|
||||||
|
Help: "Alerts held back: repeats within SWWAF_ALERT_COOLDOWN, and " +
|
||||||
|
"alerts past SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
||||||
|
ConstLabels: destination,
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(queue.Suppressed())
|
||||||
|
}),
|
||||||
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
|
Name: "smallwebwaf_alerts_dropped_total",
|
||||||
|
Help: "Alerts dropped, the oldest first, from a full queue, and " +
|
||||||
|
"alerts given up as the destination refused them.",
|
||||||
|
ConstLabels: destination,
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(queue.Counts(name).Dropped)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ServeHTTP answers with the metrics in the Prometheus text format.
|
// ServeHTTP answers with the metrics in the Prometheus text format.
|
||||||
func (m *Metrics) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (m *Metrics) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
m.handler.ServeHTTP(w, r)
|
m.handler.ServeHTTP(w, r)
|
||||||
@@ -195,7 +405,15 @@ func (m *Metrics) RequestEnded(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if line.LimitHit != "" {
|
if line.LimitHit != "" {
|
||||||
m.rateLimitHits.WithLabelValues(line.LimitHit).Inc()
|
// The log line names a byte limit's window with _bytes after it.
|
||||||
|
window, isBytes := strings.CutSuffix(line.LimitHit, "_bytes")
|
||||||
|
|
||||||
|
kind := ratelimit.KindRequests
|
||||||
|
if isBytes {
|
||||||
|
kind = ratelimit.KindBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
m.rateLimitHits.WithLabelValues(window, kind).Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
if limit != "" {
|
if limit != "" {
|
||||||
@@ -207,8 +425,18 @@ func (m *Metrics) RequestEnded(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if line.Country != "" {
|
if line.Country != "" {
|
||||||
m.countries.add(line)
|
m.countries.add(line.Country, line)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if line.ASN != "" {
|
||||||
|
m.asns.add(line.ASN, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleMatched counts a request that matched the rule id, whose action is
|
||||||
|
// action.
|
||||||
|
func (m *Metrics) RuleMatched(id, action string) {
|
||||||
|
m.ruleMatches.WithLabelValues(id, action).Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateFileWritten counts a write of the state file name, of size bytes,
|
// StateFileWritten counts a write of the state file name, of size bytes,
|
||||||
@@ -230,6 +458,44 @@ func (m *Metrics) StateFileWritten(name string, size int, err error) {
|
|||||||
m.stateFileSize.WithLabelValues(name).Set(float64(size))
|
m.stateFileSize.WithLabelValues(name).Set(float64(size))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StateFileEditTakenIn counts an admin's edit of the state file name
|
||||||
|
// taken in while smallwebwaf runs.
|
||||||
|
func (m *Metrics) StateFileEditTakenIn(name string) {
|
||||||
|
m.stateFileEditsTakenIn.WithLabelValues(name).Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateFileEditSetAside counts an admin's edit of the state file name
|
||||||
|
// renamed to name.bad because it did not parse.
|
||||||
|
func (m *Metrics) StateFileEditSetAside(name string) {
|
||||||
|
m.stateFileEditsSetAside.WithLabelValues(name).Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// addReputationQueries adds the counter of the queries to source, a DNSBL
|
||||||
|
// zone, or of the checks of clients with AbuseIPDB, which count tells.
|
||||||
|
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
|
||||||
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
|
Name: "smallwebwaf_reputation_queries_total",
|
||||||
|
Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
|
||||||
|
ConstLabels: source,
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(count())
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// addReputationFailures adds the counter of the fetches of source, a
|
||||||
|
// list, the queries to it, a DNSBL zone, or the checks with it, AbuseIPDB,
|
||||||
|
// that failed, which count tells.
|
||||||
|
func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) {
|
||||||
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
|
Name: "smallwebwaf_reputation_failures_total",
|
||||||
|
Help: "Fetches of the list, queries to the DNSBL zone, or checks with " +
|
||||||
|
"AbuseIPDB, that failed.",
|
||||||
|
ConstLabels: source,
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(count())
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// statusClass returns the class of status, such as 2xx, or none when no
|
// statusClass returns the class of status, such as 2xx, or none when no
|
||||||
// status was sent.
|
// status was sent.
|
||||||
func statusClass(status int) string {
|
func statusClass(status int) string {
|
||||||
|
|||||||
+289
-7
@@ -1,26 +1,60 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
// banBodyMaxBytes is the most of the body of a request to add a ban that
|
||||||
|
// is read; its three fields need far less.
|
||||||
|
const banBodyMaxBytes = 4 << 10
|
||||||
|
|
||||||
|
// permanent is how the log line and the ban endpoint name a ban that
|
||||||
|
// never ends.
|
||||||
|
const permanent = "permanent"
|
||||||
|
|
||||||
|
var (
|
||||||
|
errNotBanToAdd = errors.New(
|
||||||
|
"the body is not a JSON object of netblock, duration and reason")
|
||||||
|
errNotNetblock = errors.New(
|
||||||
|
"is not an address or a netblock, such as 203.0.113.9 or 203.0.113.0/24")
|
||||||
|
errMappedNetblock = errors.New(
|
||||||
|
"is IPv4-mapped: give the IPv4 netblock, such as 203.0.113.0/24")
|
||||||
|
errZone = errors.New("has a zone, which a netblock cannot have")
|
||||||
|
errNotDuration = errors.New(
|
||||||
|
"is not a duration above zero, such as 1h or 7d, or permanent")
|
||||||
|
errNotAddress = errors.New("is not an address, such as 203.0.113.9")
|
||||||
)
|
)
|
||||||
|
|
||||||
// answerAdmin answers a request for smallwebwaf itself, under
|
// answerAdmin answers a request for smallwebwaf itself, under
|
||||||
// /_smallwebwaf/, once it has passed the checks: GET MetricsPath with
|
// /_smallwebwaf/, once it has passed the checks. Each endpoint needs a
|
||||||
// SWWAF_METRICS_TOKEN gets the metrics, and without it is refused with
|
// token, sent as Authorization: Bearer <token>: the metrics
|
||||||
// 401. Any other request gets 404, as the metrics do while
|
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without
|
||||||
// SWWAF_METRICS_TOKEN is unset.
|
// it is refused with 401. An 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()
|
rq.startClientResponseTimeout()
|
||||||
|
|
||||||
token := rq.h.config.MetricsToken
|
token, answer := rq.endpoint()
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case token == "" || rq.in.Method != http.MethodGet || rq.in.URL.Path != MetricsPath:
|
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.out.Header().Set("WWW-Authenticate", "Bearer")
|
rq.out.Header().Set("WWW-Authenticate", "Bearer")
|
||||||
@@ -29,7 +63,29 @@ func (rq *request) answerAdmin() {
|
|||||||
action: requestlog.ActionAdmin,
|
action: requestlog.ActionAdmin,
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
rq.h.metrics.ServeHTTP(rq.out, rq.in)
|
answer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpoint returns the token the request's endpoint needs, and what
|
||||||
|
// answers the request there; "" when there is no such endpoint.
|
||||||
|
func (rq *request) endpoint() (string, func()) {
|
||||||
|
cfg := rq.h.config
|
||||||
|
method, path := rq.in.Method, rq.in.URL.Path
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case method == http.MethodGet && path == MetricsPath:
|
||||||
|
return cfg.MetricsToken, func() { rq.h.metrics.ServeHTTP(rq.out, rq.in) }
|
||||||
|
case method == http.MethodGet && path == BansPath:
|
||||||
|
return cfg.AdminToken, rq.listBans
|
||||||
|
case method == http.MethodPost && path == BansPath:
|
||||||
|
return cfg.AdminToken, rq.addBan
|
||||||
|
case method == http.MethodDelete && strings.HasPrefix(path, BansPath+"/"):
|
||||||
|
return cfg.AdminToken, rq.liftBans
|
||||||
|
case method == http.MethodGet && strings.HasPrefix(path, ClientsPath):
|
||||||
|
return cfg.AdminToken, rq.showClient
|
||||||
|
default:
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,3 +97,229 @@ func hasToken(r *http.Request, token string) bool {
|
|||||||
return strings.EqualFold(scheme, "Bearer") &&
|
return strings.EqualFold(scheme, "Bearer") &&
|
||||||
subtle.ConstantTimeCompare([]byte(sent), []byte(token)) == 1
|
subtle.ConstantTimeCompare([]byte(sent), []byte(token)) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listBans answers GET BansPath with every ban held.
|
||||||
|
func (rq *request) listBans() {
|
||||||
|
rq.answerBans(rq.h.ledger.Snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// banToAdd is the body of POST BansPath.
|
||||||
|
type banToAdd struct {
|
||||||
|
// Netblock is a netblock, or a client's address, which stands for the
|
||||||
|
// netblock a ban on that client covers.
|
||||||
|
Netblock string `json:"netblock"`
|
||||||
|
// Duration is how long the ban lasts, as a setting gives a duration,
|
||||||
|
// or permanent.
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// addBan answers POST BansPath: it bans the netblock the body names, as
|
||||||
|
// an admin, from now for the duration the body gives, with its reason,
|
||||||
|
// and answers with that ban.
|
||||||
|
func (rq *request) addBan() {
|
||||||
|
// The body must arrive within SWWAF_CLIENT_REQUEST_TIMEOUT, as any
|
||||||
|
// other request's must.
|
||||||
|
rq.stopReadingBody(rq.clientRequestDeadline())
|
||||||
|
|
||||||
|
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 (
|
||||||
|
netblock netip.Prefix
|
||||||
|
expires time.Time
|
||||||
|
now = rq.h.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
netblock, err = rq.h.banNetblock(toAdd.Netblock)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
expires, err = expiry(toAdd.Duration, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rq.out, err.Error(), http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ban := rq.h.ledger.BanForAdmin(netblock, now, expires, toAdd.Reason)
|
||||||
|
rq.answerBans([]bans.Ban{ban})
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBanToAdd reads the body of POST BansPath: a JSON object with
|
||||||
|
// nothing but whitespace after it, in at most banBodyMaxBytes.
|
||||||
|
func (rq *request) readBanToAdd() (banToAdd, error) {
|
||||||
|
var body io.ReadCloser = http.NoBody
|
||||||
|
if rq.body != nil {
|
||||||
|
body = rq.body
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(http.MaxBytesReader(nil, body, banBodyMaxBytes))
|
||||||
|
if err != nil {
|
||||||
|
return banToAdd{}, fmt.Errorf("%w: %w", errNotBanToAdd, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var toAdd banToAdd
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
|
||||||
|
err = decoder.Decode(&toAdd)
|
||||||
|
if err != nil {
|
||||||
|
return banToAdd{}, fmt.Errorf("%w: %w", errNotBanToAdd, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token returns io.EOF only when nothing but whitespace is left.
|
||||||
|
_, err = decoder.Token()
|
||||||
|
if !errors.Is(err, io.EOF) {
|
||||||
|
return banToAdd{}, fmt.Errorf("%w: more follows the object", errNotBanToAdd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return toAdd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// banNetblock reads value, a netblock such as 203.0.113.0/24, or a
|
||||||
|
// client's address, which stands for the netblock a ban on that client
|
||||||
|
// covers. An IPv4-mapped netblock, such as ::ffff:203.0.113.0/120, is
|
||||||
|
// refused, since a client's address is looked up as IPv4 and a ban on it
|
||||||
|
// would refuse nothing, and so is a value with a zone.
|
||||||
|
func (h *handler) banNetblock(value string) (netip.Prefix, error) {
|
||||||
|
netblock, err := netip.ParsePrefix(value)
|
||||||
|
if err == nil {
|
||||||
|
if netblock.Addr().Is4In6() {
|
||||||
|
return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errMappedNetblock)
|
||||||
|
}
|
||||||
|
|
||||||
|
return netblock, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePrefix refuses a zone, but ParseAddr reads the /48 of
|
||||||
|
// 2001:db8::1%x/48 as part of the zone.
|
||||||
|
addr, err := netip.ParseAddr(value)
|
||||||
|
if err != nil {
|
||||||
|
return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errNotNetblock)
|
||||||
|
}
|
||||||
|
|
||||||
|
if addr.Zone() != "" {
|
||||||
|
return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errZone)
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.netblock(addr), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// expiry returns when a ban made at now for duration ends: duration
|
||||||
|
// later, for a duration as a setting gives one, or zero for permanent.
|
||||||
|
func expiry(duration string, now time.Time) (time.Time, error) {
|
||||||
|
if duration == permanent {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
length, err := config.ParseDurationNotOff(duration)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("duration %q %w", duration, errNotDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return now.Add(length), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// liftBans answers DELETE BansPath/<client>: it lifts every ban active on
|
||||||
|
// a netblock the client's address is in, and answers with those bans, or
|
||||||
|
// with 404 when none is active.
|
||||||
|
func (rq *request) liftBans() {
|
||||||
|
client, err := pathAddress(rq.in.URL.Path, BansPath+"/")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rq.out, err.Error(), http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lifted := rq.h.ledger.Lift(client, rq.h.now())
|
||||||
|
if len(lifted) == 0 {
|
||||||
|
http.Error(rq.out, "no ban is active on "+client.String(), http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.answerBans(lifted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientAnswer is the answer to GET ClientsPath<ip>: the client the
|
||||||
|
// address is, as clients.json holds it, or null when the table of
|
||||||
|
// clients does not hold it, and the bans on each netblock the address is
|
||||||
|
// in, as bans.json lists them.
|
||||||
|
type clientAnswer struct {
|
||||||
|
Client *ratelimit.Client `json:"client"`
|
||||||
|
Bans []state.BanEntry `json:"bans"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// showClient answers GET ClientsPath<ip> with what smallwebwaf knows of
|
||||||
|
// the client: its counters, its history, which holds its country as last
|
||||||
|
// looked up and its offences, and its bans with their notes.
|
||||||
|
func (rq *request) showClient() {
|
||||||
|
addr, err := pathAddress(rq.in.URL.Path, ClientsPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rq.out, err.Error(), http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
answer := clientAnswer{Bans: state.BanEntries(rq.h.ledger.Covering(addr))}
|
||||||
|
|
||||||
|
client, seen := rq.h.limiter.Client(clientGroup(addr))
|
||||||
|
if seen {
|
||||||
|
answer.Client = &client
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.answerJSON(answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathAddress reads the client's address that follows prefix in path.
|
||||||
|
func pathAddress(path, prefix string) (netip.Addr, error) {
|
||||||
|
value := strings.TrimPrefix(path, prefix)
|
||||||
|
|
||||||
|
addr, err := netip.ParseAddr(value)
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}, fmt.Errorf("%q %w", value, errNotAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
return addr.Unmap(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerBans answers with held under bans, as bans.json lists them.
|
||||||
|
func (rq *request) answerBans(held []bans.Ban) {
|
||||||
|
rq.answerJSON(struct {
|
||||||
|
Bans []state.BanEntry `json:"bans"`
|
||||||
|
}{state.BanEntries(held)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerJSON answers with value as indented JSON.
|
||||||
|
func (rq *request) answerJSON(value any) {
|
||||||
|
body, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
rq.h.processLog.Error("encoding an answer failed", "error", err.Error())
|
||||||
|
http.Error(rq.out, http.StatusText(http.StatusInternalServerError),
|
||||||
|
http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.out.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = rq.out.Write(append(body, '\n'))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,535 @@
|
|||||||
|
package proxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
|
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set, and adminBearer
|
||||||
|
// how a request carries it.
|
||||||
|
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||||
|
adminBearer = "Bearer " + adminSecret
|
||||||
|
// 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
|
||||||
|
// hour.
|
||||||
|
banOtherClient = `{"netblock": "` + otherClient + `", "duration": "1h", ` +
|
||||||
|
`"reason": "probes for logins"}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The metrics token is set, and opens none of them.
|
||||||
|
s, clk, server := startWithClock(t, "", map[string]string{metricsToken: token})
|
||||||
|
server.Ledger.BanForLimit(netip.MustParsePrefix(otherClient+"/32"), clk.Now(),
|
||||||
|
bans.Notes{})
|
||||||
|
before := server.Ledger.Snapshot()
|
||||||
|
|
||||||
|
// An empty token does not match the unset one either.
|
||||||
|
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
|
||||||
|
for _, e := range adminEndpoints() {
|
||||||
|
s.adminRequest(adminClient, authorization, e.method, e.path, e.body,
|
||||||
|
http.StatusNotFound, requestlog.ActionAdmin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if after := server.Ledger.Snapshot(); !slices.Equal(after, before) {
|
||||||
|
t.Errorf("the bans are now\n%+v\nwant them unchanged\n%+v", after, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
|
adminToken: adminSecret,
|
||||||
|
metricsToken: token,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listing the bans, banning otherClient, lifting that ban, and asking
|
||||||
|
// about otherClient, in that order. Without the admin token, with the
|
||||||
|
// metrics token, or with one that differs, each is refused, and
|
||||||
|
// changes nothing; with the admin token, it is answered.
|
||||||
|
for _, e := range adminEndpoints() {
|
||||||
|
before := server.Ledger.Snapshot()
|
||||||
|
|
||||||
|
for _, authorization := range []string{
|
||||||
|
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
|
||||||
|
} {
|
||||||
|
got := s.adminRequest(adminClient, authorization, e.method, e.path, e.body,
|
||||||
|
http.StatusUnauthorized, requestlog.ActionAdmin)
|
||||||
|
if got.header.Get("WWW-Authenticate") != "Bearer" {
|
||||||
|
t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer",
|
||||||
|
e.method, e.path, authorization)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if after := server.Ledger.Snapshot(); !slices.Equal(after, before) {
|
||||||
|
t.Errorf("%s %s without the token changed the bans to\n%+v\nfrom\n%+v",
|
||||||
|
e.method, e.path, after, before)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := s.admin(e.method, e.path, e.body, http.StatusOK)
|
||||||
|
if got.header.Get("Content-Type") != "application/json" {
|
||||||
|
t.Errorf("%s %s answered %q", e.method, e.path, got.header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any other request under /_smallwebwaf/ is not found.
|
||||||
|
for _, e := range []adminEndpoint{
|
||||||
|
{http.MethodPut, proxy.BansPath, banOtherClient},
|
||||||
|
{http.MethodDelete, proxy.BansPath, ""},
|
||||||
|
{http.MethodGet, proxy.BansPath + "/" + otherClient, ""},
|
||||||
|
{http.MethodPost, proxy.ClientsPath + otherClient, ""},
|
||||||
|
{http.MethodGet, strings.TrimSuffix(proxy.ClientsPath, "/"), ""},
|
||||||
|
} {
|
||||||
|
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||||
|
adminToken: adminSecret,
|
||||||
|
banScopeV4Prefix: "24",
|
||||||
|
})
|
||||||
|
|
||||||
|
// A ban on otherClient bans the /24 a ban on that client covers, so it
|
||||||
|
// refuses client too, for an hour.
|
||||||
|
start := clk.Now()
|
||||||
|
expires := start.Add(time.Hour)
|
||||||
|
want := state.BanEntry{
|
||||||
|
Netblock: netip.MustParsePrefix("203.0.113.0/24"),
|
||||||
|
Start: start,
|
||||||
|
Expires: &expires,
|
||||||
|
Cause: bans.CauseAdmin,
|
||||||
|
Reason: "probes for logins",
|
||||||
|
}
|
||||||
|
|
||||||
|
wantBans(t, s.admin(http.MethodPost, proxy.BansPath, banOtherClient, http.StatusOK),
|
||||||
|
want)
|
||||||
|
|
||||||
|
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
if line.BanExpires != requestlog.FormatTime(expires) {
|
||||||
|
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Its notes count the request it refused.
|
||||||
|
want.Notes.Requests, want.Notes.Refused = 1, 1
|
||||||
|
|
||||||
|
wantBans(t, s.admin(http.MethodGet, proxy.BansPath, "", http.StatusOK), want)
|
||||||
|
|
||||||
|
// Ten minutes on, lifting the bans on client lifts that one, which is
|
||||||
|
// kept, marked lifted.
|
||||||
|
clk.advance(10 * time.Minute)
|
||||||
|
|
||||||
|
lifted := clk.Now()
|
||||||
|
want.Lifted = &lifted
|
||||||
|
|
||||||
|
wantBans(t, s.admin(http.MethodDelete, proxy.BansPath+"/"+client, "",
|
||||||
|
http.StatusOK), want)
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantBans(t, s.admin(http.MethodGet, proxy.BansPath, "", http.StatusOK), want)
|
||||||
|
|
||||||
|
// No ban on it is active any more.
|
||||||
|
s.admin(http.MethodDelete, proxy.BansPath+"/"+client, "", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||||
|
adminToken: adminSecret,
|
||||||
|
banScopeV4Prefix: "24",
|
||||||
|
})
|
||||||
|
start := clk.Now()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
netblock, duration string
|
||||||
|
want string
|
||||||
|
length time.Duration // 0 for a permanent ban
|
||||||
|
}{
|
||||||
|
// An address stands for the netblock a ban on that client covers.
|
||||||
|
{client, "7d", "203.0.113.0/24", 7 * 24 * time.Hour},
|
||||||
|
{"::ffff:198.51.100.7", "90m", "198.51.100.0/24", 90 * time.Minute},
|
||||||
|
{"2001:db8:5::1", "permanent", "2001:db8:5::/64", 0},
|
||||||
|
// A netblock stands for itself, its bits past its length cleared.
|
||||||
|
{"198.51.100.7/16", "1h", "198.51.0.0/16", time.Hour},
|
||||||
|
{"2001:db8:6::/48", "1h", "2001:db8:6::/48", time.Hour},
|
||||||
|
} {
|
||||||
|
// Whitespace may follow the object.
|
||||||
|
body := `{"netblock": "` + tc.netblock + `", "duration": "` + tc.duration + `"}` +
|
||||||
|
"\r\n"
|
||||||
|
want := state.BanEntry{
|
||||||
|
Netblock: netip.MustParsePrefix(tc.want), Start: start, Cause: bans.CauseAdmin,
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.length != 0 {
|
||||||
|
expires := start.Add(tc.length)
|
||||||
|
want.Expires = &expires
|
||||||
|
}
|
||||||
|
|
||||||
|
wantBans(t, s.admin(http.MethodPost, proxy.BansPath, body, http.StatusOK), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{adminToken: adminSecret})
|
||||||
|
|
||||||
|
for _, tc := range []struct{ body, want string }{
|
||||||
|
{"", "the body is not a JSON object of netblock, duration and reason: EOF"},
|
||||||
|
{"netblock=203.0.113.9", "the body is not a JSON object"},
|
||||||
|
{
|
||||||
|
`{"netblock": "203.0.113.9", "duration": "1h", "until": "2027"}`,
|
||||||
|
`unknown field "until"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`{"netblock": "203.0.113", "duration": "1h"}`,
|
||||||
|
`netblock "203.0.113" is not an address or a netblock`,
|
||||||
|
},
|
||||||
|
// A client's address is looked up as IPv4, so a ban on an
|
||||||
|
// IPv4-mapped netblock would refuse nothing.
|
||||||
|
{
|
||||||
|
`{"netblock": "::ffff:203.0.113.0/120", "duration": "1h"}`,
|
||||||
|
`netblock "::ffff:203.0.113.0/120" is IPv4-mapped`,
|
||||||
|
},
|
||||||
|
// Read as an address, its zone would be "x/48", and its ban on the
|
||||||
|
// /64 around it.
|
||||||
|
{
|
||||||
|
`{"netblock": "2001:db8::1%x/48", "duration": "1h"}`,
|
||||||
|
`netblock "2001:db8::1%x/48" has a zone`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`{"netblock": "fe80::1%eth0", "duration": "1h"}`,
|
||||||
|
`netblock "fe80::1%eth0" has a zone`,
|
||||||
|
},
|
||||||
|
// Anything but whitespace after the object.
|
||||||
|
{
|
||||||
|
`{"netblock": "203.0.113.9", "duration": "1h"}` +
|
||||||
|
`{"netblock": "198.51.100.0/24", "duration": "1h"}`,
|
||||||
|
"more follows the object",
|
||||||
|
},
|
||||||
|
{`{"netblock": "203.0.113.9", "duration": "1h"} x`, "more follows the object"},
|
||||||
|
{`{"duration": "1h"}`, `netblock "" is not an address or a netblock`},
|
||||||
|
{`{"netblock": "203.0.113.9"}`, `duration "" is not a duration above zero`},
|
||||||
|
{
|
||||||
|
`{"netblock": "203.0.113.9", "duration": "off"}`,
|
||||||
|
`duration "off" is not a duration above zero`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`{"netblock": "203.0.113.9", "duration": "0s"}`,
|
||||||
|
`duration "0s" is not a duration above zero`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`{"netblock": "203.0.113.9", "duration": "forever"}`,
|
||||||
|
`duration "forever" is not a duration above zero, such as 1h or 7d, ` +
|
||||||
|
`or permanent`,
|
||||||
|
},
|
||||||
|
// Over the 4 KiB read of a body, even when the object comes first.
|
||||||
|
{
|
||||||
|
`{"netblock": "203.0.113.9", "duration": "1h", "reason": "` +
|
||||||
|
strings.Repeat("x", 4<<10) + `"}`,
|
||||||
|
"request body too large",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`{"netblock": "203.0.113.9", "duration": "1h"}` + strings.Repeat(" ", 4<<10),
|
||||||
|
"request body too large",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
got := s.admin(http.MethodPost, proxy.BansPath, tc.body, http.StatusBadRequest)
|
||||||
|
if !strings.Contains(string(got.body), tc.want) {
|
||||||
|
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||||
|
t.Errorf("the ledger holds %+v, want no ban", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||||
|
adminToken: adminSecret,
|
||||||
|
rateLimitPerMinute: "2",
|
||||||
|
rateLimitExemptNets: adminClient,
|
||||||
|
})
|
||||||
|
start := clk.Now()
|
||||||
|
|
||||||
|
// Two of otherClient's requests are let through; the third breaks the
|
||||||
|
// limit of two a minute, and bans it.
|
||||||
|
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
|
// Asked about by its address in IPv6 form too.
|
||||||
|
for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
|
||||||
|
var got struct {
|
||||||
|
Client *ratelimit.Client `json:"client"`
|
||||||
|
Bans []state.BanEntry `json:"bans"`
|
||||||
|
}
|
||||||
|
|
||||||
|
decode(t, s.admin(http.MethodGet, proxy.ClientsPath+addr, "", http.StatusOK), &got)
|
||||||
|
|
||||||
|
if got.Client == nil {
|
||||||
|
t.Fatalf("%s: no client", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
history := got.Client.History
|
||||||
|
if got.Client.Client != netip.MustParsePrefix(otherClient+"/32") ||
|
||||||
|
history.Requests != 3 || history.Forwarded != 2 || history.Refused != 1 ||
|
||||||
|
history.Offences.Limit != 1 || !history.FirstSeen.Equal(start) {
|
||||||
|
t.Errorf("%s: client %+v", addr, got.Client)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Bans) != 1 || got.Bans[0].Cause != bans.CauseLimit ||
|
||||||
|
got.Bans[0].Reason != "requests per minute over the limit of 2" ||
|
||||||
|
got.Bans[0].Notes.Count != 3 {
|
||||||
|
t.Errorf("%s: bans %+v, want the one for the broken limit", addr, got.Bans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Of an address no request came from and no ban covers, nothing is
|
||||||
|
// known.
|
||||||
|
got := s.admin(http.MethodGet, proxy.ClientsPath+"198.51.100.99", "", http.StatusOK)
|
||||||
|
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
|
||||||
|
t.Errorf("an unknown client is answered\n%s", got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.admin(http.MethodGet, proxy.ClientsPath+"203.0.113", "", http.StatusBadRequest)
|
||||||
|
s.admin(http.MethodDelete, proxy.BansPath+"/203.0.113.0/24", "",
|
||||||
|
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) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
|
adminToken: adminSecret,
|
||||||
|
mode: observe,
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, e := range adminEndpoints() {
|
||||||
|
s.adminRequest(adminClient, "", e.method, e.path, e.body,
|
||||||
|
http.StatusUnauthorized, requestlog.ActionAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||||
|
t.Errorf("the ledger holds %+v, want no ban", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminEndpoint is a request to an endpoint SWWAF_ADMIN_TOKEN opens.
|
||||||
|
type adminEndpoint struct {
|
||||||
|
method, path, body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminEndpoints returns a request to each endpoint SWWAF_ADMIN_TOKEN
|
||||||
|
// opens: listing the bans, banning otherClient for an hour, lifting the
|
||||||
|
// bans on otherClient, and asking about otherClient.
|
||||||
|
func adminEndpoints() []adminEndpoint {
|
||||||
|
return []adminEndpoint{
|
||||||
|
{http.MethodGet, proxy.BansPath, ""},
|
||||||
|
{http.MethodPost, proxy.BansPath, banOtherClient},
|
||||||
|
{http.MethodDelete, proxy.BansPath + "/" + otherClient, ""},
|
||||||
|
{http.MethodGet, proxy.ClientsPath + otherClient, ""},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// admin sends a request with method for path, with body, from
|
||||||
|
// adminClient, with the admin token, and checks that it is answered with
|
||||||
|
// status, its log line's action admin. It returns the answer.
|
||||||
|
func (s *sender) admin(method, path, body string, status int) answer {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
return s.adminRequest(adminClient, adminBearer, method, path, body, status,
|
||||||
|
requestlog.ActionAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminRequest sends a request with method for path, with body, from the
|
||||||
|
// client at from, with authorization as its Authorization header unless
|
||||||
|
// 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(
|
||||||
|
from, authorization, method, path, body string, status int, action string,
|
||||||
|
) answer {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
var header []string
|
||||||
|
|
||||||
|
if authorization != "" {
|
||||||
|
header = append(header, "Authorization: "+authorization)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body != "" && !strings.Contains(authorization, "Transfer-Encoding") {
|
||||||
|
header = append(header, "Content-Length: "+strconv.Itoa(len(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, got := s.requestWithBody(method, from, path, strings.Join(header, "\r\n"),
|
||||||
|
body, status, action)
|
||||||
|
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantBans checks that a ban endpoint answered with want, and no other
|
||||||
|
// ban.
|
||||||
|
func wantBans(t *testing.T, got answer, want ...state.BanEntry) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var decoded struct {
|
||||||
|
Bans []state.BanEntry `json:"bans"`
|
||||||
|
}
|
||||||
|
|
||||||
|
decode(t, got, &decoded)
|
||||||
|
|
||||||
|
gotJSON, err := json.Marshal(decoded.Bans)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode %+v: %v", decoded.Bans, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantJSON, err := json.Marshal(want)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode %+v: %v", want, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(gotJSON) != string(wantJSON) {
|
||||||
|
t.Errorf("bans\n%s\nwant\n%s", gotJSON, wantJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decode reads the JSON answer of an endpoint into value.
|
||||||
|
func decode(t *testing.T, got answer, value any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := json.Unmarshal(got.body, value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", got.body, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package proxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
||||||
|
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
||||||
|
// alertInstance is the instance every alert of these tests gives.
|
||||||
|
alertInstance = "fsn1app1/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||||
|
rateLimitPerMinute: "1",
|
||||||
|
banScopeV4Prefix: "24",
|
||||||
|
})
|
||||||
|
start := clk.Now()
|
||||||
|
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||||
|
ban := server.Ledger.Bans(netblock)[0]
|
||||||
|
|
||||||
|
// A request refused under the ban raises no other alert.
|
||||||
|
clk.advance(time.Minute)
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
|
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{
|
||||||
|
Netblock: netblock, Cause: bans.CauseLimit,
|
||||||
|
Reason: "requests per minute over the limit of 1", Notes: ban.Notes,
|
||||||
|
}, requestlog.FormatTime(start.Add(time.Hour))))
|
||||||
|
|
||||||
|
if ban.Notes.Limit != 1 || ban.Notes.Request.Path != "/" {
|
||||||
|
t.Errorf("the alert's notes are %+v, want those of the broken limit", ban.Notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||||
|
rulesDir: writeRules(t, testRules),
|
||||||
|
})
|
||||||
|
start := clk.Now()
|
||||||
|
netblock := netip.MustParsePrefix(client + "/32")
|
||||||
|
other := netip.MustParsePrefix(otherClient + "/32")
|
||||||
|
|
||||||
|
// The probe bans the client for seven days, and its next request makes
|
||||||
|
// the ban permanent. The request after that changes nothing.
|
||||||
|
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
|
attackBan := server.Ledger.Bans(netblock)[0]
|
||||||
|
|
||||||
|
clk.advance(time.Minute)
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
|
permanentBan := server.Ledger.Bans(netblock)[0]
|
||||||
|
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
|
// Another client's probe after its first ban has run out without a
|
||||||
|
// request makes a permanent ban at once.
|
||||||
|
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
clk.advance(7 * 24 * time.Hour)
|
||||||
|
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
|
otherBans := server.Ledger.Bans(other)
|
||||||
|
|
||||||
|
wantAlerts(t, queue,
|
||||||
|
attackAlert(alerts.EventBan, start, client, attackBan,
|
||||||
|
requestlog.FormatTime(start.Add(7*24*time.Hour))),
|
||||||
|
attackAlert(alerts.EventPermanentBan, start.Add(time.Minute), client,
|
||||||
|
permanentBan, "permanent"),
|
||||||
|
attackAlert(alerts.EventBan, start.Add(time.Minute), otherClient, otherBans[0],
|
||||||
|
requestlog.FormatTime(start.Add(time.Minute+7*24*time.Hour))),
|
||||||
|
attackAlert(alerts.EventPermanentBan, start.Add(time.Minute+7*24*time.Hour),
|
||||||
|
otherClient, otherBans[1], "permanent"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||||
|
mode: observe,
|
||||||
|
rateLimitPerMinute: "2",
|
||||||
|
rulesDir: writeRules(t, testRules),
|
||||||
|
})
|
||||||
|
start := clk.Now()
|
||||||
|
|
||||||
|
// A ban for a clear sign of attack, which a request under it would make
|
||||||
|
// permanent.
|
||||||
|
group := netip.MustParsePrefix(ipv6Group)
|
||||||
|
attackBan, _ := server.Ledger.BanForAttack(group, start, bans.Notes{RuleID: "probe"})
|
||||||
|
|
||||||
|
// The third request breaks the limit, and so does the fourth, within the
|
||||||
|
// cooldown, which raises nothing. The probe is a clear sign of attack.
|
||||||
|
for range 4 {
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.request(otherClient, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||||
|
line := s.get(ipv6Client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
// No ban is made, and none made permanent.
|
||||||
|
if held := server.Ledger.Snapshot(); len(held) != 1 || held[0] != attackBan ||
|
||||||
|
line.BanExpires != requestlog.FormatTime(attackBan.Expires) {
|
||||||
|
t.Errorf("the ledger holds %+v, and the log line gives %s, want the ban "+
|
||||||
|
"for the attack alone, as it was", held, line.BanExpires)
|
||||||
|
}
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 3 || queue.Suppressed() != 0 {
|
||||||
|
t.Fatalf("%d alerts wait and %d are held back, want 3 and 0: %+v",
|
||||||
|
len(waiting), queue.Suppressed(), waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
limitNotes, _ := waiting[0].Detail["notes"].(bans.Notes)
|
||||||
|
attackNotes, _ := waiting[1].Detail["notes"].(bans.Notes)
|
||||||
|
|
||||||
|
if limitNotes.Limit != 2 || limitNotes.Request.Path != "/" ||
|
||||||
|
attackNotes.Request.Path != "/.env" {
|
||||||
|
t.Errorf("the notes are %+v and %+v, want those of the broken limit and "+
|
||||||
|
"of the probe", limitNotes, attackNotes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each alert is the one enforce mode would have raised, with mode
|
||||||
|
// observe in its detail.
|
||||||
|
want := []alerts.Alert{
|
||||||
|
banAlert(alerts.EventBan, start, client, bans.Ban{
|
||||||
|
Netblock: netip.MustParsePrefix(client + "/32"), Cause: bans.CauseLimit,
|
||||||
|
Reason: "requests per minute over the limit of 2", Notes: limitNotes,
|
||||||
|
}, requestlog.FormatTime(start.Add(time.Hour))),
|
||||||
|
attackAlert(alerts.EventBan, start, otherClient, bans.Ban{
|
||||||
|
Netblock: netip.MustParsePrefix(otherClient + "/32"), Notes: attackNotes,
|
||||||
|
}, requestlog.FormatTime(start.Add(7*24*time.Hour))),
|
||||||
|
attackAlert(alerts.EventPermanentBan, start, ipv6Client, attackBan, permanent),
|
||||||
|
}
|
||||||
|
for _, alert := range want {
|
||||||
|
alert.Detail["mode"] = observe
|
||||||
|
}
|
||||||
|
|
||||||
|
wantAlerts(t, queue, want...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _, queue := startWithAlerts(t, map[string]string{
|
||||||
|
mode: observe,
|
||||||
|
rateLimitPerMinute: "2",
|
||||||
|
rulesDir: writeRules(t, testRules),
|
||||||
|
alertMaxPerHour: "2",
|
||||||
|
})
|
||||||
|
|
||||||
|
// The client's third request breaks the limit, and raises the first
|
||||||
|
// alert of the hour. Its fourth is within the cooldown.
|
||||||
|
for range 4 {
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other client's first probe raises the second. Its second probe is
|
||||||
|
// within the cooldown.
|
||||||
|
for range 2 {
|
||||||
|
s.request(otherClient, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The IPv6 client's third request breaks the limit past the two alerts
|
||||||
|
// an hour.
|
||||||
|
for range 3 {
|
||||||
|
s.get(ipv6Client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Had the ban been worked out for any of the requests within the
|
||||||
|
// cooldown or past the two an hour, its alert would have been raised,
|
||||||
|
// held back and counted.
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 2 || queue.Suppressed() != 0 {
|
||||||
|
t.Errorf("%d alerts wait and %d are held back, want 2 and 0: %+v",
|
||||||
|
len(waiting), queue.Suppressed(), waiting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWithAlerts is startWithClock with alerts to a webhook, which is
|
||||||
|
// never sent them, and returns the queue they wait in as well.
|
||||||
|
func startWithAlerts(
|
||||||
|
t *testing.T, env map[string]string,
|
||||||
|
) (*sender, *clock, *proxy.Server, *alerts.Queue) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startAppWithAlerts is startWithAlerts in front of the app handler.
|
||||||
|
func startAppWithAlerts(
|
||||||
|
t *testing.T, handler http.HandlerFunc, env map[string]string,
|
||||||
|
) (*sender, *clock, *proxy.Server, *alerts.Queue) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
app := startApp(t, handler)
|
||||||
|
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||||
|
settings := map[string]string{
|
||||||
|
trustedProxies: trustLocalhost,
|
||||||
|
alertWebhookURL: "https://alerts.example/smallwebwaf",
|
||||||
|
instanceName: alertInstance,
|
||||||
|
}
|
||||||
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
|
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
|
||||||
|
|
||||||
|
return &sender{t: t, addr: addr, out: out}, clk, server, queue
|
||||||
|
}
|
||||||
|
|
||||||
|
// banAlert returns the alert for event, raised by a request from client at
|
||||||
|
// the time raised, for ban, with its netblock, cause, reason and notes,
|
||||||
|
// which ends at expires, as the log line gives it.
|
||||||
|
func banAlert(
|
||||||
|
event string, raised time.Time, client string, ban bans.Ban, expires string,
|
||||||
|
) alerts.Alert {
|
||||||
|
return alerts.Alert{
|
||||||
|
Instance: alertInstance,
|
||||||
|
Time: raised,
|
||||||
|
Event: event,
|
||||||
|
Client: netip.MustParseAddr(client),
|
||||||
|
Netblock: ban.Netblock,
|
||||||
|
Reason: ban.Reason,
|
||||||
|
Detail: map[string]any{
|
||||||
|
"cause": ban.Cause, "ban_expires": expires, "notes": ban.Notes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attackAlert is banAlert for a ban for the probe rule of testRules, with
|
||||||
|
// the netblock and the notes of ban.
|
||||||
|
func attackAlert(
|
||||||
|
event string, raised time.Time, client string, ban bans.Ban, expires string,
|
||||||
|
) alerts.Alert {
|
||||||
|
return banAlert(event, raised, client, bans.Ban{
|
||||||
|
Netblock: ban.Netblock, Cause: bans.CauseAttack, Reason: "matched the rule probe",
|
||||||
|
Notes: ban.Notes,
|
||||||
|
}, expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantAlerts checks the alerts waiting in queue, in order.
|
||||||
|
func wantAlerts(t *testing.T, queue *alerts.Queue, want ...alerts.Alert) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("%d alerts wait, want %d: %+v", len(got), len(want), got)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range want {
|
||||||
|
if !reflect.DeepEqual(got[i], want[i]) {
|
||||||
|
t.Errorf("alert %d is\n%+v\nwant\n%+v", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWithEveryAnomalyThresholdOffARequestIsNotCounted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A request from a client looked up through GeoJS, with every anomaly
|
||||||
|
// threshold off. Its handler has neither GeoJS's answers nor the
|
||||||
|
// anomaly counters, nor a clock, and the request no response: reading
|
||||||
|
// any of them to count the request panics.
|
||||||
|
rq := &request{
|
||||||
|
h: &handler{config: &config.Config{LookupSource: "geojs"}},
|
||||||
|
client: netip.MustParseAddr("203.0.113.9"),
|
||||||
|
lookedUp: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Errorf("counting the request did work, with every threshold off: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
rq.countAnomalies()
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
package proxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
||||||
|
// count.
|
||||||
|
const (
|
||||||
|
anomalyClient = "SWWAF_ANOMALY_CLIENT_"
|
||||||
|
anomalyNet = "SWWAF_ANOMALY_NET_"
|
||||||
|
anomalyASN = "SWWAF_ANOMALY_ASN_"
|
||||||
|
anomalyTotal = "SWWAF_ANOMALY_TOTAL_"
|
||||||
|
anomalyWatch = "SWWAF_WATCH_"
|
||||||
|
|
||||||
|
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||||
|
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||||
|
bytesPerMinute = "BYTES_PER_MINUTE"
|
||||||
|
bytesPerHour = "BYTES_PER_HOUR"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The other anomaly settings.
|
||||||
|
const (
|
||||||
|
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
|
||||||
|
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
|
||||||
|
watchNets = "SWWAF_WATCH_NETS"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// clientsNet is the netblock around client at the default length, and
|
||||||
|
// office a named netblock of the same.
|
||||||
|
clientsNet = "203.0.113.0/24"
|
||||||
|
office = "office=" + clientsNet
|
||||||
|
// aLot is a threshold no test reaches.
|
||||||
|
aLot = "1000"
|
||||||
|
// hour is the window an alert names for a threshold per hour.
|
||||||
|
hour = "hour"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, scope := range []struct {
|
||||||
|
prefix, scope string
|
||||||
|
// netblock is the alert's, and counted what its reason names. extra
|
||||||
|
// is what its detail gives besides what every anomaly alert's does.
|
||||||
|
netblock netip.Prefix
|
||||||
|
counted string
|
||||||
|
extra map[string]any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
anomalyClient, anomaly.ScopeClient, netip.MustParsePrefix(client + "/32"),
|
||||||
|
"the client " + client + "/32", nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anomalyNet, anomaly.ScopeNet, netip.MustParsePrefix(clientsNet),
|
||||||
|
"the netblock " + clientsNet, nil,
|
||||||
|
},
|
||||||
|
{anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}},
|
||||||
|
{anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil},
|
||||||
|
{
|
||||||
|
anomalyWatch, anomaly.ScopeWatch, netip.MustParsePrefix(clientsNet),
|
||||||
|
"the named netblock office, " + clientsNet, map[string]any{"name": "office"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
for _, threshold := range []struct {
|
||||||
|
end, kind, window string
|
||||||
|
// value is the threshold, which the third upload of 100 bytes
|
||||||
|
// takes the count over, to count.
|
||||||
|
value int64
|
||||||
|
count float64
|
||||||
|
}{
|
||||||
|
{requestsPerMinute, ratelimit.KindRequests, minute, 2, 3},
|
||||||
|
{requestsPerHour, ratelimit.KindRequests, hour, 2, 3},
|
||||||
|
{bytesPerMinute, ratelimit.KindBytes, minute, 250, 300},
|
||||||
|
{bytesPerHour, ratelimit.KindBytes, hour, 250, 300},
|
||||||
|
} {
|
||||||
|
setting := scope.prefix + threshold.end
|
||||||
|
value := strconv.FormatInt(threshold.value, 10)
|
||||||
|
|
||||||
|
t.Run(setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
|
||||||
|
setting: value, watchNets: office,
|
||||||
|
})
|
||||||
|
start := clk.Now()
|
||||||
|
|
||||||
|
// The third upload takes the count over the threshold, and the
|
||||||
|
// fourth, within the cooldown, is held back. Each is passed to
|
||||||
|
// the app.
|
||||||
|
for range 4 {
|
||||||
|
s.uploadFrom(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := map[string]any{
|
||||||
|
"scope": scope.scope, "window": threshold.window, "kind": threshold.kind,
|
||||||
|
"count": threshold.count, "threshold": threshold.value,
|
||||||
|
}
|
||||||
|
maps.Copy(detail, scope.extra)
|
||||||
|
|
||||||
|
wantAlerts(t, queue, alerts.Alert{
|
||||||
|
Instance: alertInstance,
|
||||||
|
Time: start,
|
||||||
|
Event: alerts.EventAnomaly,
|
||||||
|
Client: netip.MustParseAddr(client),
|
||||||
|
Netblock: scope.netblock,
|
||||||
|
ASN: asnDE,
|
||||||
|
ASName: asNameDE,
|
||||||
|
Country: "DE",
|
||||||
|
Reason: threshold.kind + " per " + threshold.window + " of " +
|
||||||
|
scope.counted + " over the threshold of " + value,
|
||||||
|
Detail: detail,
|
||||||
|
})
|
||||||
|
|
||||||
|
wantAlertedAgainOnceTheCooldownHasRunOut(t, s, clk, queue)
|
||||||
|
|
||||||
|
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||||
|
t.Errorf("the ledger holds %+v, want no ban", held)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantAlertedAgainOnceTheCooldownHasRunOut checks that, once the cooldown
|
||||||
|
// has run out after a first alert, which held back one repeat, the next
|
||||||
|
// count over the threshold, at the latest three uploads from client on,
|
||||||
|
// raises another alert, giving that repeat.
|
||||||
|
func wantAlertedAgainOnceTheCooldownHasRunOut(
|
||||||
|
t *testing.T, s *sender, clk *clock, queue *alerts.Queue,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
clk.advance(15 * time.Minute)
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
s.uploadFrom(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 2 || !waiting[1].Time.Equal(clk.Now()) ||
|
||||||
|
waiting[1].SuppressedRepeats != 1 {
|
||||||
|
t.Errorf("alerts wait %+v, want the first and another, with 1 repeat", waiting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS
|
||||||
|
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||||
|
denied = "192.0.2.20" // in SWWAF_DENY_NETS
|
||||||
|
)
|
||||||
|
|
||||||
|
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||||
|
anomalyClient + requestsPerMinute: "2",
|
||||||
|
allowNets: allowed,
|
||||||
|
rateLimitExemptNets: exempt,
|
||||||
|
rateLimitExemptPaths: "/static/",
|
||||||
|
denyNets: denied,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The third request of each takes its client's count over the threshold
|
||||||
|
// of 2.
|
||||||
|
for _, sent := range []struct {
|
||||||
|
from, path string
|
||||||
|
status int
|
||||||
|
action string
|
||||||
|
}{
|
||||||
|
{allowed, "/", http.StatusOK, requestlog.ActionForward},
|
||||||
|
{exempt, "/", http.StatusOK, requestlog.ActionForward},
|
||||||
|
{client, "/static/app.js", http.StatusOK, requestlog.ActionForward},
|
||||||
|
{denied, "/", http.StatusForbidden, requestlog.ActionDenied},
|
||||||
|
} {
|
||||||
|
for range 3 {
|
||||||
|
s.request(sent.from, sent.path, sent.status, sent.action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
|
||||||
|
got := make([]string, 0, len(waiting))
|
||||||
|
for _, alert := range waiting {
|
||||||
|
got = append(got, alert.Client.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if want := []string{allowed, exempt, client, denied}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("alerts for the clients %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThresholdsOffCountNothingAndAlertNothing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// With every threshold off, nothing is counted.
|
||||||
|
s, server, queue := startWithLookups(t, map[string]string{watchNets: office})
|
||||||
|
|
||||||
|
for range 5 {
|
||||||
|
s.uploadFrom(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
if counters := server.Anomalies.Snapshot(); len(counters) != 0 {
|
||||||
|
t.Errorf("counters %+v, want none", counters)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantAlerts(t, queue)
|
||||||
|
|
||||||
|
// With one set, its count alone is counted, in its scope alone.
|
||||||
|
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
|
||||||
|
anomalyNet + requestsPerMinute: aLot, watchNets: office,
|
||||||
|
})
|
||||||
|
|
||||||
|
for range 5 {
|
||||||
|
s.uploadFrom(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []anomaly.Counter{{
|
||||||
|
Scope: anomaly.ScopeNet,
|
||||||
|
Netblock: netip.MustParsePrefix(clientsNet),
|
||||||
|
Minute: ratelimit.Buckets{Start: clk.Now(), Current: 5},
|
||||||
|
}}
|
||||||
|
if got := server.Anomalies.Snapshot(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("counters\n%+v\nwant\n%+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantAlerts(t, queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNetblockAroundAClientIsAsLongAsTheSettingsSay(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env map[string]string
|
||||||
|
// Each client of sent sends one request, and want gives the
|
||||||
|
// netblocks they are counted in, each with its requests.
|
||||||
|
sent []string
|
||||||
|
want map[string]int64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"by default", nil,
|
||||||
|
[]string{client, "203.0.113.200", "192.0.2.7", ipv6Client, "2001:db8:0:ffff::1"},
|
||||||
|
map[string]int64{clientsNet: 2, "192.0.2.0/24": 1, "2001:db8::/48": 2},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"as set", map[string]string{anomalyNetV4Prefix: "16", anomalyNetV6Prefix: "32"},
|
||||||
|
[]string{client, "203.0.200.1", ipv6Client, "2001:db8:ffff::1"},
|
||||||
|
map[string]int64{"203.0.0.0/16": 2, "2001:db8::/32": 2},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := map[string]string{anomalyNet + requestsPerMinute: aLot}
|
||||||
|
maps.Copy(env, tc.env)
|
||||||
|
|
||||||
|
s, _, server, _ := startAppWithAlerts(t, readAndAnswer, env)
|
||||||
|
|
||||||
|
for _, from := range tc.sent {
|
||||||
|
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := map[string]int64{}
|
||||||
|
for _, counter := range server.Anomalies.Snapshot() {
|
||||||
|
got[counter.Netblock.String()] = counter.Minute.Current
|
||||||
|
}
|
||||||
|
|
||||||
|
if !maps.Equal(got, tc.want) {
|
||||||
|
t.Errorf("requests by netblock %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIsCountedForItsASNumberOnceTheLookupGivesOne(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
anomalyASN + requestsPerMinute: aLot,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The lookup database does not hold unplaced.
|
||||||
|
for _, from := range []string{fromDE, fromDE, fromKP, noCountry, unplaced} {
|
||||||
|
s.uploadFrom(from)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := map[string]int64{}
|
||||||
|
for _, counter := range server.Anomalies.Snapshot() {
|
||||||
|
got[counter.ASN] = counter.Minute.Current
|
||||||
|
}
|
||||||
|
|
||||||
|
if want := map[string]int64{asnDE: 2, asnKP: 1, "AS64500": 1}; !maps.Equal(got, want) {
|
||||||
|
t.Errorf("requests by AS number %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The stand-in for GeoJS answers only once released, which the app
|
||||||
|
// does as it answers the request, and then waits until the answer is
|
||||||
|
// kept.
|
||||||
|
geojsURL, _, release := startHeldGeoJS(t)
|
||||||
|
|
||||||
|
var server atomic.Pointer[proxy.Server]
|
||||||
|
|
||||||
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
|
release()
|
||||||
|
waitUntil(func() bool {
|
||||||
|
_, kept := server.Load().GeoJS.Kept(netip.MustParsePrefix(fromDE + "/32"))
|
||||||
|
|
||||||
|
return kept
|
||||||
|
})
|
||||||
|
})
|
||||||
|
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||||
|
addr, out, started := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
|
||||||
|
map[string]string{
|
||||||
|
trustedProxies: trustLocalhost,
|
||||||
|
lookupTimeout: "1h",
|
||||||
|
anomalyASN + requestsPerMinute: aLot,
|
||||||
|
})
|
||||||
|
server.Store(started)
|
||||||
|
|
||||||
|
// The request went on without the answer, and is counted for the AS
|
||||||
|
// number it gives.
|
||||||
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
|
if line := s.get(fromDE, http.StatusOK, requestlog.ActionForward); line.ASN != "" {
|
||||||
|
t.Errorf("log line has AS number %q, want none: the request waited", line.ASN)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []anomaly.Counter{{
|
||||||
|
Scope: anomaly.ScopeASN, ASN: asnDE,
|
||||||
|
Minute: ratelimit.Buckets{Start: clk.Now(), Current: 1},
|
||||||
|
}}
|
||||||
|
if got := started.Anomalies.Snapshot(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("counters\n%+v\nwant\n%+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachNamedNetblockCountsTheClientsInIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, server, _ := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||||
|
anomalyWatch + requestsPerMinute: aLot,
|
||||||
|
watchNets: office + ",wide=203.0.0.0/16,other=198.51.100.0/25",
|
||||||
|
})
|
||||||
|
|
||||||
|
// client is in office and in wide.
|
||||||
|
for _, from := range []string{client, "203.0.200.1", "192.0.2.7"} {
|
||||||
|
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := map[string]int64{}
|
||||||
|
for _, counter := range server.Anomalies.Snapshot() {
|
||||||
|
got[counter.Name] = counter.Minute.Current
|
||||||
|
}
|
||||||
|
|
||||||
|
if want := map[string]int64{"office": 1, "wide": 2}; !maps.Equal(got, want) {
|
||||||
|
t.Errorf("requests by named netblock %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
+236
-31
@@ -4,8 +4,11 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
|
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
|
||||||
@@ -14,61 +17,263 @@ func (rq *request) banResponse(action string) *refusal {
|
|||||||
return &refusal{status: rq.h.config.BanResponse, action: action}
|
return &refusal{status: rq.h.config.BanResponse, action: action}
|
||||||
}
|
}
|
||||||
|
|
||||||
// banned reports whether a ban on a netblock the client is in refuses
|
// banned reports whether a ban on a netblock the client is in covers the
|
||||||
// the request at now, and notes for the log line when that ban ends.
|
// request at now, and notes for the log line when that ban ends. A
|
||||||
|
// request that makes the ban permanent, or in observe mode would have,
|
||||||
|
// raises the alert for it.
|
||||||
func (rq *request) banned(now time.Time) bool {
|
func (rq *request) banned(now time.Time) bool {
|
||||||
ban, banned := rq.h.ledger.Check(rq.client, now)
|
check := rq.h.ledger.Check
|
||||||
|
if rq.h.config.Observe {
|
||||||
|
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
|
||||||
|
}
|
||||||
|
|
||||||
|
ban, banned, madePermanent := check(rq.client, now)
|
||||||
if banned {
|
if banned {
|
||||||
rq.line.BanExpires = banExpires(ban)
|
rq.line.BanExpires = banExpires(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if madePermanent {
|
||||||
|
ban.Expires = time.Time{} // the ban made permanent, which Find leaves as it is
|
||||||
|
rq.alertBan(ban)
|
||||||
|
}
|
||||||
|
|
||||||
return banned
|
return banned
|
||||||
}
|
}
|
||||||
|
|
||||||
// limitBroken counts the request for the rate limits at now, and reports
|
// limitBroken counts the request for the rate limits at now, notes the
|
||||||
// whether it takes the client over one. Such a request bans the client's
|
// client's counts for the log line, and reports whether the request takes
|
||||||
// netblock, and sets the client's counters back to zero.
|
// the client over a rate limit, as its limit percentage lowers it, which
|
||||||
|
// breaks it.
|
||||||
func (rq *request) limitBroken(now time.Time) bool {
|
func (rq *request) limitBroken(now time.Time) bool {
|
||||||
group := clientGroup(rq.client)
|
counts, hit, over := rq.h.limiter.Count(clientGroup(rq.client), now,
|
||||||
|
rq.limitPercent.percent)
|
||||||
|
rq.line.Counts = counts
|
||||||
|
|
||||||
hit, over := rq.h.limiter.Count(group, now)
|
if over {
|
||||||
if !over {
|
rq.banForLimit(now, hit, rq.h.config.BanResponse)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
netblock := rq.netblock()
|
return over
|
||||||
ban := rq.h.ledger.BanForLimit(netblock, now, bans.Notes{
|
}
|
||||||
|
|
||||||
|
// countBytes counts the request's bytes, as countedBytes gives them, for
|
||||||
|
// the byte limits, once its response has ended, and notes the client's
|
||||||
|
// byte totals for the log line; its requests stay there as the rate limits
|
||||||
|
// counted them. Only a request passed to the app has them counted, and
|
||||||
|
// only one the rate limits counted; in observe mode, not one that enforce
|
||||||
|
// mode would have refused. Bytes that take the client over a byte limit,
|
||||||
|
// as its limit percentage for the byte limits lowers it, break it; the
|
||||||
|
// response was passed on whole.
|
||||||
|
func (rq *request) countBytes() {
|
||||||
|
if !rq.counted || rq.line.WouldAction != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := rq.h.now()
|
||||||
|
|
||||||
|
counts, hit, over := rq.h.limiter.CountBytes(clientGroup(rq.client), now,
|
||||||
|
rq.countedBytes(), rq.bytesPercent.percent)
|
||||||
|
rq.line.Counts.MinuteBytes = counts.MinuteBytes
|
||||||
|
rq.line.Counts.HourBytes = counts.HourBytes
|
||||||
|
rq.line.Counts.DayBytes = counts.DayBytes
|
||||||
|
|
||||||
|
if over {
|
||||||
|
rq.banForLimit(now, hit, rq.out.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countedBytes returns the request's bytes, once it has ended, as the
|
||||||
|
// byte limits and the anomaly thresholds count them: the response's body
|
||||||
|
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says. For an
|
||||||
|
// 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 {
|
||||||
|
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 {
|
||||||
|
case "response":
|
||||||
|
return response
|
||||||
|
case "request":
|
||||||
|
return request
|
||||||
|
default: // both
|
||||||
|
return response + request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// banForLimit bans the client's netblock at now for a broken limit, the
|
||||||
|
// one hit names, and notes the offence for the log line. 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 one whose bytes broke a byte limit.
|
||||||
|
// The ban's notes give the client's limit percentage for that kind of
|
||||||
|
// limit. The ban sets the client's counters back to zero. In observe mode
|
||||||
|
// it makes no ban and sets nothing back, and raises the alert for the ban
|
||||||
|
// it would have made, if that alert would be sent.
|
||||||
|
func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
||||||
|
rq.line.LimitHit = hit.Window
|
||||||
|
if hit.Kind == ratelimit.KindBytes {
|
||||||
|
rq.line.LimitHit += "_bytes" // as counts names the byte totals
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.line.Offence = requestlog.OffenceLimit
|
||||||
|
|
||||||
|
netblock := rq.h.netblock(rq.client)
|
||||||
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notes := bans.Notes{
|
||||||
|
ASN: rq.line.ASN,
|
||||||
|
ASName: rq.line.ASName,
|
||||||
Country: rq.line.Country,
|
Country: rq.line.Country,
|
||||||
|
Kind: hit.Kind,
|
||||||
Limit: hit.Limit,
|
Limit: hit.Limit,
|
||||||
Window: hit.Window,
|
Window: hit.Window,
|
||||||
Count: hit.Requests,
|
Count: hit.Count,
|
||||||
Request: bans.Request{
|
Request: rq.noted(now, status),
|
||||||
|
Requests: rq.netblockRequests(netblock),
|
||||||
|
}
|
||||||
|
|
||||||
|
percent := rq.limitPercent
|
||||||
|
if hit.Kind == ratelimit.KindBytes {
|
||||||
|
percent = rq.bytesPercent
|
||||||
|
}
|
||||||
|
|
||||||
|
notes.LimitPercent, notes.LimitPercentSetting = percent.logged()
|
||||||
|
|
||||||
|
if rq.h.config.Observe {
|
||||||
|
ban, wouldBan := rq.h.ledger.WouldBanForLimit(netblock, now, notes)
|
||||||
|
if wouldBan {
|
||||||
|
rq.alertBan(ban)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ban, made := rq.h.ledger.BanForLimit(netblock, now, notes)
|
||||||
|
rq.h.limiter.Reset(clientGroup(rq.client))
|
||||||
|
rq.line.BanExpires = banExpires(ban)
|
||||||
|
|
||||||
|
if made {
|
||||||
|
rq.alertBan(ban)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// banForAttack bans the client's netblock at now for a clear sign of
|
||||||
|
// attack, the match of rule, a ban rule. In observe mode it makes no ban,
|
||||||
|
// and raises the alert for the ban it would have made, if that alert
|
||||||
|
// would be sent.
|
||||||
|
func (rq *request) banForAttack(now time.Time, rule rules.Rule) {
|
||||||
|
netblock := rq.h.netblock(rq.client)
|
||||||
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notes := bans.Notes{
|
||||||
|
ASN: rq.line.ASN,
|
||||||
|
ASName: rq.line.ASName,
|
||||||
|
Country: rq.line.Country,
|
||||||
|
RuleID: rule.ID,
|
||||||
|
Target: rule.Target,
|
||||||
|
Request: rq.noted(now, rq.h.config.BanResponse),
|
||||||
|
Requests: rq.netblockRequests(netblock),
|
||||||
|
}
|
||||||
|
|
||||||
|
if rq.h.config.Observe {
|
||||||
|
ban, wouldBan := rq.h.ledger.WouldBanForAttack(netblock, now, notes)
|
||||||
|
if wouldBan {
|
||||||
|
rq.alertBan(ban)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
|
||||||
|
rq.line.BanExpires = banExpires(ban)
|
||||||
|
|
||||||
|
if made {
|
||||||
|
rq.alertBan(ban)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
||||||
|
// made at now would be sent. In observe mode the ban the request would
|
||||||
|
// have made is worked out only then, at most once per
|
||||||
|
// SWWAF_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
||||||
|
// netblock's requests, which can mean going through every client.
|
||||||
|
func (rq *request) wouldAlertBan(
|
||||||
|
netblock netip.Prefix, now time.Time, cause string,
|
||||||
|
) bool {
|
||||||
|
event := alerts.EventBan
|
||||||
|
if rq.h.ledger.WouldBePermanent(netblock, now, cause) {
|
||||||
|
event = alerts.EventPermanentBan
|
||||||
|
}
|
||||||
|
|
||||||
|
return rq.h.alerts.WouldSend(event, netblock)
|
||||||
|
}
|
||||||
|
|
||||||
|
// alertBan raises the alert for ban, which the request made, or made
|
||||||
|
// permanent: permanent_ban for a permanent ban, ban for another. Its
|
||||||
|
// detail gives the ban's cause, when it ends, and its notes, and in
|
||||||
|
// observe mode, where ban is the ban that would have been made, or made
|
||||||
|
// permanent, mode, observe.
|
||||||
|
func (rq *request) alertBan(ban bans.Ban) {
|
||||||
|
event := alerts.EventBan
|
||||||
|
if ban.Permanent() {
|
||||||
|
event = alerts.EventPermanentBan
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := map[string]any{
|
||||||
|
"cause": ban.Cause, "ban_expires": banExpires(ban), "notes": ban.Notes,
|
||||||
|
}
|
||||||
|
if rq.h.config.Observe {
|
||||||
|
detail["mode"] = "observe"
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.h.alerts.Raise(alerts.Alert{
|
||||||
|
Event: event,
|
||||||
|
Client: rq.client,
|
||||||
|
Netblock: ban.Netblock,
|
||||||
|
ASN: ban.Notes.ASN,
|
||||||
|
ASName: ban.Notes.ASName,
|
||||||
|
Country: ban.Notes.Country,
|
||||||
|
Reason: ban.Reason,
|
||||||
|
Detail: detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// noted is the request, at now, with status, what the client was sent, or
|
||||||
|
// in observe mode would have been, as the notes of the ban it makes keep
|
||||||
|
// it.
|
||||||
|
func (rq *request) noted(now time.Time, status int) bans.Request {
|
||||||
|
return bans.Request{
|
||||||
Time: now,
|
Time: now,
|
||||||
Method: rq.in.Method,
|
Method: rq.in.Method,
|
||||||
Host: rq.in.Host,
|
Host: rq.in.Host,
|
||||||
Path: rq.in.URL.RequestURI(),
|
Path: rq.in.URL.RequestURI(),
|
||||||
Status: rq.h.config.BanResponse,
|
Status: status,
|
||||||
UserAgent: rq.in.UserAgent(),
|
UserAgent: rq.in.UserAgent(),
|
||||||
},
|
}
|
||||||
// The histories count this request only once it has ended.
|
|
||||||
Requests: rq.h.limiter.Requests(netblock) + 1,
|
|
||||||
})
|
|
||||||
rq.h.limiter.Reset(group)
|
|
||||||
|
|
||||||
rq.line.LimitHit = hit.Window
|
|
||||||
rq.line.Offence = requestlog.OffenceLimit
|
|
||||||
rq.line.BanExpires = banExpires(ban)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// netblock is the netblock a ban on the client covers: its IPv4 address,
|
// netblockRequests is how many requests netblock has sent since it was
|
||||||
|
// first seen, this one included: the histories count it only once it has
|
||||||
|
// ended.
|
||||||
|
func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
|
||||||
|
return rq.h.limiter.Requests(netblock) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// netblock is the netblock a ban on client covers: its IPv4 address,
|
||||||
// widened to SWWAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
// widened to SWWAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
||||||
// counts it in.
|
// counts it in.
|
||||||
func (rq *request) netblock() netip.Prefix {
|
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
||||||
addr := rq.client.Unmap()
|
addr := client.Unmap()
|
||||||
if addr.Is4() {
|
if addr.Is4() {
|
||||||
return netip.PrefixFrom(addr, rq.h.config.BanScopeV4Prefix).Masked()
|
return netip.PrefixFrom(addr, h.config.BanScopeV4Prefix).Masked()
|
||||||
}
|
}
|
||||||
|
|
||||||
return clientGroup(addr)
|
return clientGroup(addr)
|
||||||
@@ -78,7 +283,7 @@ func (rq *request) netblock() netip.Prefix {
|
|||||||
// permanent.
|
// permanent.
|
||||||
func banExpires(ban bans.Ban) string {
|
func banExpires(ban bans.Ban) string {
|
||||||
if ban.Permanent() {
|
if ban.Permanent() {
|
||||||
return "permanent"
|
return permanent
|
||||||
}
|
}
|
||||||
|
|
||||||
return requestlog.FormatTime(ban.Expires)
|
return requestlog.FormatTime(ban.Expires)
|
||||||
|
|||||||
@@ -278,8 +278,13 @@ func TestBanNotes(t *testing.T) {
|
|||||||
Netblock: netblock,
|
Netblock: netblock,
|
||||||
Start: start,
|
Start: start,
|
||||||
Expires: start.Add(time.Hour),
|
Expires: start.Add(time.Hour),
|
||||||
|
Cause: bans.CauseLimit,
|
||||||
|
Reason: "requests per minute over the limit of 1",
|
||||||
Notes: bans.Notes{
|
Notes: bans.Notes{
|
||||||
|
ASN: asnDE,
|
||||||
|
ASName: asNameDE,
|
||||||
Country: "DE",
|
Country: "DE",
|
||||||
|
Kind: "requests",
|
||||||
Limit: 1,
|
Limit: 1,
|
||||||
Window: minute,
|
Window: minute,
|
||||||
Count: 2,
|
Count: 2,
|
||||||
@@ -295,7 +300,7 @@ func TestBanNotes(t *testing.T) {
|
|||||||
// refused under the ban.
|
// refused under the ban.
|
||||||
Requests: 4,
|
Requests: 4,
|
||||||
Refused: 2,
|
Refused: 2,
|
||||||
EarlierBans: 0,
|
EarlierBans: bans.EarlierBans{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,8 +317,8 @@ func TestBanNotes(t *testing.T) {
|
|||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
got = ledger.Bans(netblock)
|
got = ledger.Bans(netblock)
|
||||||
if len(got) != 2 || got[1].Notes.EarlierBans != 1 {
|
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||||
t.Errorf("bans %+v, want two, the second with one earlier ban", got)
|
t.Errorf("bans %+v, want two, the second with one earlier ban for a limit", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,8 +365,9 @@ func (c *clock) advance(d time.Duration) {
|
|||||||
|
|
||||||
// startWithClock starts smallwebwaf in front of an app that answers 200,
|
// startWithClock starts smallwebwaf 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' countries looked up at geojsURL, and a clock
|
// X-Forwarded-For, clients' AS numbers and countries looked up at
|
||||||
// set to midnight, the start of a bucket in every window.
|
// geojsURL, and a clock set to midnight, the start of a bucket in every
|
||||||
|
// 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, *proxy.Server) {
|
||||||
@@ -415,14 +421,28 @@ func (s *sender) requestWithHeader(
|
|||||||
) (logLine, string) {
|
) (logLine, string) {
|
||||||
s.t.Helper()
|
s.t.Helper()
|
||||||
|
|
||||||
|
line, got := s.requestWithBody(http.MethodGet, from, path, header, "", status, action)
|
||||||
|
|
||||||
|
return line, string(got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestWithBody is requestWithHeader for a request with method, whose
|
||||||
|
// body is sent as it is after the headers, header holding its
|
||||||
|
// Content-Length or Transfer-Encoding. header may hold several lines,
|
||||||
|
// separated by "\r\n". It returns the whole answer.
|
||||||
|
func (s *sender) requestWithBody(
|
||||||
|
method, from, path, header, body string, status int, action string,
|
||||||
|
) (logLine, answer) {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
if header != "" {
|
if header != "" {
|
||||||
header += "\r\n"
|
header += "\r\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
conn := dial(s.t, s.addr)
|
conn := dial(s.t, s.addr)
|
||||||
send(s.t, conn, "GET "+path+" HTTP/1.1\r\nHost: "+appHost+
|
send(s.t, conn, method+" "+path+" HTTP/1.1\r\nHost: "+appHost+
|
||||||
"\r\nUser-Agent: "+userAgent+"\r\n"+forwardedFor+": "+from+"\r\n"+
|
"\r\nUser-Agent: "+userAgent+"\r\n"+forwardedFor+": "+from+"\r\n"+
|
||||||
header+"\r\n")
|
header+"\r\n"+body)
|
||||||
|
|
||||||
err := conn.SetReadDeadline(time.Now().Add(waitLimit))
|
err := conn.SetReadDeadline(time.Now().Add(waitLimit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -451,5 +471,5 @@ func (s *sender) requestWithHeader(
|
|||||||
s.sent++
|
s.sent++
|
||||||
wantLine(s.t, line, status, action)
|
wantLine(s.t, line, status, action)
|
||||||
|
|
||||||
return line, string(got.body)
|
return line, got
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// whole is the percentage of each limit a client gets when no biased
|
||||||
|
// threshold lowers its limits.
|
||||||
|
const whole = 100
|
||||||
|
|
||||||
|
// percentage is a client's limit percentage for the rate limits or for
|
||||||
|
// the byte limits, as the biased thresholds give it, and the setting that
|
||||||
|
// gave it: "" with whole when none lowers that kind of limit.
|
||||||
|
type percentage struct {
|
||||||
|
percent int64
|
||||||
|
setting string
|
||||||
|
}
|
||||||
|
|
||||||
|
// biasedThresholdsSet reports whether a biased threshold can lower a
|
||||||
|
// client's limits: one of its lists is not empty,
|
||||||
|
// SWWAF_UNKNOWN_LIMIT_PERCENT is below 100, or SWWAF_ASN_LIMIT_PERCENT_URL
|
||||||
|
// is set. The client's lookup is then needed before its request goes on.
|
||||||
|
func biasedThresholdsSet(cfg *config.Config) bool {
|
||||||
|
return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
|
||||||
|
len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
|
||||||
|
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitPercentages returns the client's limit percentages, for the rate
|
||||||
|
// limits and for the byte limits, by its AS number and country as looked
|
||||||
|
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
|
||||||
|
// that list it. Each is the lowest of those the settings give it, the
|
||||||
|
// first of them in the order below when several are lowest: the
|
||||||
|
// percentage SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file
|
||||||
|
// SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one
|
||||||
|
// SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
|
||||||
|
// country, SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists,
|
||||||
|
// the percentage of SWWAF_BLOCKLIST_ACTION while it is limit, and for a
|
||||||
|
// client a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit,
|
||||||
|
// the percentage of SWWAF_REPUTATION_ACTION while it is limit. For the
|
||||||
|
// byte limits, SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT
|
||||||
|
// take the place of the first three for an AS number or a country they
|
||||||
|
// list.
|
||||||
|
func (rq *request) limitPercentages() (percentage, percentage) {
|
||||||
|
cfg := rq.h.config
|
||||||
|
asn, country := rq.line.ASN, rq.line.Country
|
||||||
|
|
||||||
|
unknown := percentage{percent: whole}
|
||||||
|
if country == "" {
|
||||||
|
unknown = percentage{cfg.UnknownLimitPercent, "SWWAF_UNKNOWN_LIMIT_PERCENT"}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetched := percentage{percent: whole}
|
||||||
|
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
||||||
|
fetched = percentage{percent, "SWWAF_ASN_LIMIT_PERCENT_URL"}
|
||||||
|
}
|
||||||
|
|
||||||
|
blocklisted := percentage{percent: whole}
|
||||||
|
if rq.blocklisted && cfg.BlocklistAction == "limit" {
|
||||||
|
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWWAF_BLOCKLIST_ACTION"}
|
||||||
|
}
|
||||||
|
|
||||||
|
reputationListed := percentage{percent: whole}
|
||||||
|
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
||||||
|
reputationListed = percentage{cfg.ReputationLimitPercent, "SWWAF_REPUTATION_ACTION"}
|
||||||
|
}
|
||||||
|
|
||||||
|
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWWAF_ASN_LIMIT_PERCENT"),
|
||||||
|
fetched)
|
||||||
|
countryRequests := given(cfg.CountryLimitPercent, country,
|
||||||
|
"SWWAF_COUNTRY_LIMIT_PERCENT")
|
||||||
|
|
||||||
|
asnBytes, countryBytes := asnRequests, countryRequests
|
||||||
|
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
||||||
|
asnBytes = given(cfg.ASNBytesPercent, asn, "SWWAF_ASN_BYTES_PERCENT")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
||||||
|
countryBytes = given(cfg.CountryBytesPercent, country, "SWWAF_COUNTRY_BYTES_PERCENT")
|
||||||
|
}
|
||||||
|
|
||||||
|
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
|
||||||
|
lowest(asnBytes, countryBytes, unknown, blocklisted, reputationListed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// given returns the percentage percents, the setting named setting, gives
|
||||||
|
// code, an AS number or a country, or whole when it does not list code.
|
||||||
|
func given(percents map[string]int64, code, setting string) percentage {
|
||||||
|
percent, listed := percents[code]
|
||||||
|
if !listed {
|
||||||
|
return percentage{percent: whole}
|
||||||
|
}
|
||||||
|
|
||||||
|
return percentage{percent, setting}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lowest returns the lowest of percentages below whole, the first of them
|
||||||
|
// when several are lowest, or whole when none is below it.
|
||||||
|
func lowest(percentages ...percentage) percentage {
|
||||||
|
low := percentage{percent: whole}
|
||||||
|
|
||||||
|
for _, p := range percentages {
|
||||||
|
if p.percent < low.percent {
|
||||||
|
low = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return low
|
||||||
|
}
|
||||||
|
|
||||||
|
// logged returns p as the log line and the notes of a ban give it: its
|
||||||
|
// percent and setting, or nil and "" for whole, which they leave out.
|
||||||
|
func (p percentage) logged() (*int64, string) {
|
||||||
|
if p.percent == whole {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return &p.percent, p.setting
|
||||||
|
}
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
package proxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The biased thresholds.
|
||||||
|
const (
|
||||||
|
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT"
|
||||||
|
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT"
|
||||||
|
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT"
|
||||||
|
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT"
|
||||||
|
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// asnDEHalf and countryDEHalf give fromDE's AS number and its country
|
||||||
|
// half of every limit, and asnDEQuarter gives its AS number a quarter.
|
||||||
|
asnDEHalf = asnDE + ":50"
|
||||||
|
asnDEQuarter = asnDE + ":25"
|
||||||
|
countryDEHalf = "de:50"
|
||||||
|
// noCountry is in an AS of its own, AS64500, and in no country.
|
||||||
|
noCountry = "192.0.2.80"
|
||||||
|
// fourAMinute is the rate limit these tests set: half of it is 2
|
||||||
|
// requests a minute, a quarter of it 1.
|
||||||
|
fourAMinute = "4"
|
||||||
|
// twoUploads is the byte limit these tests set: 199 bytes, which an
|
||||||
|
// upload, a request with a body and its answer, 100 bytes, is within,
|
||||||
|
// and half of which, 99 bytes, it is over.
|
||||||
|
twoUploads = "199"
|
||||||
|
// none is how percentText gives a percentage left out.
|
||||||
|
none = "none"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
setting, value, from string
|
||||||
|
}{
|
||||||
|
{asnLimitPercent, asnDEHalf, fromDE},
|
||||||
|
{countryLimitPercent, countryDEHalf, fromDE},
|
||||||
|
{unknownLimitPercent, "50", unplaced},
|
||||||
|
} {
|
||||||
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithLookups(t, map[string]string{
|
||||||
|
rateLimitPerMinute: fourAMinute, tc.setting: tc.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Half of 4 requests a minute: the third breaks the limit.
|
||||||
|
for _, sent := range []struct {
|
||||||
|
status int
|
||||||
|
action string
|
||||||
|
}{
|
||||||
|
{http.StatusOK, requestlog.ActionForward},
|
||||||
|
{http.StatusOK, requestlog.ActionForward},
|
||||||
|
{http.StatusForbidden, requestlog.ActionRateLimited},
|
||||||
|
} {
|
||||||
|
line := s.get(tc.from, sent.status, sent.action)
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
|
"50 from "+tc.setting)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fromKP, which no setting lists, has the whole limit.
|
||||||
|
for range 3 {
|
||||||
|
line := s.get(fromKP, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
|
none)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The AS numbers and countries are given in either case.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
setting, value, from string
|
||||||
|
}{
|
||||||
|
{asnLimitPercent, asnDEHalf, fromDE},
|
||||||
|
{countryLimitPercent, "DE:50", fromDE},
|
||||||
|
{unknownLimitPercent, "50", unplaced},
|
||||||
|
{asnBytesPercent, "as64496:50", fromDE},
|
||||||
|
{countryBytesPercent, countryDEHalf, fromDE},
|
||||||
|
} {
|
||||||
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithLookups(t, map[string]string{
|
||||||
|
bytesLimitPerMinute: twoUploads, tc.setting: tc.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The upload's 100 bytes are over half of 199, 99.
|
||||||
|
line := s.uploadFrom(tc.from)
|
||||||
|
if line.LimitHit != minuteBytes {
|
||||||
|
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
||||||
|
"50 from "+tc.setting)
|
||||||
|
|
||||||
|
// fromKP, which no setting lists, has the whole limit.
|
||||||
|
line = s.uploadFrom(fromKP)
|
||||||
|
if line.LimitHit != "" {
|
||||||
|
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, none)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env map[string]string
|
||||||
|
// limitPercent and bytesPercent are the log line's, as percentText
|
||||||
|
// gives them, and limitHit is its limit_hit.
|
||||||
|
limitPercent, bytesPercent, limitHit string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"lowering the byte limits alone",
|
||||||
|
map[string]string{asnBytesPercent: asnDEHalf},
|
||||||
|
none, "50 from " + asnBytesPercent, minuteBytes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lowering the byte limits alone, by country",
|
||||||
|
map[string]string{countryBytesPercent: countryDEHalf},
|
||||||
|
none, "50 from " + countryBytesPercent, minuteBytes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"raising the byte limits back",
|
||||||
|
map[string]string{asnLimitPercent: asnDEHalf, asnBytesPercent: asnDE + ":100"},
|
||||||
|
"50 from " + asnLimitPercent, none, "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"raising the byte limits back, by country",
|
||||||
|
map[string]string{countryLimitPercent: countryDEHalf, countryBytesPercent: "de:100"},
|
||||||
|
"50 from " + countryLimitPercent, none, "",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := map[string]string{bytesLimitPerMinute: twoUploads}
|
||||||
|
maps.Copy(env, tc.env)
|
||||||
|
s, _, _ := startWithLookups(t, env)
|
||||||
|
|
||||||
|
// The upload's 100 bytes are over 99, half of 199, and within 199.
|
||||||
|
line := s.uploadFrom(fromDE)
|
||||||
|
if line.LimitHit != tc.limitHit {
|
||||||
|
t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
|
tc.limitPercent)
|
||||||
|
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
||||||
|
tc.bytesPercent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithLookups(t, map[string]string{asnLimitPercent: asnDE + ":0"})
|
||||||
|
|
||||||
|
// The first request breaks the limit, and bans the client; the log line
|
||||||
|
// gives the 0.
|
||||||
|
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
if line.fields["limit_percent"] != float64(0) ||
|
||||||
|
line.fields["limit_percent_setting"] != asnLimitPercent {
|
||||||
|
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
|
||||||
|
line.fields["limit_percent"], line.fields["limit_percent_setting"],
|
||||||
|
asnLimitPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLowestPercentageApplies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env map[string]string
|
||||||
|
from string
|
||||||
|
// want is the log line's limit_percent, as percentText gives it.
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"the country's",
|
||||||
|
map[string]string{asnLimitPercent: asnDEHalf, countryLimitPercent: "de:25"},
|
||||||
|
fromDE, "25 from " + countryLimitPercent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"the AS number's",
|
||||||
|
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: countryDEHalf},
|
||||||
|
fromDE, "25 from " + asnLimitPercent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"the AS number's, the first of two alike",
|
||||||
|
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: "de:25"},
|
||||||
|
fromDE, "25 from " + asnLimitPercent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"that for a client without a country",
|
||||||
|
map[string]string{asnLimitPercent: "AS64500:50", unknownLimitPercent: "25"},
|
||||||
|
noCountry, "25 from " + unknownLimitPercent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// SWWAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
||||||
|
"the AS number's, for a client without a country",
|
||||||
|
map[string]string{asnLimitPercent: "AS64500:25"},
|
||||||
|
noCountry, "25 from " + asnLimitPercent,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := map[string]string{rateLimitPerMinute: fourAMinute}
|
||||||
|
maps.Copy(env, tc.env)
|
||||||
|
s, _, _ := startWithLookups(t, env)
|
||||||
|
|
||||||
|
// A quarter of 4 requests a minute: the second breaks the limit.
|
||||||
|
s.get(tc.from, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
|
tc.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithLookups(t, map[string]string{
|
||||||
|
rateLimitPerMinute: fourAMinute, unknownLimitPercent: "50",
|
||||||
|
})
|
||||||
|
|
||||||
|
// One the lookup database does not hold, and one on a private address,
|
||||||
|
// which is never looked up: the third request of each breaks half of 4.
|
||||||
|
for _, from := range []string{unplaced, "10.0.0.8"} {
|
||||||
|
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.get(from, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One in a country has the whole limit.
|
||||||
|
for range 3 {
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||||
|
// says, with a GeoJS that never answers.
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
|
||||||
|
time.Now, map[string]string{unknownLimitPercent: "0"})
|
||||||
|
|
||||||
|
// Once the second the request waits for its answer is up, the client
|
||||||
|
// counts as without a country, and its zero allowance refuses the
|
||||||
|
// request before it reaches the app.
|
||||||
|
serveFromDE(t, server, http.MethodGet, http.NoBody)
|
||||||
|
|
||||||
|
line := out.requestLine(t)
|
||||||
|
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
|
"0 from "+unknownLimitPercent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const timeout = 3 * time.Second
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
setting, value string
|
||||||
|
waits bool
|
||||||
|
}{
|
||||||
|
{asnLimitPercent, asnDEHalf, true},
|
||||||
|
{countryLimitPercent, countryDEHalf, true},
|
||||||
|
{asnBytesPercent, asnDEHalf, true},
|
||||||
|
{countryBytesPercent, countryDEHalf, true},
|
||||||
|
{unknownLimitPercent, "99", true},
|
||||||
|
{asnLimitPercentURL, asnURL, true},
|
||||||
|
// At 100, its default, it lowers no limit.
|
||||||
|
{unknownLimitPercent, "100", false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.setting+"="+tc.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||||
|
// says, with a GeoJS that never answers.
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
// The request's body is over SWWAF_REQUEST_MAX_BYTES, so that it
|
||||||
|
// is refused after the checks, and never reaches the app.
|
||||||
|
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()
|
||||||
|
|
||||||
|
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
|
||||||
|
|
||||||
|
want := time.Duration(0)
|
||||||
|
if tc.waits {
|
||||||
|
want = timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
if waited := time.Since(began); waited != want {
|
||||||
|
t.Errorf("the request waited %s for its answer, want %s", waited, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantLine(t, out.requestLine(t), http.StatusRequestEntityTooLarge,
|
||||||
|
requestlog.ActionTooLarge)
|
||||||
|
|
||||||
|
// The bubble's clock stops once this function returns, so the
|
||||||
|
// request to GeoJS, which a request that did not wait leaves
|
||||||
|
// under way, has to be abandoned before then.
|
||||||
|
time.Sleep(timeout)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env map[string]string
|
||||||
|
// before is how many uploads come before the one that breaks a
|
||||||
|
// limit, which is answered with status and logged with action.
|
||||||
|
before int
|
||||||
|
status int
|
||||||
|
action string
|
||||||
|
// reason and want are the ban's reason, and its notes' limit
|
||||||
|
// percentage, as percentText gives it.
|
||||||
|
reason, want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// A quarter of 12 requests a minute is 3: the fourth breaks it.
|
||||||
|
"a rate limit",
|
||||||
|
map[string]string{rateLimitPerMinute: "12", asnLimitPercent: asnDEQuarter},
|
||||||
|
3, http.StatusForbidden, requestlog.ActionRateLimited,
|
||||||
|
"requests per minute over the limit of 3", "25 from " + asnLimitPercent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The byte limits' percentage, not the rate limits'.
|
||||||
|
"a byte limit",
|
||||||
|
map[string]string{
|
||||||
|
bytesLimitPerMinute: twoUploads, asnLimitPercent: asnDEQuarter,
|
||||||
|
asnBytesPercent: asnDEHalf,
|
||||||
|
},
|
||||||
|
0, http.StatusOK, requestlog.ActionForward,
|
||||||
|
"bytes per minute over the limit of 99", "50 from " + asnBytesPercent,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, queue := startWithLookups(t, tc.env)
|
||||||
|
|
||||||
|
for range tc.before {
|
||||||
|
s.uploadFrom(fromDE)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.requestWithBody(http.MethodPost, fromDE, "/", uploadHeader, uploadBody,
|
||||||
|
tc.status, tc.action)
|
||||||
|
|
||||||
|
held := server.Ledger.Bans(netip.MustParsePrefix(fromDE + "/32"))
|
||||||
|
if len(held) != 1 {
|
||||||
|
t.Fatalf("bans %+v, want one", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
notes := held[0].Notes
|
||||||
|
if held[0].Reason != tc.reason {
|
||||||
|
t.Errorf("the ban's reason is %q, want %q", held[0].Reason, tc.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPercent(t, "the notes' limit_percent", notes.LimitPercent,
|
||||||
|
notes.LimitPercentSetting, tc.want)
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 1 {
|
||||||
|
t.Fatalf("%d alerts wait, want the ban's alone: %+v", len(waiting), waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
alerted, _ := waiting[0].Detail["notes"].(bans.Notes)
|
||||||
|
wantPercent(t, "the alert's notes' limit_percent", alerted.LimitPercent,
|
||||||
|
alerted.LimitPercentSetting, tc.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWithLookups is startWithLookupsAndClock for a test that needs no
|
||||||
|
// clock.
|
||||||
|
func startWithLookups(
|
||||||
|
t *testing.T, env map[string]string,
|
||||||
|
) (*sender, *proxy.Server, *alerts.Queue) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
s, _, server, queue := startWithLookupsAndClock(t, env)
|
||||||
|
|
||||||
|
return s, server, queue
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWithLookupsAndClock is startAppWithAlerts in front of
|
||||||
|
// readAndAnswer, with the settings in env on top of clients looked up in a
|
||||||
|
// lookup database, which places fromDE and fromKP in the AS numbers and
|
||||||
|
// countries the stand-in for GeoJS gives them, noCountry in AS64500 and no
|
||||||
|
// country, and no other address.
|
||||||
|
func startWithLookupsAndClock(
|
||||||
|
t *testing.T, env map[string]string,
|
||||||
|
) (*sender, *clock, *proxy.Server, *alerts.Queue) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||||
|
lookuptest.Write(t, path, map[string]lookuptest.Network{
|
||||||
|
fromDE + "/32": {ASN: asnDE, ASName: asNameDE, Country: "DE"},
|
||||||
|
fromKP + "/32": {ASN: asnKP, ASName: asNameKP, Country: "KP"},
|
||||||
|
noCountry + "/32": {ASN: "AS64500", ASName: "Nowhere Net"},
|
||||||
|
})
|
||||||
|
|
||||||
|
settings := map[string]string{lookupSource: fileSource, lookupDBPath: path}
|
||||||
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
|
return startAppWithAlerts(t, readAndAnswer, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadFrom is upload from the client at from.
|
||||||
|
func (s *sender) uploadFrom(from string) logLine {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
line, _ := s.requestWithBody(http.MethodPost, from, "/", uploadHeader, uploadBody,
|
||||||
|
http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveFromDE hands a request from fromDE with method and body straight to
|
||||||
|
// server's handler, without the network, and returns once it is answered.
|
||||||
|
func serveFromDE(t *testing.T, server *proxy.Server, method string, body io.Reader) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
||||||
|
req.RemoteAddr = net.JoinHostPort(fromDE, "1234")
|
||||||
|
|
||||||
|
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantPercent checks a limit percentage that a log line or a ban's notes
|
||||||
|
// give, what, and the setting that gave it, against want, as percentText
|
||||||
|
// gives them.
|
||||||
|
func wantPercent(t *testing.T, what string, percent *int64, setting, want string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if got := percentText(percent, setting); got != want {
|
||||||
|
t.Errorf("%s is %s, want %s", what, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// percentText gives a limit percentage and the setting that gave it as
|
||||||
|
// text, such as "50 from SWWAF_ASN_LIMIT_PERCENT", or none when both are
|
||||||
|
// left out.
|
||||||
|
func percentText(percent *int64, setting string) string {
|
||||||
|
switch {
|
||||||
|
case percent == nil && setting == "":
|
||||||
|
return none
|
||||||
|
case percent == nil:
|
||||||
|
return "none from " + setting
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d from %s", *percent, setting)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,48 @@ func (b *responseBody) Close() error {
|
|||||||
return b.body.Close()
|
return b.body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// upgradedConn is the connection to the app once the app has switched
|
||||||
|
// protocols, as for a WebSocket. ReverseProxy writes to it what the client
|
||||||
|
// sends and reads from it what the app sends, on goroutines of its own,
|
||||||
|
// until the connection closes; it counts the bytes each way, for the byte
|
||||||
|
// limits.
|
||||||
|
type upgradedConn struct {
|
||||||
|
io.ReadWriteCloser
|
||||||
|
|
||||||
|
// fromApp is how many bytes the app has sent, and toApp how many the
|
||||||
|
// client has.
|
||||||
|
fromApp atomic.Int64
|
||||||
|
toApp atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reads what the app sends.
|
||||||
|
func (c *upgradedConn) Read(p []byte) (int, error) {
|
||||||
|
n, err := c.ReadWriteCloser.Read(p)
|
||||||
|
c.fromApp.Add(int64(n))
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write sends the app what the client sent.
|
||||||
|
func (c *upgradedConn) Write(p []byte) (int, error) {
|
||||||
|
n, err := c.ReadWriteCloser.Write(p)
|
||||||
|
c.toApp.Add(int64(n))
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseWrite tells the app that the client sends no more, while what the
|
||||||
|
// app sends still passes. ReverseProxy calls it once the client has
|
||||||
|
// stopped sending, and closes the connection there if it is not supported.
|
||||||
|
func (c *upgradedConn) CloseWrite() error {
|
||||||
|
conn, ok := c.ReadWriteCloser.(interface{ CloseWrite() error })
|
||||||
|
if !ok {
|
||||||
|
return http.ErrNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
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 unchanged if maxBytes is zero, which is off.
|
// maxBytes, or 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 {
|
||||||
|
|||||||
@@ -0,0 +1,583 @@
|
|||||||
|
package proxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The byte limit settings.
|
||||||
|
const (
|
||||||
|
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
||||||
|
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
||||||
|
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
||||||
|
bytesCount = "SWWAF_BYTES_COUNT"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The values of SWWAF_BYTES_COUNT.
|
||||||
|
const (
|
||||||
|
countResponse = "response"
|
||||||
|
countRequest = "request"
|
||||||
|
countBoth = "both"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// bodyBytes is the size of the body of each request these tests send
|
||||||
|
// with one, and answerBytes that of each answer of the app.
|
||||||
|
bodyBytes = 30
|
||||||
|
answerBytes = 70
|
||||||
|
// byteLimit is the byte limit these tests set, as a setting: a request
|
||||||
|
// with a body and its answer, 100 bytes, go over it.
|
||||||
|
byteLimit = "99"
|
||||||
|
// minuteBytes is limit_hit for SWWAF_BYTES_LIMIT_PER_MINUTE.
|
||||||
|
minuteBytes = "minute_bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const scraper = "192.0.2.200"
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
setting, window string
|
||||||
|
// apart is the time between the two requests, which the window
|
||||||
|
// still covers.
|
||||||
|
apart time.Duration
|
||||||
|
}{
|
||||||
|
{bytesLimitPerMinute, minute, 0},
|
||||||
|
{bytesLimitPerHour, "hour", 2 * time.Minute},
|
||||||
|
{bytesLimitPerDay, "day", 2 * time.Hour},
|
||||||
|
} {
|
||||||
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk := startWithAnswers(t, map[string]string{
|
||||||
|
tc.setting: byteLimit, metricsToken: token,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 70 bytes are within the limit of 99.
|
||||||
|
line, _ := s.download()
|
||||||
|
if line.LimitHit != "" || line.Offence != "" {
|
||||||
|
t.Errorf("log line has limit_hit %q and offence %q, want neither",
|
||||||
|
line.LimitHit, line.Offence)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 140 bytes are over it. The response is passed on whole, and
|
||||||
|
// then bans the client for an hour.
|
||||||
|
clk.advance(tc.apart)
|
||||||
|
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
line, got := s.download()
|
||||||
|
if got.err != nil || len(got.body) != answerBytes ||
|
||||||
|
line.ResponseBytes != answerBytes {
|
||||||
|
t.Errorf("got %d bytes (%v), and the log line has response_bytes %d, "+
|
||||||
|
"want %d", len(got.body), got.err, line.ResponseBytes, answerBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.LimitHit != tc.window+"_bytes" || line.Offence != requestlog.OffenceLimit ||
|
||||||
|
line.BanExpires != expires {
|
||||||
|
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
||||||
|
"want %s_bytes, limit and %s", line.LimitHit, line.Offence,
|
||||||
|
line.BanExpires, tc.window, expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
|
wantMetric(t, s.scrape(scraper), `smallwebwaf_rate_limit_hits_total{`+
|
||||||
|
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: "50"})
|
||||||
|
|
||||||
|
// The answer's 70 bytes are over the limit of 50 on their own.
|
||||||
|
line, got := s.download()
|
||||||
|
if got.err != nil || len(got.body) != answerBytes || line.LimitHit != minuteBytes {
|
||||||
|
t.Errorf("got %d bytes (%v), and the log line has limit_hit %q, want %d and %s",
|
||||||
|
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, _, _ := startAppWithAlerts(t, breakOff, map[string]string{
|
||||||
|
bytesLimitPerMinute: "50",
|
||||||
|
})
|
||||||
|
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
// The 70 bytes passed on before the app broke off are over the limit of
|
||||||
|
// 50, and ban the client for an hour.
|
||||||
|
line, got := s.requestWithBody(http.MethodGet, client, "/", "", "", http.StatusOK,
|
||||||
|
requestlog.ActionUpstreamError)
|
||||||
|
if len(got.body) != answerBytes || line.LimitHit != minuteBytes ||
|
||||||
|
line.BanExpires != expires {
|
||||||
|
t.Errorf("got %d bytes, and the log line has limit_hit %q and ban_expires %q, "+
|
||||||
|
"want %d, %s and %s", len(got.body), line.LimitHit, line.BanExpires,
|
||||||
|
answerBytes, minuteBytes, expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
setting string
|
||||||
|
counted float64
|
||||||
|
}{
|
||||||
|
{countResponse, answerBytes},
|
||||||
|
{countRequest, bodyBytes},
|
||||||
|
{countBoth, bodyBytes + answerBytes},
|
||||||
|
} {
|
||||||
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _, _ := startAppWithAlerts(t, answerAfterUpgrade, map[string]string{
|
||||||
|
bytesLimitPerMinute: "29", bytesCount: tc.setting,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The client sends 30 bytes and the app 70, each over the limit
|
||||||
|
// of 29, which bans the client once the WebSocket has closed.
|
||||||
|
line := s.webSocket()
|
||||||
|
if line.LimitHit != minuteBytes || line.Counts.MinuteBytes != tc.counted {
|
||||||
|
t.Errorf("log line has limit_hit %q and minute_bytes %v, want %s and %v",
|
||||||
|
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
setting string
|
||||||
|
// each is the bytes each request counts, and breaking the request
|
||||||
|
// that goes over the limit of 99.
|
||||||
|
each float64
|
||||||
|
breaking int
|
||||||
|
}{
|
||||||
|
{countResponse, answerBytes, 2},
|
||||||
|
{countRequest, bodyBytes, 4},
|
||||||
|
{countBoth, bodyBytes + answerBytes, 1},
|
||||||
|
} {
|
||||||
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
|
bytesLimitPerMinute: byteLimit, bytesCount: tc.setting,
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 1; i <= tc.breaking; i++ {
|
||||||
|
line := s.upload()
|
||||||
|
|
||||||
|
want := ""
|
||||||
|
if i == tc.breaking {
|
||||||
|
want = minuteBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
counted := float64(i) * tc.each
|
||||||
|
if line.LimitHit != want || line.Counts.MinuteBytes != counted {
|
||||||
|
t.Errorf("request %d: log line has limit_hit %q and minute_bytes %v, "+
|
||||||
|
"want %q and %v", i, line.LimitHit, line.Counts.MinuteBytes,
|
||||||
|
want, counted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS
|
||||||
|
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||||
|
)
|
||||||
|
|
||||||
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
|
bytesLimitPerMinute: byteLimit,
|
||||||
|
allowNets: allowed,
|
||||||
|
rateLimitExemptNets: exempt,
|
||||||
|
rateLimitExemptPaths: "/assets/",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each sends 200 bytes, none of which is counted.
|
||||||
|
for _, sent := range []struct{ from, path string }{
|
||||||
|
{allowed, "/"}, {exempt, "/"}, {client, "/assets/app.js"},
|
||||||
|
} {
|
||||||
|
for range 2 {
|
||||||
|
line, _ := s.requestWithBody(http.MethodPost, sent.from, sent.path,
|
||||||
|
uploadHeader, uploadBody, http.StatusOK, requestlog.ActionForward)
|
||||||
|
if _, counted := line.fields["counts"]; counted || line.LimitHit != "" {
|
||||||
|
t.Errorf("%s %s: log line has counts %v and limit_hit %q, want neither",
|
||||||
|
sent.from, sent.path, line.fields["counts"], line.LimitHit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A path that is not exempt is counted, and breaks the limit.
|
||||||
|
line := s.upload()
|
||||||
|
if line.LimitHit != minuteBytes {
|
||||||
|
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestByteLimitsOffCountTheBytesAndBanNoOne(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const off = "off"
|
||||||
|
|
||||||
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
|
bytesLimitPerMinute: off, bytesLimitPerHour: off, bytesLimitPerDay: off,
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
line := s.upload()
|
||||||
|
|
||||||
|
counted := float64(i * (bodyBytes + answerBytes))
|
||||||
|
if line.LimitHit != "" || line.Counts.MinuteBytes != counted ||
|
||||||
|
line.Counts.HourBytes != counted || line.Counts.DayBytes != counted {
|
||||||
|
t.Errorf("request %d: log line has limit_hit %q and counts %+v, "+
|
||||||
|
"want none and %v bytes in each window", i, line.LimitHit,
|
||||||
|
line.Counts, counted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBanForABrokenByteLimitHasItsNotesAndItsAlert(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||||
|
bytesLimitPerMinute: byteLimit,
|
||||||
|
})
|
||||||
|
start := clk.Now()
|
||||||
|
|
||||||
|
s.requestWithBody(http.MethodPost, client, "/upload?part=1", uploadHeader,
|
||||||
|
uploadBody, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
netblock := netip.MustParsePrefix(client + "/32")
|
||||||
|
want := bans.Ban{
|
||||||
|
Netblock: netblock,
|
||||||
|
Start: start,
|
||||||
|
Expires: start.Add(time.Hour),
|
||||||
|
Cause: bans.CauseLimit,
|
||||||
|
Reason: "bytes per minute over the limit of " + byteLimit,
|
||||||
|
Notes: bans.Notes{
|
||||||
|
Kind: "bytes",
|
||||||
|
Limit: 99,
|
||||||
|
Window: minute,
|
||||||
|
Count: bodyBytes + answerBytes,
|
||||||
|
// The request as it was answered, by the app.
|
||||||
|
Request: bans.Request{
|
||||||
|
Time: start,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Host: appHost,
|
||||||
|
Path: "/upload?part=1",
|
||||||
|
Status: http.StatusOK,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
},
|
||||||
|
Requests: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := server.Ledger.Bans(netblock)
|
||||||
|
if len(got) != 1 || got[0] != want {
|
||||||
|
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, want,
|
||||||
|
requestlog.FormatTime(want.Expires)))
|
||||||
|
|
||||||
|
if offences := historyOf(t, server, client).Offences.Limit; offences != 1 {
|
||||||
|
t.Errorf("history counts %d offences for a limit, want 1", offences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveModeLogsAndAlertsAByteLimitAndBansNoOne(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||||
|
mode: observe,
|
||||||
|
bytesLimitPerMinute: byteLimit,
|
||||||
|
})
|
||||||
|
start := clk.Now()
|
||||||
|
|
||||||
|
// No ban sets the client's counters back to zero, so each request
|
||||||
|
// breaks the limit again. The answer is the app's either way, and the
|
||||||
|
// alert for the ban is not sent twice within the cooldown.
|
||||||
|
for range 2 {
|
||||||
|
line := s.upload()
|
||||||
|
wantWouldAction(t, line, "")
|
||||||
|
|
||||||
|
if line.LimitHit != minuteBytes || line.Offence != requestlog.OffenceLimit ||
|
||||||
|
line.BanExpires != "" {
|
||||||
|
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
||||||
|
"want %s, limit and none", line.LimitHit, line.Offence, line.BanExpires,
|
||||||
|
minuteBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||||
|
t.Errorf("the ledger holds %+v, want no ban", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 1 {
|
||||||
|
t.Fatalf("%d alerts wait, want 1: %+v", len(waiting), waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
notes, _ := waiting[0].Detail["notes"].(bans.Notes)
|
||||||
|
alert := banAlert(alerts.EventBan, start, client, bans.Ban{
|
||||||
|
Netblock: netip.MustParsePrefix(client + "/32"), Cause: bans.CauseLimit,
|
||||||
|
Reason: "bytes per minute over the limit of " + byteLimit, Notes: notes,
|
||||||
|
}, requestlog.FormatTime(start.Add(time.Hour)))
|
||||||
|
alert.Detail["mode"] = observe
|
||||||
|
wantAlerts(t, queue, alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
|
mode: observe,
|
||||||
|
rateLimitPerMinute: "1",
|
||||||
|
bytesLimitPerMinute: "150",
|
||||||
|
})
|
||||||
|
|
||||||
|
s.upload()
|
||||||
|
|
||||||
|
// The second request breaks the rate limit, which in enforce mode would
|
||||||
|
// refuse it before the app sent anything, so its 100 bytes are not
|
||||||
|
// counted, and the byte limit is not broken. Its line gives the bytes
|
||||||
|
// counted before it.
|
||||||
|
line := s.upload()
|
||||||
|
wantWouldAction(t, line, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
|
if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes {
|
||||||
|
t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d",
|
||||||
|
line.LimitHit, line.Counts.MinuteBytes, bodyBytes+answerBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadHeader and uploadBody are the header and the body of a request
|
||||||
|
// with a body of bodyBytes.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // a constant cannot call strings.Repeat
|
||||||
|
var (
|
||||||
|
uploadHeader = "Content-Length: " + strconv.Itoa(bodyBytes)
|
||||||
|
uploadBody = strings.Repeat("u", bodyBytes)
|
||||||
|
)
|
||||||
|
|
||||||
|
// readAndAnswer is the app of these tests: it reads each request's whole
|
||||||
|
// body and answers with answerBytes bytes.
|
||||||
|
func readAndAnswer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
_, _ = io.WriteString(w, strings.Repeat("a", answerBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// breakOff is an app that announces an answer of twice answerBytes, and
|
||||||
|
// breaks off after answerBytes.
|
||||||
|
func breakOff(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(2*answerBytes))
|
||||||
|
_, _ = io.WriteString(w, strings.Repeat("a", answerBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerAfterUpgrade is an app that switches protocols, as for a
|
||||||
|
// WebSocket, and then answers each line it receives with a line of
|
||||||
|
// answerBytes.
|
||||||
|
func answerAfterUpgrade(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
|
||||||
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||||
|
_ = buffered.Flush()
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, err := buffered.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = buffered.WriteString(strings.Repeat("a", answerBytes-1) + "\n")
|
||||||
|
_ = buffered.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// line of bodyBytes on it, reads the answer, and closes it. It checks the
|
||||||
|
// answer, and the log line as request does, and returns the log line.
|
||||||
|
func (s *sender) webSocket() logLine {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
conn, reader := s.openWebSocket()
|
||||||
|
send(s.t, conn, strings.Repeat("u", bodyBytes-1)+"\n")
|
||||||
|
|
||||||
|
got, err := reader.ReadString('\n')
|
||||||
|
if err != nil || len(got) != answerBytes {
|
||||||
|
s.t.Errorf("got %d bytes (%v), want %d", len(got), err, answerBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.closeWebSocket(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openWebSocket sends a request from client to switch protocols, as for a
|
||||||
|
// WebSocket, and checks that the app switches. It returns the connection,
|
||||||
|
// on which reading fails once waitLimit has passed, and a reader of what
|
||||||
|
// the app sends on it.
|
||||||
|
func (s *sender) openWebSocket() (net.Conn, *bufio.Reader) {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
conn := dial(s.t, s.addr)
|
||||||
|
send(s.t, conn, "GET /socket HTTP/1.1\r\nHost: "+appHost+"\r\n"+forwardedFor+
|
||||||
|
": "+client+"\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||||
|
|
||||||
|
err := conn.SetReadDeadline(time.Now().Add(waitLimit))
|
||||||
|
if err != nil {
|
||||||
|
s.t.Fatalf("set read deadline: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
|
||||||
|
res, err := http.ReadResponse(reader, nil)
|
||||||
|
if err != nil {
|
||||||
|
s.t.Fatalf("read the answer to the upgrade: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode != http.StatusSwitchingProtocols {
|
||||||
|
s.t.Fatalf("status %d, want %d", res.StatusCode, http.StatusSwitchingProtocols)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn, reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeWebSocket closes conn, a WebSocket openWebSocket opened, checks its
|
||||||
|
// log line as request does, and returns it.
|
||||||
|
func (s *sender) closeWebSocket(conn net.Conn) logLine {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
line := s.out.requestLines(s.t, s.sent+1)[s.sent]
|
||||||
|
s.sent++
|
||||||
|
wantLine(s.t, line, http.StatusSwitchingProtocols, requestlog.ActionForward)
|
||||||
|
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWithAnswers is startAppWithAlerts in front of readAndAnswer, for a
|
||||||
|
// test that looks at neither the server nor the alerts.
|
||||||
|
func startWithAnswers(t *testing.T, env map[string]string) (*sender, *clock) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
s, clk, _, _ := startAppWithAlerts(t, readAndAnswer, env)
|
||||||
|
|
||||||
|
return s, clk
|
||||||
|
}
|
||||||
|
|
||||||
|
// download sends a GET request for / from client, and checks that the
|
||||||
|
// app's answer is passed on, as request does. It returns the log line and
|
||||||
|
// the answer.
|
||||||
|
func (s *sender) download() (logLine, answer) {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
return s.requestWithBody(http.MethodGet, client, "/", "", "", http.StatusOK,
|
||||||
|
requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload is download for a POST request with a body of bodyBytes, and
|
||||||
|
// returns the log line.
|
||||||
|
func (s *sender) upload() logLine {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
line, _ := s.requestWithBody(http.MethodPost, client, "/", uploadHeader,
|
||||||
|
uploadBody, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
return line
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -48,6 +49,33 @@ func clientAddress(
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestIDHeader carries the request's id, from traefik and to the app.
|
||||||
|
const requestIDHeader = "X-Request-ID"
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
proto := r.Header.Get("X-Forwarded-Proto")
|
||||||
|
if !peerTrusted || proto == "" {
|
||||||
|
return "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
return proto
|
||||||
|
}
|
||||||
|
|
||||||
// ipv6GroupPrefix is the length of the IPv6 netblock that is one client.
|
// ipv6GroupPrefix is the length of the IPv6 netblock that is one client.
|
||||||
const ipv6GroupPrefix = 64
|
const ipv6GroupPrefix = 64
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ const (
|
|||||||
appHost = "app.example"
|
appHost = "app.example"
|
||||||
// client is the client's address, as a proxy names it.
|
// client is the client's address, as a proxy names it.
|
||||||
client = "203.0.113.9"
|
client = "203.0.113.9"
|
||||||
// forwardedFor is the header that lists the client and its proxies.
|
// 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"
|
forwardedFor = "X-Forwarded-For"
|
||||||
// secure is the scheme a client reached traefik with.
|
forwardedProto = "X-Forwarded-Proto"
|
||||||
|
// secure is the scheme a client reached traefik with, and plain the
|
||||||
|
// one smallwebwaf serves.
|
||||||
secure = "https"
|
secure = "https"
|
||||||
|
plain = "http"
|
||||||
)
|
)
|
||||||
|
|
||||||
// appHeaders is what the app tells about the headers it received.
|
// appHeaders is what the app tells about the headers it received.
|
||||||
@@ -67,11 +71,11 @@ func clientAddressCases() []clientAddressCase {
|
|||||||
forged := http.Header{
|
forged := http.Header{
|
||||||
forwardedFor: {client},
|
forwardedFor: {client},
|
||||||
"X-Forwarded-Host": {"forged.example"},
|
"X-Forwarded-Host": {"forged.example"},
|
||||||
"X-Forwarded-Proto": {secure},
|
forwardedProto: {secure},
|
||||||
"X-Real-Ip": {client},
|
"X-Real-Ip": {client},
|
||||||
}
|
}
|
||||||
replaced := appHeaders{
|
replaced := appHeaders{
|
||||||
ForwardedFor: localhost, ForwardedHost: appHost, ForwardedProto: "http",
|
ForwardedFor: localhost, ForwardedHost: appHost, ForwardedProto: plain,
|
||||||
}
|
}
|
||||||
|
|
||||||
return []clientAddressCase{{
|
return []clientAddressCase{{
|
||||||
@@ -89,7 +93,7 @@ func clientAddressCases() []clientAddressCase {
|
|||||||
header: http.Header{
|
header: http.Header{
|
||||||
forwardedFor: {"198.51.100.7, " + client + ", 10.0.0.2"},
|
forwardedFor: {"198.51.100.7, " + client + ", 10.0.0.2"},
|
||||||
"X-Forwarded-Host": {appHost},
|
"X-Forwarded-Host": {appHost},
|
||||||
"X-Forwarded-Proto": {secure},
|
forwardedProto: {secure},
|
||||||
"X-Real-Ip": {client},
|
"X-Real-Ip": {client},
|
||||||
},
|
},
|
||||||
wantClient: client,
|
wantClient: client,
|
||||||
@@ -138,7 +142,7 @@ func requestWithHeaders(
|
|||||||
Host: r.Host,
|
Host: r.Host,
|
||||||
ForwardedFor: r.Header.Get(forwardedFor),
|
ForwardedFor: r.Header.Get(forwardedFor),
|
||||||
ForwardedHost: r.Header.Get("X-Forwarded-Host"),
|
ForwardedHost: r.Header.Get("X-Forwarded-Host"),
|
||||||
ForwardedProto: r.Header.Get("X-Forwarded-Proto"),
|
ForwardedProto: r.Header.Get(forwardedProto),
|
||||||
RealIP: r.Header.Get("X-Real-IP"),
|
RealIP: r.Header.Get("X-Real-IP"),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,31 +1,17 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/netip"
|
|
||||||
"slices"
|
"slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
// countryDenied reports whether the country lists refuse the request.
|
// countryDenied reports whether the country lists refuse the request, by
|
||||||
// The client's country is looked up only while a list is set, and never
|
// the client's country as it was looked up. A client without a country,
|
||||||
// for a client on a private, loopback or link-local address, which has
|
// or whose country cannot be found, is refused only by
|
||||||
// no country. A client without a country, or whose country cannot be
|
// SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||||
// found, is refused only by SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES. ctx is
|
func (rq *request) countryDenied() bool {
|
||||||
// the request's own context.
|
|
||||||
func (rq *request) countryDenied(ctx context.Context) bool {
|
|
||||||
denied := rq.h.config.DeniedCountries
|
denied := rq.h.config.DeniedCountries
|
||||||
allowed := rq.h.config.ExclusivelyAllowedCountries
|
allowed := rq.h.config.ExclusivelyAllowedCountries
|
||||||
|
country := rq.line.Country
|
||||||
if len(denied) == 0 && len(allowed) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var country string
|
|
||||||
if hasCountry(rq.client) {
|
|
||||||
country = rq.h.geojs.Country(ctx, clientGroup(rq.client))
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.line.Country = country
|
|
||||||
|
|
||||||
if slices.Contains(denied, country) {
|
if slices.Contains(denied, country) {
|
||||||
return true
|
return true
|
||||||
@@ -33,9 +19,3 @@ func (rq *request) countryDenied(ctx context.Context) bool {
|
|||||||
|
|
||||||
return len(allowed) > 0 && !slices.Contains(allowed, country)
|
return len(allowed) > 0 && !slices.Contains(allowed, country)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasCountry reports whether addr can be placed in a country: private,
|
|
||||||
// loopback and link-local addresses cannot.
|
|
||||||
func hasCountry(addr netip.Addr) bool {
|
|
||||||
return !addr.IsPrivate() && !addr.IsLoopback() && !addr.IsLinkLocalUnicast()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -56,16 +56,21 @@ func TestCountryLists(t *testing.T) {
|
|||||||
maps.Copy(env, tc.env)
|
maps.Copy(env, tc.env)
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
|
||||||
|
|
||||||
for i, sent := range []struct{ client, country string }{
|
// The AS number GeoJS gives unplaced, 64512, counts as unknown.
|
||||||
{fromDE, "DE"}, {fromKP, "KP"}, {unplaced, ""},
|
for i, sent := range []struct{ client, asn, asName, country string }{
|
||||||
|
{fromDE, asnDE, asNameDE, "DE"}, {fromKP, asnKP, asNameKP, "KP"},
|
||||||
|
{unplaced, "", "", ""},
|
||||||
} {
|
} {
|
||||||
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)
|
||||||
got := do(t, req)
|
got := do(t, req)
|
||||||
|
|
||||||
line := out.requestLines(t, i+1)[i]
|
line := out.requestLines(t, i+1)[i]
|
||||||
if line.Country != sent.country {
|
if line.ASN != sent.asn || line.ASName != sent.asName ||
|
||||||
t.Errorf("log line has country %q, want %q", line.Country, sent.country)
|
line.Country != sent.country {
|
||||||
|
t.Errorf("log line has %q, %q and %q, want %q, %q and %q",
|
||||||
|
line.ASN, line.ASName, line.Country,
|
||||||
|
sent.asn, sent.asName, sent.country)
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains(tc.refused, sent.client) {
|
if slices.Contains(tc.refused, sent.client) {
|
||||||
@@ -130,7 +135,7 @@ func TestRequestRefusedByCountryIsNotCounted(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
answer := []map[string]string{{"ip": r.URL.Query().Get("ip"), "country": "DE"}}
|
answer := []geojsAnswer{{IP: r.URL.Query().Get("ip"), CountryCode: "DE"}}
|
||||||
|
|
||||||
err := json.NewEncoder(w).Encode(answer)
|
err := json.NewEncoder(w).Encode(answer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -174,20 +179,15 @@ func TestRequestRefusedByCountryIsNotCounted(t *testing.T) {
|
|||||||
wantStatus(t, got, http.StatusOK)
|
wantStatus(t, got, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCountryNotLookedUpWithoutAListOrForAPrivateAddress(t *testing.T) {
|
func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
env map[string]string
|
env map[string]string
|
||||||
clients []string // "" sends no X-Forwarded-For: the client is 127.0.0.1
|
|
||||||
}{
|
}{
|
||||||
{"no country list is set", nil, []string{fromKP, fromDE}},
|
{"no setting needs the lookup", nil},
|
||||||
{
|
{"a country list is set", map[string]string{deniedCountries: "kp"}},
|
||||||
"private, loopback and link-local addresses",
|
|
||||||
map[string]string{deniedCountries: "kp"},
|
|
||||||
[]string{"10.0.0.5", "192.168.1.9", "fd00::5", "", "169.254.0.9", "fe80::9"},
|
|
||||||
},
|
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -198,7 +198,10 @@ func TestCountryNotLookedUpWithoutAListOrForAPrivateAddress(t *testing.T) {
|
|||||||
maps.Copy(env, tc.env)
|
maps.Copy(env, tc.env)
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
|
||||||
|
|
||||||
for i, sent := range tc.clients {
|
// "" sends no X-Forwarded-For: the client is 127.0.0.1.
|
||||||
|
for i, sent := range []string{
|
||||||
|
"10.0.0.5", "192.168.1.9", "fd00::5", "", "169.254.0.9", "fe80::9",
|
||||||
|
} {
|
||||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
if sent != "" {
|
if sent != "" {
|
||||||
req.Header.Set(forwardedFor, sent)
|
req.Header.Set(forwardedFor, sent)
|
||||||
@@ -209,15 +212,26 @@ func TestCountryNotLookedUpWithoutAListOrForAPrivateAddress(t *testing.T) {
|
|||||||
line := out.requestLines(t, i+1)[i]
|
line := out.requestLines(t, i+1)[i]
|
||||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
country, present := line.fields["country"]
|
for _, field := range []string{"asn", "as_name", "country"} {
|
||||||
if !present || country != "" {
|
value, present := line.fields[field]
|
||||||
t.Errorf("log line for %q has country %v, want an empty one",
|
if !present || value != "" {
|
||||||
line.ClientIP, country)
|
t.Errorf("log line for %q has %s %v, want an empty one",
|
||||||
|
line.ClientIP, field, value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(asked()) != 0 {
|
// GeoJS is asked about up to 200 waiting clients at once, so once it
|
||||||
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
// has been asked about fromDE, which comes last, it has been asked
|
||||||
|
// about every client before it that waited for an answer.
|
||||||
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
|
req.Header.Set(forwardedFor, fromDE)
|
||||||
|
wantStatus(t, do(t, req), http.StatusOK)
|
||||||
|
|
||||||
|
waitUntil(func() bool { return slices.Contains(asked(), fromDE) })
|
||||||
|
|
||||||
|
if got := asked(); !slices.Equal(got, []string{fromDE}) {
|
||||||
|
t.Errorf("GeoJS was asked about %v, want %s alone", got, fromDE)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -264,18 +278,31 @@ 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,
|
||||||
// and no other address. It returns its URL, and what returns the
|
// each in an AS of its own, and no other address. It returns its URL, and
|
||||||
// addresses it has been asked about.
|
// what returns the addresses it has been asked about.
|
||||||
func startGeoJS(t *testing.T) (string, func() []string) {
|
func startGeoJS(t *testing.T) (string, func() []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
places := map[string]string{fromDE: "DE", fromKP: "KP"}
|
geojsURL, asked, release := startHeldGeoJS(t)
|
||||||
|
release()
|
||||||
|
|
||||||
var asked struct {
|
return geojsURL, asked
|
||||||
|
}
|
||||||
|
|
||||||
|
// startHeldGeoJS is startGeoJS for a stand-in that answers nothing until
|
||||||
|
// release is called. Each request to it waits until then.
|
||||||
|
func startHeldGeoJS(t *testing.T) (string, func() []string, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var (
|
||||||
|
asked struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
addrs []string
|
addrs []string
|
||||||
}
|
}
|
||||||
|
released = make(chan struct{})
|
||||||
|
once sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
geojs := httptest.NewServer(http.HandlerFunc(
|
geojs := httptest.NewServer(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -285,11 +312,11 @@ func startGeoJS(t *testing.T) (string, func() []string) {
|
|||||||
asked.addrs = append(asked.addrs, addrs...)
|
asked.addrs = append(asked.addrs, addrs...)
|
||||||
asked.mu.Unlock()
|
asked.mu.Unlock()
|
||||||
|
|
||||||
answers := make([]map[string]string, 0, len(addrs))
|
<-released
|
||||||
|
|
||||||
|
answers := make([]geojsAnswer, 0, len(addrs))
|
||||||
for _, addr := range addrs {
|
for _, addr := range addrs {
|
||||||
answers = append(answers, map[string]string{
|
answers = append(answers, answerAbout(addr))
|
||||||
"ip": addr, "country": places[addr],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := json.NewEncoder(w).Encode(answers)
|
err := json.NewEncoder(w).Encode(answers)
|
||||||
@@ -299,10 +326,48 @@ func startGeoJS(t *testing.T) (string, func() []string) {
|
|||||||
}))
|
}))
|
||||||
t.Cleanup(geojs.Close)
|
t.Cleanup(geojs.Close)
|
||||||
|
|
||||||
|
release := func() { once.Do(func() { close(released) }) }
|
||||||
|
// Run before geojs.Close, which waits for every request to be answered.
|
||||||
|
t.Cleanup(release)
|
||||||
|
|
||||||
return geojs.URL, func() []string {
|
return geojs.URL, func() []string {
|
||||||
asked.mu.Lock()
|
asked.mu.Lock()
|
||||||
defer asked.mu.Unlock()
|
defer asked.mu.Unlock()
|
||||||
|
|
||||||
return slices.Clone(asked.addrs)
|
return slices.Clone(asked.addrs)
|
||||||
|
}, release
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The AS numbers and names the stand-in for GeoJS gives fromDE and
|
||||||
|
// fromKP, as they are logged.
|
||||||
|
const (
|
||||||
|
asnDE = "AS64496"
|
||||||
|
asNameDE = "Example Net"
|
||||||
|
asnKP = "AS64511"
|
||||||
|
asNameKP = "Other Net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// geojsAnswer is an answer of GeoJS about one address, with the fields
|
||||||
|
// smallwebwaf reads.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // GeoJS's own names
|
||||||
|
type geojsAnswer struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
ASN int `json:"asn"`
|
||||||
|
ASName string `json:"organization_name"`
|
||||||
|
CountryCode string `json:"country_code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerAbout is what the stand-in for GeoJS answers about addr: for an
|
||||||
|
// address it cannot place, the AS number 64512 and the AS name Unknown
|
||||||
|
// with no country, as GeoJS does.
|
||||||
|
func answerAbout(addr string) geojsAnswer {
|
||||||
|
switch addr {
|
||||||
|
case fromDE:
|
||||||
|
return geojsAnswer{IP: addr, ASN: 64496, ASName: asNameDE, CountryCode: "DE"}
|
||||||
|
case fromKP:
|
||||||
|
return geojsAnswer{IP: addr, ASN: 64511, ASName: asNameKP, CountryCode: "KP"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return geojsAnswer{IP: addr, ASN: 64512, ASName: "Unknown"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,18 @@ func TestHealthEndpointIsAnsweredBeforeAnyCheck(t *testing.T) {
|
|||||||
// the last one would have it refused.
|
// the last one would have it refused.
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{rateLimitPerMinute: "1"})
|
addr, out := startProxy(t, app.URL, map[string]string{rateLimitPerMinute: "1"})
|
||||||
|
|
||||||
const healthChecks = 3
|
const (
|
||||||
|
healthChecks = 3
|
||||||
|
contentType = "text/plain; charset=utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
for range healthChecks {
|
for range healthChecks {
|
||||||
got := get(t, addr, proxy.HealthPath)
|
got := get(t, addr, proxy.HealthPath)
|
||||||
wantStatus(t, got, http.StatusOK)
|
wantStatus(t, got, http.StatusOK)
|
||||||
|
|
||||||
if string(got.body) != "ok\n" {
|
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
|
||||||
t.Errorf("health endpoint answered %q, want ok", got.body)
|
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
|
||||||
|
"with %q", got.body, got.header.Get("Content-Type"), contentType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +41,11 @@ func TestHealthEndpointIsAnsweredBeforeAnyCheck(t *testing.T) {
|
|||||||
lines := out.requestLines(t, healthChecks+1)
|
lines := out.requestLines(t, healthChecks+1)
|
||||||
for _, line := range lines[:healthChecks] {
|
for _, line := range lines[:healthChecks] {
|
||||||
wantLine(t, line, http.StatusOK, requestlog.ActionAdmin)
|
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)
|
wantLine(t, lines[healthChecks], http.StatusOK, requestlog.ActionForward)
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
// Two let through, one over the limit, which bans the client, and one
|
// Two let through, one over the limit, which bans the client, and one
|
||||||
// refused under that ban, for which the country is not looked up.
|
// refused under that ban, for which the client is not looked up. GeoJS
|
||||||
|
// answers about the client at its first request, and its later ones
|
||||||
|
// use that answer.
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
clk.advance(time.Second)
|
clk.advance(time.Second)
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
@@ -35,8 +37,10 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
want := ratelimit.History{
|
want := ratelimit.History{
|
||||||
FirstSeen: start,
|
FirstSeen: start,
|
||||||
LastSeen: start.Add(2 * time.Second),
|
LastSeen: start.Add(2 * time.Second),
|
||||||
|
ASN: asnDE,
|
||||||
|
ASName: asNameDE,
|
||||||
Country: "DE",
|
Country: "DE",
|
||||||
LookedUp: start.Add(time.Second),
|
LookedUp: start,
|
||||||
Requests: 4,
|
Requests: 4,
|
||||||
Forwarded: 2,
|
Forwarded: 2,
|
||||||
Refused: 2,
|
Refused: 2,
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The headers in which the app is passed the client's AS number and
|
||||||
|
// country while SWWAF_ADD_LOOKUP_HEADERS is set. Go writes every header
|
||||||
|
// name in this form, as it sends it and as it receives it, so X-Client-ASN
|
||||||
|
// arrives as X-Client-Asn, and Del removes a client's own whatever their
|
||||||
|
// case; header names are not case-sensitive.
|
||||||
|
const (
|
||||||
|
asnHeader = "X-Client-Asn"
|
||||||
|
countryHeader = "X-Client-Country"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lookUp looks up the client's AS number and country, in the lookup
|
||||||
|
// database or through GeoJS, and notes them for the log line, unless
|
||||||
|
// SWWAF_LOOKUP_SOURCE is off or the client is on a private, loopback or
|
||||||
|
// link-local address, which no lookup can place. The lookup database
|
||||||
|
// answers at once. With GeoJS, while a setting needs the answer, such as a
|
||||||
|
// country list or a biased threshold, a new client's request waits for it.
|
||||||
|
// ctx is the request's own context.
|
||||||
|
func (rq *request) lookUp(ctx context.Context) {
|
||||||
|
if rq.h.config.LookupSource == "off" || !canBePlaced(rq.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rq.h.config.LookupSource == "file" {
|
||||||
|
rq.lookupAnswer = rq.h.lookupFile.LookUp(clientGroup(rq.client))
|
||||||
|
} else {
|
||||||
|
rq.lookupAnswer = rq.h.geojs.LookUp(ctx, clientGroup(rq.client))
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.lookedUp = true
|
||||||
|
rq.line.ASN = rq.lookupAnswer.ASN
|
||||||
|
rq.line.ASName = rq.lookupAnswer.ASName
|
||||||
|
rq.line.Country = rq.lookupAnswer.Country
|
||||||
|
}
|
||||||
|
|
||||||
|
// addLookup adds answer, an answer about a client from the lookup
|
||||||
|
// database or GeoJS, to the client's history, and to the notes of the bans
|
||||||
|
// on its netblock that have no AS number, AS name or country yet.
|
||||||
|
func (h *handler) addLookup(answer lookup.Answer) {
|
||||||
|
h.limiter.AddLookup(answer.Client, answer.Answered,
|
||||||
|
answer.ASN, answer.ASName, answer.Country)
|
||||||
|
h.ledger.AddLookup(h.netblock(answer.Client.Addr()),
|
||||||
|
answer.ASN, answer.ASName, answer.Country)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setLookupHeaders sets the headers in which the app is passed the
|
||||||
|
// client's AS number and country, leaving out one that is unknown.
|
||||||
|
func setLookupHeaders(header http.Header, asn, country string) {
|
||||||
|
if asn != "" {
|
||||||
|
header.Set(asnHeader, asn)
|
||||||
|
}
|
||||||
|
|
||||||
|
if country != "" {
|
||||||
|
header.Set(countryHeader, country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// canBePlaced reports whether a lookup can place addr: private, loopback
|
||||||
|
// and link-local addresses have no AS number or country.
|
||||||
|
func canBePlaced(addr netip.Addr) bool {
|
||||||
|
return !addr.IsPrivate() && !addr.IsLoopback() && !addr.IsLinkLocalUnicast()
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
package proxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// asnAndCountry is what a lookup gives a client: its AS number, AS name
|
||||||
|
// and country.
|
||||||
|
type asnAndCountry struct{ asn, asName, country string }
|
||||||
|
|
||||||
|
// fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the
|
||||||
|
// lookup database.
|
||||||
|
const fileSource = "file"
|
||||||
|
|
||||||
|
func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The stand-in for GeoJS answers only once released. A request that
|
||||||
|
// waited for it would wait an hour, and get no answer within
|
||||||
|
// waitLimit.
|
||||||
|
geojsURL, asked, release := startHeldGeoJS(t)
|
||||||
|
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
|
lookupTimeout: "1h",
|
||||||
|
rateLimitPerMinute: "1",
|
||||||
|
})
|
||||||
|
|
||||||
|
// fromDE's second request breaks the limit and bans it, and fromKP
|
||||||
|
// comes too. None waits for GeoJS.
|
||||||
|
for _, line := range []logLine{
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited),
|
||||||
|
s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
||||||
|
} {
|
||||||
|
got := asnAndCountry{line.ASN, line.ASName, line.Country}
|
||||||
|
if got != (asnAndCountry{}) {
|
||||||
|
t.Errorf("log line has %+v before GeoJS answered, want nothing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once GeoJS answers, each answer reaches the client's history, and
|
||||||
|
// fromDE's reaches the notes of its ban.
|
||||||
|
release()
|
||||||
|
|
||||||
|
netblock := netip.MustParsePrefix(fromDE + "/32")
|
||||||
|
|
||||||
|
waitUntil(func() bool {
|
||||||
|
return historyOf(t, server, fromDE).ASN != "" &&
|
||||||
|
historyOf(t, server, fromKP).ASN != "" &&
|
||||||
|
server.Ledger.Bans(netblock)[0].Notes.ASN != ""
|
||||||
|
})
|
||||||
|
|
||||||
|
de := asnAndCountry{asnDE, asNameDE, "DE"}
|
||||||
|
|
||||||
|
for addr, want := range map[string]asnAndCountry{
|
||||||
|
fromDE: de, fromKP: {asnKP, asNameKP, "KP"},
|
||||||
|
} {
|
||||||
|
h := historyOf(t, server, addr)
|
||||||
|
if got := (asnAndCountry{h.ASN, h.ASName, h.Country}); got != want {
|
||||||
|
t.Errorf("%s's history has %+v, want %+v", addr, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notes := server.Ledger.Bans(netblock)[0].Notes
|
||||||
|
if got := (asnAndCountry{notes.ASN, notes.ASName, notes.Country}); got != de {
|
||||||
|
t.Errorf("the ban's notes have %+v, want %+v", got, de)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeoJS was asked about each client once, fromKP after fromDE, whose
|
||||||
|
// request was under way when fromKP came.
|
||||||
|
if got := asked(); !slices.Equal(got, []string{fromDE, fromKP}) {
|
||||||
|
t.Errorf("GeoJS was asked about %v, want %s and %s", got, fromDE, fromKP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
|
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||||
|
addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now,
|
||||||
|
map[string]string{
|
||||||
|
trustedProxies: trustLocalhost,
|
||||||
|
alertWebhookURL: "https://alerts.example/smallwebwaf",
|
||||||
|
rateLimitPerMinute: "1",
|
||||||
|
})
|
||||||
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
|
|
||||||
|
// The answer is kept before the requests, so GeoJS is not asked, and
|
||||||
|
// gives no answer of its own.
|
||||||
|
netblock := netip.MustParsePrefix(fromDE + "/32")
|
||||||
|
server.GeoJS.Load([]lookup.Answer{{
|
||||||
|
Client: netblock, ASN: asnDE, ASName: asNameDE, Country: "DE",
|
||||||
|
Answered: clk.Now(), Used: clk.Now(),
|
||||||
|
}})
|
||||||
|
|
||||||
|
want := asnAndCountry{asnDE, asNameDE, "DE"}
|
||||||
|
|
||||||
|
for _, line := range []logLine{
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited),
|
||||||
|
} {
|
||||||
|
if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want {
|
||||||
|
t.Errorf("log line has %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h := historyOf(t, server, fromDE)
|
||||||
|
if got := (asnAndCountry{h.ASN, h.ASName, h.Country}); got != want ||
|
||||||
|
!h.LookedUp.Equal(clk.Now()) {
|
||||||
|
t.Errorf("history has %+v, looked up at %s; want %+v, at %s",
|
||||||
|
got, h.LookedUp, want, clk.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
notes := server.Ledger.Bans(netblock)[0].Notes
|
||||||
|
if got := (asnAndCountry{notes.ASN, notes.ASName, notes.Country}); got != want {
|
||||||
|
t.Errorf("the ban's notes have %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 1 {
|
||||||
|
t.Fatalf("alerts waiting %+v, want the ban's alone", waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
alert := waiting[0]
|
||||||
|
if got := (asnAndCountry{alert.ASN, alert.ASName, alert.Country}); got != want {
|
||||||
|
t.Errorf("the ban's alert has %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupSourceOffLooksNoClientUp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
geojsURL, asked := startGeoJS(t)
|
||||||
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{lookupSource: "off"})
|
||||||
|
|
||||||
|
// Even an answer kept from before is not used.
|
||||||
|
server.GeoJS.Load([]lookup.Answer{{
|
||||||
|
Client: netip.MustParsePrefix(fromDE + "/32"), ASN: asnDE, ASName: asNameDE,
|
||||||
|
Country: "DE", Answered: clk.Now(), Used: clk.Now(),
|
||||||
|
}})
|
||||||
|
|
||||||
|
for _, from := range []string{fromDE, fromKP} {
|
||||||
|
line := s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
got := asnAndCountry{line.ASN, line.ASName, line.Country}
|
||||||
|
if got != (asnAndCountry{}) {
|
||||||
|
t.Errorf("log line has %+v, want nothing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if h := historyOf(t, server, fromDE); h.ASN != "" || !h.LookedUp.IsZero() {
|
||||||
|
t.Errorf("history has %q, looked up at %s, want no lookup", h.ASN, h.LookedUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(asked()) != 0 {
|
||||||
|
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientsAreLookedUpInTheLookupDatabaseAndGeoJSIsNotAsked(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
geojsURL, asked := startGeoJS(t)
|
||||||
|
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||||
|
lookuptest.Write(t, path, map[string]lookuptest.Network{
|
||||||
|
fromDE + "/32": {ASN: asnDE, ASName: asNameDE, Country: "DE"},
|
||||||
|
fromKP + "/32": {ASN: asnKP, ASName: asNameKP, Country: "KP"},
|
||||||
|
})
|
||||||
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
|
lookupSource: fileSource,
|
||||||
|
lookupDBPath: path,
|
||||||
|
allowedCountries: "DE",
|
||||||
|
rateLimitPerMinute: "1",
|
||||||
|
})
|
||||||
|
|
||||||
|
// fromDE's second request breaks the limit and bans it. The list
|
||||||
|
// refuses fromKP, and unplaced, which the file does not hold.
|
||||||
|
de := asnAndCountry{asnDE, asNameDE, "DE"}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
line logLine
|
||||||
|
want asnAndCountry
|
||||||
|
}{
|
||||||
|
{s.get(fromDE, http.StatusOK, requestlog.ActionForward), de},
|
||||||
|
{s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited), de},
|
||||||
|
{
|
||||||
|
s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
||||||
|
asnAndCountry{asnKP, asNameKP, "KP"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied),
|
||||||
|
asnAndCountry{},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
got := asnAndCountry{tc.line.ASN, tc.line.ASName, tc.line.Country}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("log line has %+v, want %+v", got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h := historyOf(t, server, fromDE)
|
||||||
|
if got := (asnAndCountry{h.ASN, h.ASName, h.Country}); got != de ||
|
||||||
|
!h.LookedUp.Equal(clk.Now()) {
|
||||||
|
t.Errorf("history has %+v, looked up at %s; want %+v, at %s",
|
||||||
|
got, h.LookedUp, de, clk.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
notes := server.Ledger.Bans(netip.MustParsePrefix(fromDE + "/32"))[0].Notes
|
||||||
|
if got := (asnAndCountry{notes.ASN, notes.ASName, notes.Country}); got != de {
|
||||||
|
t.Errorf("the ban's notes have %+v, want %+v", got, de)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(asked()) != 0 {
|
||||||
|
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
got [][2][]string // each request's X-Client-ASN and X-Client-Country
|
||||||
|
)
|
||||||
|
|
||||||
|
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
got = append(got, [2][]string{
|
||||||
|
r.Header.Values("X-Client-Asn"), r.Header.Values("X-Client-Country"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{
|
||||||
|
trustedProxies: trustLocalhost,
|
||||||
|
addLookupHeaders: "true",
|
||||||
|
})
|
||||||
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
|
|
||||||
|
// Each client sends headers of its own. fromDE's first request waits
|
||||||
|
// for its answer, which the app is passed; unplaced has none to pass,
|
||||||
|
// and a client on a private address is not looked up.
|
||||||
|
for _, from := range []string{fromDE, unplaced, "10.0.0.8"} {
|
||||||
|
s.requestWithHeader(from, "/", clientsOwnLookupHeaders,
|
||||||
|
http.StatusOK, requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
want := [][2][]string{{{asnDE}, {"DE"}}, {nil, nil}, {nil, nil}}
|
||||||
|
if !slices.EqualFunc(got, want, func(a, b [2][]string) bool {
|
||||||
|
return slices.Equal(a[0], b[0]) && slices.Equal(a[1], b[1])
|
||||||
|
}) {
|
||||||
|
t.Errorf("the app was passed %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
asn, country []string
|
||||||
|
)
|
||||||
|
|
||||||
|
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
asn, country = r.Header.Values("X-Client-Asn"), r.Header.Values("X-Client-Country")
|
||||||
|
})
|
||||||
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{
|
||||||
|
trustedProxies: trustLocalhost,
|
||||||
|
})
|
||||||
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
|
|
||||||
|
s.requestWithHeader(fromDE, "/", clientsOwnLookupHeaders,
|
||||||
|
http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if asn != nil || country != nil {
|
||||||
|
t.Errorf("the app was passed X-Client-ASN %v and X-Client-Country %v, want neither",
|
||||||
|
asn, country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The test runs in a synctest bubble, where the time package runs on a
|
||||||
|
// clock of the test's own: the wait lasts exactly as long as it should,
|
||||||
|
// however slowly the test process runs. Nothing in it may wait on the
|
||||||
|
// network, which would keep that clock from moving on: the request is
|
||||||
|
// handed to the proxy's handler, and GeoJS is one that never answers.
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
// Not the default second. The exclusive list needs the answer, and
|
||||||
|
// the app is never reached.
|
||||||
|
const timeout = 3 * time.Second
|
||||||
|
|
||||||
|
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
|
||||||
|
time.Now, map[string]string{
|
||||||
|
lookupTimeout: timeout.String(),
|
||||||
|
allowedCountries: "DE",
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/",
|
||||||
|
http.NoBody)
|
||||||
|
req.RemoteAddr = net.JoinHostPort(fromDE, "1234")
|
||||||
|
began := time.Now()
|
||||||
|
|
||||||
|
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
if waited := time.Since(began); waited != timeout {
|
||||||
|
t.Errorf("the request waited %s for its answer, want %s", waited, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without an answer, the client is in no country the list allows.
|
||||||
|
wantLine(t, out.requestLine(t), http.StatusForbidden,
|
||||||
|
requestlog.ActionCountryDenied)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// unansweredGeoJSURL is where a GeoJS that never answers is asked: a
|
||||||
|
// request to it waits, without the network, until it is abandoned.
|
||||||
|
// TestMain registers it with Go's default transport, through which GeoJS
|
||||||
|
// is asked.
|
||||||
|
const unansweredGeoJSURL = "unanswered://geojs/v1/ip/geo.json"
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
transport, _ := http.DefaultTransport.(*http.Transport)
|
||||||
|
transport.RegisterProtocol("unanswered", unansweredGeoJS{})
|
||||||
|
transport.RegisterProtocol("abuseipdb", abuseIPDBStandIn{})
|
||||||
|
|
||||||
|
m.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// unansweredGeoJS is the GeoJS at unansweredGeoJSURL.
|
||||||
|
type unansweredGeoJS struct{}
|
||||||
|
|
||||||
|
// RoundTrip waits until req is abandoned.
|
||||||
|
func (unansweredGeoJS) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
<-req.Context().Done()
|
||||||
|
|
||||||
|
return nil, req.Context().Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientsOwnLookupHeaders are the X-Client-ASN and X-Client-Country a
|
||||||
|
// client sends of its own, each twice, in two cases.
|
||||||
|
const clientsOwnLookupHeaders = "X-Client-ASN: AS1\r\nx-client-asn: AS2\r\n" +
|
||||||
|
"X-CLIENT-COUNTRY: KP\r\nx-client-country: CN"
|
||||||
|
|
||||||
|
// waitUntil waits until done reports true, for at most waitLimit.
|
||||||
|
func waitUntil(done func() bool) {
|
||||||
|
deadline := time.Now().Add(waitLimit)
|
||||||
|
for !done() && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
+134
-44
@@ -12,6 +12,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
@@ -147,8 +148,8 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
||||||
out.requestLines(t, 2)
|
out.requestLines(t, 2)
|
||||||
|
|
||||||
forward := `{action="forward",status_class="2xx"}`
|
forward := `{action="forward",instance="app",status_class="2xx"}`
|
||||||
notFound := `{action="admin",status_class="4xx"}`
|
notFound := `{action="admin",instance="app",status_class="4xx"}`
|
||||||
|
|
||||||
// The request for the metrics is itself under way.
|
// The request for the metrics is itself under way.
|
||||||
metrics := scrape(t, addr)
|
metrics := scrape(t, addr)
|
||||||
@@ -158,11 +159,13 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+forward, 5)
|
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+forward, 5)
|
||||||
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+notFound,
|
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+notFound,
|
||||||
float64(len("Not Found\n")))
|
float64(len("Not Found\n")))
|
||||||
wantMetric(t, metrics, "smallwebwaf_request_duration_seconds_count", 2)
|
wantMetric(t, metrics,
|
||||||
wantMetric(t, metrics, "smallwebwaf_upstream_duration_seconds_count", 1)
|
`smallwebwaf_request_duration_seconds_count{instance="app"}`, 2)
|
||||||
wantMetric(t, metrics, "smallwebwaf_requests_in_flight", 1)
|
wantMetric(t, metrics,
|
||||||
metric(t, metrics, "go_goroutines")
|
`smallwebwaf_upstream_duration_seconds_count{instance="app"}`, 1)
|
||||||
metric(t, metrics, "process_start_time_seconds")
|
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.
|
// A request the app holds is under way until it ends.
|
||||||
httpClient := newClient(t)
|
httpClient := newClient(t)
|
||||||
@@ -179,7 +182,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
<-arrived
|
<-arrived
|
||||||
wantMetric(t, scrape(t, addr), "smallwebwaf_requests_in_flight", 2)
|
wantMetric(t, scrape(t, addr), `smallwebwaf_requests_in_flight{instance="app"}`, 2)
|
||||||
releaseApp()
|
releaseApp()
|
||||||
|
|
||||||
err := <-ended
|
err := <-ended
|
||||||
@@ -188,7 +191,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out.requestLines(t, 5)
|
out.requestLines(t, 5)
|
||||||
wantMetric(t, scrape(t, addr), "smallwebwaf_requests_in_flight", 1)
|
wantMetric(t, scrape(t, addr), `smallwebwaf_requests_in_flight{instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
||||||
@@ -218,15 +221,16 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
|
|
||||||
metrics := s.scrape(scraper)
|
metrics := s.scrape(scraper)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_requests_total{action="denied",status_class="none"}`, 1)
|
`smallwebwaf_requests_total{action="denied",instance="app",status_class="none"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{window="minute"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{kind="limit"}`, 1)
|
`kind="requests",window="minute"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_active_bans", 1)
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_permanent_bans", 0)
|
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
||||||
|
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 0)
|
||||||
|
|
||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
wantMetric(t, s.scrape(scraper), "smallwebwaf_active_bans", 0)
|
wantMetric(t, s.scrape(scraper), `smallwebwaf_active_bans{instance="app"}`, 0)
|
||||||
|
|
||||||
// A limit broken again right after would ban for three hours, longer
|
// A limit broken again right after would ban for three hours, longer
|
||||||
// than SWWAF_MAX_BAN_DURATION, so the ban is permanent.
|
// than SWWAF_MAX_BAN_DURATION, so the ban is permanent.
|
||||||
@@ -234,13 +238,42 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
s.get(client, 0, requestlog.ActionRateLimited)
|
s.get(client, 0, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
metrics = s.scrape(scraper)
|
metrics = s.scrape(scraper)
|
||||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{window="minute"}`, 2)
|
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{kind="limit"}`, 2)
|
`kind="requests",window="minute"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit"}`, 2)
|
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 2)
|
||||||
wantMetric(t, metrics, "smallwebwaf_active_bans", 1)
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics, "smallwebwaf_permanent_bans", 1)
|
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.
|
// denied, client, and the scraper as of its earlier requests.
|
||||||
wantMetric(t, metrics, "smallwebwaf_tracked_clients", 3)
|
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) {
|
func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
||||||
@@ -258,7 +291,8 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
metricsTopN: "2",
|
metricsTopN: "2",
|
||||||
deniedCountries: "kp",
|
deniedCountries: "kp",
|
||||||
}
|
}
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, env)
|
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
|
// The answers are kept before the requests, so that none waits for
|
||||||
// GeoJS.
|
// GeoJS.
|
||||||
@@ -290,28 +324,82 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
metrics := scrape(t, addr)
|
metrics := scrape(t, addr)
|
||||||
lines++
|
lines++
|
||||||
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="KP"}`, 3)
|
wantMetric(t, metrics,
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="DE"}`, 2)
|
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="other"}`, 1)
|
wantMetric(t, metrics,
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_list_refusals_total{country="KP"}`, 3)
|
`smallwebwaf_country_requests_total{country="DE",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_request_bytes_total{country="KP"}`, 0)
|
wantMetric(t, metrics,
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_request_bytes_total{country="DE"}`, 6)
|
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_response_bytes_total{country="KP"}`,
|
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")))
|
float64(3*len("Forbidden\n")))
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_response_bytes_total{country="other"}`,
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_country_response_bytes_total{country="other",instance="app"}`,
|
||||||
float64(len("hello")))
|
float64(len("hello")))
|
||||||
wantNoSeries(t, metrics, `smallwebwaf_country_requests_total{country="FR"}`)
|
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
|
// Once FR is busier than DE, it takes DE's place: its series counts
|
||||||
// from then on, and DE's is gone.
|
// from then on, and DE's is gone.
|
||||||
send(fromFR, 3, http.StatusOK)
|
send(fromFR, 3, http.StatusOK)
|
||||||
|
|
||||||
metrics = scrape(t, addr)
|
metrics = scrape(t, addr)
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="KP"}`, 3)
|
wantMetric(t, metrics,
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="FR"}`, 2)
|
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="other"}`, 2)
|
wantMetric(t, metrics,
|
||||||
wantNoSeries(t, metrics, `smallwebwaf_country_requests_total{country="DE"}`)
|
`smallwebwaf_country_requests_total{country="FR",instance="app"}`, 2)
|
||||||
wantNoSeries(t, metrics, `smallwebwaf_country_request_bytes_total{country="DE"}`)
|
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) {
|
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
||||||
@@ -341,16 +429,16 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
|||||||
deadline := time.Now().Add(waitLimit)
|
deadline := time.Now().Add(waitLimit)
|
||||||
metrics := scrape(t, addr)
|
metrics := scrape(t, addr)
|
||||||
|
|
||||||
for metric(t, metrics, "smallwebwaf_geojs_failures_total") == 0 &&
|
for metric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`) == 0 &&
|
||||||
time.Now().Before(deadline) {
|
time.Now().Before(deadline) {
|
||||||
time.Sleep(pollInterval)
|
time.Sleep(pollInterval)
|
||||||
|
|
||||||
metrics = scrape(t, addr)
|
metrics = scrape(t, addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_geojs_requests_total", 1)
|
wantMetric(t, metrics, `smallwebwaf_geojs_requests_total{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_geojs_failures_total", 1)
|
wantMetric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_geojs_unanswered_total", 1)
|
wantMetric(t, metrics, `smallwebwaf_geojs_unanswered_total{instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// keptAnswer returns GeoJS's answer that the client at addr is in
|
// keptAnswer returns GeoJS's answer that the client at addr is in
|
||||||
@@ -393,8 +481,9 @@ func (s *sender) scrape(from string) string {
|
|||||||
|
|
||||||
// metric returns the value of series in metrics, which are in the
|
// metric returns the value of series in metrics, which are in the
|
||||||
// Prometheus text format. series is a name and its labels in the order of
|
// Prometheus text format. series is a name and its labels in the order of
|
||||||
// their names, such as smallwebwaf_offences_total{kind="limit"}. It fails
|
// their names, such as
|
||||||
// the test if there is no such series.
|
// 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 {
|
func metric(t *testing.T, metrics, series string) float64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -442,7 +531,8 @@ func wantNoSeries(t *testing.T, metrics, series string) {
|
|||||||
func wantLimitHits(t *testing.T, addr, limit string, hits int) {
|
func wantLimitHits(t *testing.T, addr, limit string, hits int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
series := `smallwebwaf_size_and_time_limit_hits_total{limit="` + limit + `"}`
|
series := `smallwebwaf_size_and_time_limit_hits_total{instance="app",limit="` +
|
||||||
|
limit + `"}`
|
||||||
metrics := scrape(t, addr)
|
metrics := scrape(t, addr)
|
||||||
|
|
||||||
if hits == 0 {
|
if hits == 0 {
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package proxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// observe is the value of SWWAF_MODE for observe mode.
|
||||||
|
const observe = "observe"
|
||||||
|
|
||||||
|
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||||
|
banned = otherClient // under a ban read from bans.json
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
setting string // "" leaves SWWAF_MODE at its default
|
||||||
|
observe bool
|
||||||
|
}{
|
||||||
|
{"", false},
|
||||||
|
{"enforce", false},
|
||||||
|
{observe, true},
|
||||||
|
} {
|
||||||
|
t.Run(mode+"="+tc.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
env := map[string]string{
|
||||||
|
rateLimitPerMinute: "1",
|
||||||
|
denyNets: denied,
|
||||||
|
deniedCountries: "kp",
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.setting != "" {
|
||||||
|
env[mode] = tc.setting
|
||||||
|
}
|
||||||
|
|
||||||
|
s, clk, server := startWithClock(t, geojsURL, env)
|
||||||
|
server.Ledger.Load([]bans.Ban{{
|
||||||
|
Netblock: netip.MustParsePrefix(banned + "/32"),
|
||||||
|
Start: clk.Now(),
|
||||||
|
Expires: clk.Now().Add(time.Hour),
|
||||||
|
}})
|
||||||
|
|
||||||
|
// fromDE's first request is within the limit of one a minute,
|
||||||
|
// and its second breaks it.
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
for _, sent := range []struct{ from, refusal string }{
|
||||||
|
{denied, requestlog.ActionDenied},
|
||||||
|
{banned, requestlog.ActionBanned},
|
||||||
|
{fromKP, requestlog.ActionCountryDenied},
|
||||||
|
{fromDE, requestlog.ActionRateLimited},
|
||||||
|
} {
|
||||||
|
if !tc.observe {
|
||||||
|
line := s.get(sent.from, http.StatusForbidden, sent.refusal)
|
||||||
|
wantWouldAction(t, line, "")
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passed to the app, which answered it.
|
||||||
|
line := s.get(sent.from, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantWouldAction(t, line, sent.refusal)
|
||||||
|
|
||||||
|
if line.UpstreamStatus != http.StatusOK {
|
||||||
|
t.Errorf("log line has upstream_status %d, want 200",
|
||||||
|
line.UpstreamStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server := startWithClock(t, "", map[string]string{
|
||||||
|
mode: observe,
|
||||||
|
rateLimitPerMinute: "1",
|
||||||
|
})
|
||||||
|
kept := bans.Ban{
|
||||||
|
Netblock: netip.MustParsePrefix(otherClient + "/32"),
|
||||||
|
Start: clk.Now(),
|
||||||
|
Expires: clk.Now().Add(time.Hour),
|
||||||
|
Cause: bans.CauseAdmin,
|
||||||
|
}
|
||||||
|
server.Ledger.Load([]bans.Ban{kept})
|
||||||
|
|
||||||
|
// No ban sets client's counters back to zero, so each request after
|
||||||
|
// the first breaks the limit of one a minute.
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantWouldAction(t, line, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
|
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
||||||
|
line.BanExpires != "" {
|
||||||
|
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
||||||
|
"want minute, limit and none", line.LimitHit, line.Offence,
|
||||||
|
line.BanExpires)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ban read from bans.json refuses nothing, and so counts no
|
||||||
|
// refusal in its notes, but is kept.
|
||||||
|
line := s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||||
|
|
||||||
|
if line.BanExpires != requestlog.FormatTime(kept.Expires) {
|
||||||
|
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires,
|
||||||
|
requestlog.FormatTime(kept.Expires))
|
||||||
|
}
|
||||||
|
|
||||||
|
got := server.Ledger.Snapshot()
|
||||||
|
if len(got) != 1 || got[0] != kept {
|
||||||
|
t.Errorf("bans\n%+v\nwant only\n%+v", got, kept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// line that should have none has no such field.
|
||||||
|
func wantWouldAction(t *testing.T, line logLine, want string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got, present := line.fields["would_action"]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case want == "" && present:
|
||||||
|
t.Errorf("log line has would_action %v, want none", got)
|
||||||
|
case want != "" && got != want:
|
||||||
|
t.Errorf("log line has would_action %v, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -115,27 +117,37 @@ func wantAnswer(t *testing.T, got answer, body []byte) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantRequestFields checks the log line's fields about the request.
|
// 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) {
|
func wantRequestFields(t *testing.T, line logLine, host string, sent, received int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
want := requestlog.Line{
|
bytes := float64(sent + received)
|
||||||
Type: "request", Time: line.Time, ClientIP: localhost, PeerIP: localhost,
|
|
||||||
Method: http.MethodPatch, Host: host, Path: rawPath, Query: rawQuery,
|
want := withTimings(line, requestlog.Line{
|
||||||
Protocol: "HTTP/1.1", Status: http.StatusTeapot,
|
Type: requestType, Time: line.Time, Instance: "app",
|
||||||
UpstreamStatus: http.StatusTeapot, RequestBytes: int64(sent),
|
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",
|
ResponseBytes: int64(received), UserAgent: "test-agent",
|
||||||
Action: requestlog.ActionForward, DurationTotal: line.DurationTotal,
|
RequestID: line.RequestID, PeerIP: localhost, ClientGroup: localhost + "/32",
|
||||||
DurationUpstreamTotal: line.DurationUpstreamTotal,
|
ContentLength: int64(sent), ResponseContentType: "text/plain; charset=utf-8",
|
||||||
}
|
UpstreamStatus: http.StatusTeapot, Action: requestlog.ActionForward,
|
||||||
if line.Line != want {
|
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)
|
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := time.Parse(time.RFC3339, line.Time)
|
_, err := time.Parse(time.RFC3339, line.Time)
|
||||||
if err != nil || line.DurationTotal <= 0 || line.DurationUpstreamTotal <= 0 {
|
if err != nil || line.RequestID == "" || line.DurationTotal <= 0 ||
|
||||||
t.Errorf("log line has time %q and durations %v and %v",
|
line.DurationUpstreamTotal == nil || *line.DurationUpstreamTotal <= 0 {
|
||||||
line.Time, line.DurationTotal, line.DurationUpstreamTotal)
|
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"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +320,7 @@ func TestServerHasTheDefaultLimits(t *testing.T) {
|
|||||||
server := proxy.New(proxy.Params{
|
server := proxy.New(proxy.Params{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
RequestLog: io.Discard,
|
RequestLog: io.Discard,
|
||||||
ProcessLog: requestlog.NewProcessLogger(io.Discard),
|
ProcessLog: requestlog.NewProcessLogger(io.Discard, cfg.InstanceName),
|
||||||
})
|
})
|
||||||
|
|
||||||
if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 ||
|
if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 ||
|
||||||
@@ -371,8 +383,13 @@ func TestAnswers502WhenTheAppCannotBeReached(t *testing.T) {
|
|||||||
addr, out := startProxy(t, "http://"+localhost+":1", nil)
|
addr, out := startProxy(t, "http://"+localhost+":1", nil)
|
||||||
|
|
||||||
wantStatus(t, get(t, addr, "/"), http.StatusBadGateway)
|
wantStatus(t, get(t, addr, "/"), http.StatusBadGateway)
|
||||||
wantLine(t, out.requestLine(t), http.StatusBadGateway,
|
|
||||||
requestlog.ActionUpstreamError)
|
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 {
|
logged := slices.ContainsFunc(out.lines(t), func(line map[string]any) bool {
|
||||||
return line["type"] == "process" && line["msg"] == "request to the app failed"
|
return line["type"] == "process" && line["msg"] == "request to the app failed"
|
||||||
|
|||||||
+130
-7
@@ -11,12 +11,16 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// How smallwebwaf keeps connections to the app open between requests.
|
// How smallwebwaf keeps connections to the app open between requests.
|
||||||
@@ -37,6 +41,14 @@ const HealthPath = "/_smallwebwaf/healthz"
|
|||||||
// SWWAF_METRICS_TOKEN.
|
// SWWAF_METRICS_TOKEN.
|
||||||
const MetricsPath = "/_smallwebwaf/metrics"
|
const MetricsPath = "/_smallwebwaf/metrics"
|
||||||
|
|
||||||
|
// BansPath is where an admin lists and adds bans, and, followed by / and
|
||||||
|
// a client's address, lifts them, with SWWAF_ADMIN_TOKEN.
|
||||||
|
const BansPath = "/_smallwebwaf/bans"
|
||||||
|
|
||||||
|
// ClientsPath is where an admin asks what smallwebwaf knows of a client,
|
||||||
|
// by the client's address after it, with SWWAF_ADMIN_TOKEN.
|
||||||
|
const ClientsPath = "/_smallwebwaf/clients/"
|
||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
@@ -44,23 +56,46 @@ type Params struct {
|
|||||||
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' countries are looked up, normally
|
// GeoJSURL is where clients' AS numbers and countries are looked up
|
||||||
// lookup.URL. GeoJS is asked only while a country list is set.
|
// while SWWAF_LOOKUP_SOURCE is geojs, normally lookup.URL.
|
||||||
GeoJSURL string
|
GeoJSURL string
|
||||||
|
// AbuseIPDBURL is where clients are checked with AbuseIPDB while
|
||||||
|
// SWWAF_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL.
|
||||||
|
AbuseIPDBURL string
|
||||||
|
// LookupFile is the lookup database they are looked up in while
|
||||||
|
// SWWAF_LOOKUP_SOURCE is file, and nil otherwise.
|
||||||
|
LookupFile *lookup.File
|
||||||
// Now tells the time by which requests are counted for the rate
|
// 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.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
|
// Rules are the rule files' rules, which each request is checked
|
||||||
|
// against.
|
||||||
|
Rules *rules.Files
|
||||||
|
// Alerts receive the alert for each ban the proxy makes or makes
|
||||||
|
// permanent, for each count over an anomaly threshold, for each request
|
||||||
|
// whose client a blocklist, a DNSBL zone or AbuseIPDB lists, and for
|
||||||
|
// GeoJS failing, a fetch of a list failing, a query to a DNSBL zone or
|
||||||
|
// a check with AbuseIPDB failing, or the day's AbuseIPDB checks used up.
|
||||||
|
Alerts *alerts.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the server smallwebwaf runs, with the parts of the proxy
|
// Server is the server smallwebwaf runs, with the parts of the proxy
|
||||||
// whose state the state files keep, and the metrics.
|
// whose state the state files keep, the lookup database, nil unless
|
||||||
|
// SWWAF_LOOKUP_SOURCE is file, the lists fetched from URLs, which its Run
|
||||||
|
// fetches, the DNSBL zones' verdicts, AbuseIPDB's scores and checks
|
||||||
|
// spent, and the metrics.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
*http.Server
|
*http.Server
|
||||||
|
|
||||||
Ledger *bans.Ledger
|
Ledger *bans.Ledger
|
||||||
Limiter *ratelimit.Limiter
|
Limiter *ratelimit.Limiter
|
||||||
GeoJS *lookup.GeoJS
|
GeoJS *lookup.GeoJS
|
||||||
|
Anomalies *anomaly.Counters
|
||||||
|
LookupFile *lookup.File
|
||||||
|
Lists *reputation.Lists
|
||||||
|
DNSBL *reputation.DNSBL
|
||||||
|
AbuseIPDB *reputation.AbuseIPDB
|
||||||
Metrics *metrics.Metrics
|
Metrics *metrics.Metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +107,8 @@ type Server struct {
|
|||||||
// applies the timeouts and size limits from then on.
|
// applies the timeouts and size limits from then on.
|
||||||
func New(params Params) *Server {
|
func New(params Params) *Server {
|
||||||
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
||||||
m := metrics.New(params.Config.MetricsTopN)
|
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
|
||||||
|
lists, dnsbl, abuseIPDB := newReputation(params, m)
|
||||||
h := &handler{
|
h := &handler{
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
requestLog: params.RequestLog,
|
requestLog: params.RequestLog,
|
||||||
@@ -85,21 +121,51 @@ func New(params Params) *Server {
|
|||||||
PerMinute: params.Config.RateLimitPerMinute,
|
PerMinute: params.Config.RateLimitPerMinute,
|
||||||
PerHour: params.Config.RateLimitPerHour,
|
PerHour: params.Config.RateLimitPerHour,
|
||||||
PerDay: params.Config.RateLimitPerDay,
|
PerDay: params.Config.RateLimitPerDay,
|
||||||
|
BytesPerMinute: params.Config.BytesLimitPerMinute,
|
||||||
|
BytesPerHour: params.Config.BytesLimitPerHour,
|
||||||
|
BytesPerDay: params.Config.BytesLimitPerDay,
|
||||||
}),
|
}),
|
||||||
ledger: bans.New(bans.Rules{
|
ledger: bans.New(bans.Rules{
|
||||||
LimitBanDuration: params.Config.LimitBanDuration,
|
LimitBanDuration: params.Config.LimitBanDuration,
|
||||||
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
|
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
|
||||||
MaxBanDuration: params.Config.MaxBanDuration,
|
MaxBanDuration: params.Config.MaxBanDuration,
|
||||||
|
AttackBanDuration: params.Config.AttackBanDuration,
|
||||||
MaxBans: params.Config.MaxBans,
|
MaxBans: params.Config.MaxBans,
|
||||||
}),
|
}),
|
||||||
geojs: lookup.New(lookup.Params{
|
anomalies: anomaly.New(anomaly.Params{
|
||||||
|
Client: params.Config.AnomalyClient,
|
||||||
|
Net: params.Config.AnomalyNet,
|
||||||
|
ASN: params.Config.AnomalyASN,
|
||||||
|
Total: params.Config.AnomalyTotal,
|
||||||
|
Watch: params.Config.AnomalyWatch,
|
||||||
|
NetV4Prefix: params.Config.AnomalyNetV4Prefix,
|
||||||
|
NetV6Prefix: params.Config.AnomalyNetV6Prefix,
|
||||||
|
NamedNetblocks: params.Config.WatchNets,
|
||||||
|
Alerts: params.Alerts,
|
||||||
|
}),
|
||||||
|
lookupFile: params.LookupFile,
|
||||||
|
lists: lists,
|
||||||
|
dnsbl: dnsbl,
|
||||||
|
abuseIPDB: abuseIPDB,
|
||||||
|
rules: params.Rules,
|
||||||
|
alerts: params.Alerts,
|
||||||
|
}
|
||||||
|
h.geojs = lookup.New(lookup.Params{
|
||||||
URL: params.GeoJSURL,
|
URL: params.GeoJSURL,
|
||||||
|
Timeout: params.Config.LookupTimeout,
|
||||||
|
// The country lists, the headers and the biased thresholds act on
|
||||||
|
// the answer before the request goes on.
|
||||||
|
Wait: len(params.Config.DeniedCountries) > 0 ||
|
||||||
|
len(params.Config.ExclusivelyAllowedCountries) > 0 ||
|
||||||
|
params.Config.AddLookupHeaders || biasedThresholdsSet(params.Config),
|
||||||
|
Answered: h.addLookup,
|
||||||
Now: params.Now,
|
Now: params.Now,
|
||||||
ProcessLog: params.ProcessLog,
|
ProcessLog: params.ProcessLog,
|
||||||
Metrics: m,
|
Metrics: m,
|
||||||
}),
|
Alerts: params.Alerts,
|
||||||
}
|
})
|
||||||
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
||||||
|
m.AddRules(params.Rules)
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
Server: &http.Server{
|
Server: &http.Server{
|
||||||
@@ -117,10 +183,49 @@ func New(params Params) *Server {
|
|||||||
Ledger: h.ledger,
|
Ledger: h.ledger,
|
||||||
Limiter: h.limiter,
|
Limiter: h.limiter,
|
||||||
GeoJS: h.geojs,
|
GeoJS: h.geojs,
|
||||||
|
Anomalies: h.anomalies,
|
||||||
|
LookupFile: h.lookupFile,
|
||||||
|
Lists: h.lists,
|
||||||
|
DNSBL: h.dnsbl,
|
||||||
|
AbuseIPDB: h.abuseIPDB,
|
||||||
Metrics: m,
|
Metrics: m,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newReputation returns the lists fetched from URLs, the DNSBL zones'
|
||||||
|
// verdicts and AbuseIPDB's scores, as the settings in params name them,
|
||||||
|
// with none fetched, asked for or checked yet, and adds their metrics to
|
||||||
|
// m, AbuseIPDB's while SWWAF_ABUSEIPDB_KEY is set.
|
||||||
|
func newReputation(
|
||||||
|
params Params, m *metrics.Metrics,
|
||||||
|
) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) {
|
||||||
|
cfg := params.Config
|
||||||
|
lists := reputation.New(reputation.Params{
|
||||||
|
BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh,
|
||||||
|
ASNLimitPercentURL: cfg.ASNLimitPercentURL, Now: params.Now,
|
||||||
|
ProcessLog: params.ProcessLog, Alerts: params.Alerts,
|
||||||
|
})
|
||||||
|
dnsbl := reputation.NewDNSBL(reputation.DNSBLParams{
|
||||||
|
Zones: cfg.DNSBLZones, Resolver: cfg.DNSBLResolver, CacheTTL: cfg.ReputationCacheTTL,
|
||||||
|
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
|
||||||
|
Alerts: params.Alerts,
|
||||||
|
})
|
||||||
|
abuseIPDB := reputation.NewAbuseIPDB(reputation.AbuseIPDBParams{
|
||||||
|
URL: params.AbuseIPDBURL, Key: cfg.AbuseIPDBKey, MinScore: cfg.AbuseIPDBMinScore,
|
||||||
|
DailyBudget: cfg.AbuseIPDBDailyBudget, CacheTTL: cfg.ReputationCacheTTL,
|
||||||
|
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
|
||||||
|
Alerts: params.Alerts,
|
||||||
|
})
|
||||||
|
|
||||||
|
m.AddReputation(lists, dnsbl)
|
||||||
|
|
||||||
|
if cfg.AbuseIPDBKey != "" {
|
||||||
|
m.AddAbuseIPDB(abuseIPDB)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lists, dnsbl, abuseIPDB
|
||||||
|
}
|
||||||
|
|
||||||
// handler is the proxy. It holds what every request shares; what belongs
|
// handler is the proxy. It holds what every request shares; what belongs
|
||||||
// to one request is in a request.
|
// to one request is in a request.
|
||||||
type handler struct {
|
type handler struct {
|
||||||
@@ -134,6 +239,13 @@ type handler struct {
|
|||||||
limiter *ratelimit.Limiter
|
limiter *ratelimit.Limiter
|
||||||
ledger *bans.Ledger
|
ledger *bans.Ledger
|
||||||
geojs *lookup.GeoJS
|
geojs *lookup.GeoJS
|
||||||
|
anomalies *anomaly.Counters
|
||||||
|
lookupFile *lookup.File
|
||||||
|
lists *reputation.Lists
|
||||||
|
dnsbl *reputation.DNSBL
|
||||||
|
abuseIPDB *reputation.AbuseIPDB
|
||||||
|
rules *rules.Files
|
||||||
|
alerts *alerts.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTransport returns what carries requests to the app. It never goes
|
// newTransport returns what carries requests to the app. It never goes
|
||||||
@@ -160,6 +272,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// a health checker is never refused. It does not ask the app.
|
// a health checker is never refused. It does not ask the app.
|
||||||
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
|
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
|
||||||
rq.line.Action = requestlog.ActionAdmin
|
rq.line.Action = requestlog.ActionAdmin
|
||||||
|
// Set here rather than left to Go's server, which would set it only
|
||||||
|
// after the log line has taken the response's headers.
|
||||||
|
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
_, _ = io.WriteString(rq.out, "ok\n")
|
_, _ = io.WriteString(rq.out, "ok\n")
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -167,8 +282,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Once the request has ended, before its log line is written.
|
// Once the request has ended, before its log line is written.
|
||||||
defer rq.addToHistory()
|
defer rq.addToHistory()
|
||||||
|
defer rq.countAnomalies()
|
||||||
|
|
||||||
refused := rq.check(r.Context())
|
refused := rq.check(r.Context())
|
||||||
|
rq.checked = time.Now()
|
||||||
|
|
||||||
if refused != nil {
|
if refused != nil {
|
||||||
rq.answer(*refused)
|
rq.answer(*refused)
|
||||||
|
|
||||||
@@ -183,5 +301,10 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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())
|
rq.forward(r.Context())
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-22
@@ -14,9 +14,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -35,6 +38,10 @@ const (
|
|||||||
// localhost is where every test server listens, and so the address
|
// localhost is where every test server listens, and so the address
|
||||||
// smallwebwaf sees each test's requests come from.
|
// smallwebwaf 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 = "request"
|
||||||
|
// protocol is the protocol of every test's requests.
|
||||||
|
protocol = "HTTP/1.1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// shortTimeoutSetting is shortTimeout as a setting's value.
|
// shortTimeoutSetting is shortTimeout as a setting's value.
|
||||||
@@ -50,6 +57,7 @@ const (
|
|||||||
clientResponseTimeout = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
|
clientResponseTimeout = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
|
||||||
upstreamRequestTimeout = "SWWAF_UPSTREAM_REQUEST_TIMEOUT"
|
upstreamRequestTimeout = "SWWAF_UPSTREAM_REQUEST_TIMEOUT"
|
||||||
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT"
|
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT"
|
||||||
|
mode = "SWWAF_MODE"
|
||||||
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES"
|
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES"
|
||||||
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
|
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
|
||||||
trustedProxies = "SWWAF_TRUSTED_PROXIES"
|
trustedProxies = "SWWAF_TRUSTED_PROXIES"
|
||||||
@@ -58,6 +66,11 @@ const (
|
|||||||
denyNets = "SWWAF_DENY_NETS"
|
denyNets = "SWWAF_DENY_NETS"
|
||||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
||||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||||
|
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||||
|
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
||||||
|
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
||||||
|
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
||||||
|
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
||||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
||||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
banResponse = "SWWAF_BAN_RESPONSE"
|
banResponse = "SWWAF_BAN_RESPONSE"
|
||||||
@@ -66,6 +79,10 @@ const (
|
|||||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
||||||
maxBans = "SWWAF_MAX_BANS"
|
maxBans = "SWWAF_MAX_BANS"
|
||||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||||
|
instanceName = "SWWAF_INSTANCE_NAME"
|
||||||
|
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||||
|
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
||||||
|
rulesDir = "SWWAF_RULES_DIR"
|
||||||
)
|
)
|
||||||
|
|
||||||
// output collects what smallwebwaf writes on stdout.
|
// output collects what smallwebwaf writes on stdout.
|
||||||
@@ -82,6 +99,14 @@ func (o *output) Write(p []byte) (int, error) {
|
|||||||
return o.buf.Write(p)
|
return o.buf.Write(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// text returns everything written so far.
|
||||||
|
func (o *output) text() string {
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
|
||||||
|
return o.buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
// lines returns every line written so far, decoded.
|
// lines returns every line written so far, decoded.
|
||||||
func (o *output) lines(t *testing.T) []map[string]any {
|
func (o *output) lines(t *testing.T) []map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -121,7 +146,7 @@ func (o *output) requestLines(t *testing.T, count int) []logLine {
|
|||||||
var found []logLine
|
var found []logLine
|
||||||
|
|
||||||
for _, fields := range o.lines(t) {
|
for _, fields := range o.lines(t) {
|
||||||
if fields["type"] == "request" {
|
if fields["type"] == requestType {
|
||||||
found = append(found, decodeLine(t, fields))
|
found = append(found, decodeLine(t, fields))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,8 +208,8 @@ func startProxy(t *testing.T, appURL string, env map[string]string) (string, *ou
|
|||||||
return startProxyWithGeoJS(t, appURL, "", env)
|
return startProxyWithGeoJS(t, appURL, "", env)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startProxyWithGeoJS is startProxy with clients' countries looked up at
|
// startProxyWithGeoJS is startProxy with clients' AS numbers and
|
||||||
// geojsURL.
|
// countries looked up at geojsURL.
|
||||||
func startProxyWithGeoJS(
|
func startProxyWithGeoJS(
|
||||||
t *testing.T, appURL, geojsURL string, env map[string]string,
|
t *testing.T, appURL, geojsURL string, env map[string]string,
|
||||||
) (string, *output) {
|
) (string, *output) {
|
||||||
@@ -196,33 +221,30 @@ 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.
|
// bans made by the time now tells, and returns the server as well. Unless
|
||||||
|
// env sets SWWAF_RULES_DIR, it is an empty directory, of no rules, and
|
||||||
|
// unless it sets SWWAF_INSTANCE_NAME, that is app, 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, *proxy.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{"SWWAF_UPSTREAM_URL": appURL}
|
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
|
||||||
maps.Copy(settings, env)
|
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(func(name string) (string, bool) {
|
return addr, out, server
|
||||||
value, ok := settings[name]
|
|
||||||
|
|
||||||
return value, ok
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("settings %v: %v", settings, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out := &output{}
|
// startProxyWithAlerts is startProxyWithClock, and returns the queue of
|
||||||
server := proxy.New(proxy.Params{
|
// the alerts the proxy raises as well, as newProxy makes them.
|
||||||
Config: cfg,
|
func startProxyWithAlerts(
|
||||||
RequestLog: out,
|
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||||
ProcessLog: requestlog.NewProcessLogger(out),
|
env map[string]string,
|
||||||
GeoJSURL: geojsURL,
|
) (string, *output, *proxy.Server, *alerts.Queue) {
|
||||||
Now: now,
|
t.Helper()
|
||||||
})
|
|
||||||
|
server, out, alertQueue := newProxy(t, appURL, geojsURL, now, env)
|
||||||
|
|
||||||
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 {
|
||||||
@@ -237,7 +259,85 @@ func startProxyWithClock(
|
|||||||
_ = server.Close()
|
_ = server.Close()
|
||||||
})
|
})
|
||||||
|
|
||||||
return listener.Addr().String(), out, server
|
return listener.Addr().String(), out, server, alertQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
// newProxy makes the server startProxyWithClock starts, without starting
|
||||||
|
// it, and returns it, what it writes, and the queue of the alerts the
|
||||||
|
// proxy raises, as the settings in env make it. No alert is sent from the
|
||||||
|
// queue: they wait in it, for the test to look at. With no geojsURL, there
|
||||||
|
// is no stand-in for GeoJS to look clients up at, and SWWAF_LOOKUP_SOURCE
|
||||||
|
// is off unless env sets it. While it is file, the lookup database
|
||||||
|
// SWWAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
|
||||||
|
// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY.
|
||||||
|
func newProxy(
|
||||||
|
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||||
|
env map[string]string,
|
||||||
|
) (*proxy.Server, *output, *alerts.Queue) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
settings := map[string]string{
|
||||||
|
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
|
||||||
|
}
|
||||||
|
if geojsURL == "" {
|
||||||
|
settings[lookupSource] = "off"
|
||||||
|
}
|
||||||
|
|
||||||
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
|
cfg, err := config.FromEnvironment(func(name string) (string, bool) {
|
||||||
|
value, ok := settings[name]
|
||||||
|
|
||||||
|
return value, ok
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("settings %v: %v", settings, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &output{}
|
||||||
|
processLog := requestlog.NewProcessLogger(out, cfg.InstanceName)
|
||||||
|
|
||||||
|
ruleFiles, err := rules.Load(rules.Params{
|
||||||
|
Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rule files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
alertQueue := alerts.New(alerts.Params{
|
||||||
|
WebhookURL: cfg.AlertWebhookURL,
|
||||||
|
Events: cfg.AlertEvents,
|
||||||
|
Cooldown: cfg.AlertCooldown,
|
||||||
|
MaxPerHour: cfg.AlertMaxPerHour,
|
||||||
|
Instance: cfg.InstanceName,
|
||||||
|
Now: now,
|
||||||
|
ProcessLog: processLog,
|
||||||
|
})
|
||||||
|
|
||||||
|
var lookupFile *lookup.File
|
||||||
|
|
||||||
|
if cfg.LookupSource == fileSource {
|
||||||
|
lookupFile, err = lookup.OpenFile(lookup.FileParams{
|
||||||
|
Path: cfg.LookupDBPath, Now: now, ProcessLog: processLog, Alerts: alertQueue,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup database: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server := proxy.New(proxy.Params{
|
||||||
|
Config: cfg,
|
||||||
|
RequestLog: out,
|
||||||
|
ProcessLog: processLog,
|
||||||
|
GeoJSURL: geojsURL,
|
||||||
|
AbuseIPDBURL: abuseIPDBURL,
|
||||||
|
LookupFile: lookupFile,
|
||||||
|
Now: now,
|
||||||
|
Rules: ruleFiles,
|
||||||
|
Alerts: alertQueue,
|
||||||
|
})
|
||||||
|
|
||||||
|
return server, out, alertQueue
|
||||||
}
|
}
|
||||||
|
|
||||||
// newClient returns an HTTP client that sends requests as they are made,
|
// newClient returns an HTTP client that sends requests as they are made,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,3 +71,90 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
|
|||||||
t.Errorf("the app was called %d times, want 4", calls.Load())
|
t.Errorf("the app was called %d times, want 4", calls.Load())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||||
|
|
||||||
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
|
rateLimitPerMinute: "1",
|
||||||
|
rateLimitExemptPaths: "/assets/,/favicon.ico",
|
||||||
|
denyNets: denied,
|
||||||
|
deniedCountries: "kp",
|
||||||
|
})
|
||||||
|
|
||||||
|
// The answers are kept before the requests, so that none waits for
|
||||||
|
// GeoJS.
|
||||||
|
server.GeoJS.Load([]lookup.Answer{
|
||||||
|
keptAnswer(client, "DE"), keptAnswer(fromKP, "KP"),
|
||||||
|
})
|
||||||
|
|
||||||
|
// With a limit of one request a minute, the requests for paths under a
|
||||||
|
// prefix are not counted, so client's first request for / is within
|
||||||
|
// the limit; and once client has reached it, they are not refused.
|
||||||
|
s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.request(client, "/favicon.ico?v=2", http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
line := s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward)
|
||||||
|
if line.LimitHit != "" || line.Counts != (ratelimit.Counts{}) {
|
||||||
|
t.Errorf("log line has limit_hit %q and counts %+v, want neither",
|
||||||
|
line.LimitHit, line.Counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A path outside every prefix is counted: /assets is not under
|
||||||
|
// /assets/, and breaks the limit.
|
||||||
|
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
|
// A ban, SWWAF_DENY_NETS and the country lists still refuse a path
|
||||||
|
// under a prefix.
|
||||||
|
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
|
||||||
|
s.request(fromKP, "/assets/app.js",
|
||||||
|
http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, sent := range []string{
|
||||||
|
// A prefix matches only at the start of the path.
|
||||||
|
"/static/assets/app.js",
|
||||||
|
// A prefix matches the path as sent: a router that matches the
|
||||||
|
// path as received does not take /%61ssets/x for a path under
|
||||||
|
// /assets/.
|
||||||
|
"/%61ssets/x",
|
||||||
|
// .. once percent-decoded: an app may act on these as /login, the
|
||||||
|
// last as a path under /sneak/app/ or as /assets/x.
|
||||||
|
"/assets/../login",
|
||||||
|
"/assets/%2e%2e/login",
|
||||||
|
"/assets/..%2Flogin",
|
||||||
|
"/assets/..;/login",
|
||||||
|
"/sneak/app/src/branch/main/..%2F..%2F..%2F..%2F..%2F..%2Fassets/x",
|
||||||
|
// Not under /assets/ as sent: Go's router takes /assets%2Fx for one
|
||||||
|
// path segment, not a path under /assets/.
|
||||||
|
"/assets%2Fx",
|
||||||
|
"/assets%2fx",
|
||||||
|
// Under /assets/ as sent, but holding an encoded slash, in either
|
||||||
|
// case, or a backslash: never exempt, whatever the prefix.
|
||||||
|
"/assets/x%2Fy",
|
||||||
|
"/assets/x%2fy",
|
||||||
|
`/assets/x\y`,
|
||||||
|
} {
|
||||||
|
t.Run(sent, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
|
rateLimitPerMinute: "1",
|
||||||
|
rateLimitExemptPaths: "/assets/",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Counted, the second request breaks the limit of one request
|
||||||
|
// a minute.
|
||||||
|
s.request(client, sent, http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.request(client, sent, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deny is the SWWAF_BLOCKLIST_ACTION and the SWWAF_REPUTATION_ACTION that
|
||||||
|
// refuses the requests of a client a source lists.
|
||||||
|
const deny = "deny"
|
||||||
|
|
||||||
|
// blocklistDenied notes the blocklists that list the client, as
|
||||||
|
// noteListed does, and reports whether SWWAF_BLOCKLIST_ACTION, being deny,
|
||||||
|
// refuses the request. Being limit, it lowers the client's limits instead
|
||||||
|
// (see limitPercentages), and being log, it does nothing more.
|
||||||
|
func (rq *request) blocklistDenied() bool {
|
||||||
|
listedBy := rq.h.lists.ListedBy(rq.client)
|
||||||
|
rq.blocklisted = len(listedBy) > 0
|
||||||
|
rq.noteListed(listedBy, "listed by a blocklist")
|
||||||
|
|
||||||
|
return rq.blocklisted && rq.h.config.BlocklistAction == deny
|
||||||
|
}
|
||||||
|
|
||||||
|
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as
|
||||||
|
// noteListed does, and reports whether SWWAF_REPUTATION_ACTION, being
|
||||||
|
// deny, refuses the request. Being limit, it lowers the client's limits
|
||||||
|
// instead (see limitPercentages), and being log, it does nothing more. A
|
||||||
|
// zone without a verdict on the client is asked about it in the
|
||||||
|
// background, and the request does not wait for the answer. ctx is the
|
||||||
|
// request's own context.
|
||||||
|
func (rq *request) dnsblDenied(ctx context.Context) bool {
|
||||||
|
listedBy := rq.h.dnsbl.ListedBy(ctx, rq.client)
|
||||||
|
rq.dnsblListed = len(listedBy) > 0
|
||||||
|
rq.noteListed(listedBy, "listed by a DNSBL zone")
|
||||||
|
|
||||||
|
return rq.dnsblListed && rq.h.config.ReputationAction == deny
|
||||||
|
}
|
||||||
|
|
||||||
|
// abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
|
||||||
|
// its score of the client is a hit, and reports whether
|
||||||
|
// SWWAF_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
|
||||||
|
// does for a zone. While SWWAF_ABUSEIPDB_KEY is unset it does nothing. A
|
||||||
|
// client without a score is checked in the background, by the request's
|
||||||
|
// address, if its history counts an offence, and the request does not
|
||||||
|
// wait for the answer. The score is then used for each address of the
|
||||||
|
// client. ctx is the request's own context.
|
||||||
|
func (rq *request) abuseIPDBDenied(ctx context.Context) bool {
|
||||||
|
if rq.h.config.AbuseIPDBKey == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
client := clientGroup(rq.client)
|
||||||
|
held, _ := rq.h.limiter.Client(client)
|
||||||
|
offender := held.History.Offences != ratelimit.Offences{}
|
||||||
|
|
||||||
|
score, hit := rq.h.abuseIPDB.Hit(ctx, client, rq.client, offender)
|
||||||
|
if !hit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.abuseIPDBHit = true
|
||||||
|
rq.noteHit(reputation.AbuseIPDBSource, "scored by AbuseIPDB at or over "+
|
||||||
|
"SWWAF_ABUSEIPDB_MIN_SCORE", map[string]any{
|
||||||
|
"source": reputation.AbuseIPDBSource, "score": score,
|
||||||
|
})
|
||||||
|
|
||||||
|
return rq.h.config.ReputationAction == deny
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteListed notes each of sources, the URLs of the blocklists or the
|
||||||
|
// DNSBL zones, their keys masked, that list the client, as noteHit does,
|
||||||
|
// with reason, and the source in the alert's detail.
|
||||||
|
func (rq *request) noteListed(sources []string, reason string) {
|
||||||
|
for _, source := range sources {
|
||||||
|
rq.noteHit(source, reason, map[string]any{"source": source})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteHit adds source, which lists the client, to the log line's
|
||||||
|
// reputation, counts it in the metrics, and raises a reputation_hit alert
|
||||||
|
// with reason and detail.
|
||||||
|
func (rq *request) noteHit(source, reason string, detail map[string]any) {
|
||||||
|
rq.line.Reputation = append(rq.line.Reputation, source)
|
||||||
|
rq.h.metrics.ReputationHit(source)
|
||||||
|
rq.h.alerts.Raise(alerts.Alert{
|
||||||
|
Event: alerts.EventReputationHit,
|
||||||
|
Client: rq.client,
|
||||||
|
Netblock: clientGroup(rq.client),
|
||||||
|
ASN: rq.line.ASN,
|
||||||
|
ASName: rq.line.ASName,
|
||||||
|
Country: rq.line.Country,
|
||||||
|
Reason: reason,
|
||||||
|
Detail: detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,960 @@
|
|||||||
|
package proxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The reputation settings.
|
||||||
|
const (
|
||||||
|
blocklistURLs = "SWWAF_BLOCKLIST_URLS"
|
||||||
|
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
||||||
|
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The actions of SWWAF_BLOCKLIST_ACTION and SWWAF_REPUTATION_ACTION:
|
||||||
|
// limitHalf gives a listed client half of every limit, and limitQuarter a
|
||||||
|
// quarter.
|
||||||
|
const (
|
||||||
|
actionDeny = "deny"
|
||||||
|
actionLog = "log"
|
||||||
|
limitHalf = "limit:50"
|
||||||
|
limitQuarter = "limit:25"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The lists these tests name, which are never fetched: each test puts in
|
||||||
|
// the copies it needs, as reputation.json would at start.
|
||||||
|
const (
|
||||||
|
dropURL = "https://lists.example/drop.txt"
|
||||||
|
torURL = "https://lists.example/tor.txt"
|
||||||
|
asnURL = "https://lists.example/asn.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
forward, denied := requestlog.ActionForward, requestlog.ActionDenied
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
action string
|
||||||
|
// statuses and actions are those of a listed client's three
|
||||||
|
// requests, and percent their limit_percent, as percentText gives it.
|
||||||
|
statuses []int
|
||||||
|
actions []string
|
||||||
|
percent string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
actionDeny, []int{http.StatusForbidden, http.StatusForbidden, http.StatusForbidden},
|
||||||
|
[]string{denied, denied, denied}, none,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Half of 4 requests a minute: the third breaks the limit.
|
||||||
|
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
||||||
|
[]string{forward, forward, requestlog.ActionRateLimited},
|
||||||
|
"50 from " + blocklistAction,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
|
||||||
|
[]string{forward, forward, forward}, none,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.action, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
rateLimitPerMinute: fourAMinute, blocklistURLs: dropURL,
|
||||||
|
blocklistAction: tc.action,
|
||||||
|
})
|
||||||
|
// fromDE is listed as an address, and fromKP in a netblock.
|
||||||
|
loadLists(t, server, map[string][]string{
|
||||||
|
dropURL: {"; DROP", fromDE, "198.51.100.0/24 ; SBL1"},
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, from := range []string{fromDE, fromKP} {
|
||||||
|
for i := range 3 {
|
||||||
|
line := s.get(from, tc.statuses[i], tc.actions[i])
|
||||||
|
wantReputation(t, line, dropURL)
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent,
|
||||||
|
line.LimitPercentSetting, tc.percent)
|
||||||
|
|
||||||
|
// A request refused for the list is not counted.
|
||||||
|
counted := line.fields["counts"] != nil
|
||||||
|
if counted != (tc.actions[i] != denied) {
|
||||||
|
t.Errorf("request from %s counted %t, logged %s", from, counted,
|
||||||
|
tc.actions[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client no list lists has the whole limit.
|
||||||
|
for range 3 {
|
||||||
|
line := s.get(unplaced, http.StatusOK, forward)
|
||||||
|
wantReputation(t, line)
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
|
none)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal for the list makes no ban.
|
||||||
|
if held := server.Ledger.Snapshot(); tc.action == actionDeny && len(held) != 0 {
|
||||||
|
t.Errorf("bans %+v, want none", held)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, queue := startWithLookups(t, map[string]string{
|
||||||
|
blocklistURLs: dropURL, deniedCountries: "kp", allowNets: fromDE,
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// alerted.
|
||||||
|
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied))
|
||||||
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
||||||
|
wantAlerts(t, queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveModeForwardsAClientABlocklistDeniesAndAlertsIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, queue := startWithLookups(t, map[string]string{
|
||||||
|
blocklistURLs: dropURL, mode: observe,
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
||||||
|
|
||||||
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantWouldAction(t, line, requestlog.ActionDenied)
|
||||||
|
wantReputation(t, line, dropURL)
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit {
|
||||||
|
t.Errorf("alerts waiting %+v, want a reputation_hit alert", waiting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
action, asnPercent string
|
||||||
|
// want is the upload's limit_percent and bytes_percent, as
|
||||||
|
// percentText gives them, and limitHit its limit_hit.
|
||||||
|
want, limitHit string
|
||||||
|
}{
|
||||||
|
{limitHalf, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
|
||||||
|
{limitQuarter, asnDEHalf, "25 from " + blocklistAction, minuteBytes},
|
||||||
|
// The AS number's, the first of two alike.
|
||||||
|
{limitQuarter, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
|
||||||
|
{actionLog, asnDE + ":100", none, ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL,
|
||||||
|
blocklistAction: tc.action, asnLimitPercent: tc.asnPercent,
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
||||||
|
|
||||||
|
// The upload's 100 bytes are over 49, a quarter of 199, and 99,
|
||||||
|
// half of it, and within 199.
|
||||||
|
line := s.uploadFrom(fromDE)
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
|
tc.want)
|
||||||
|
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
||||||
|
tc.want)
|
||||||
|
|
||||||
|
if line.LimitHit != tc.limitHit {
|
||||||
|
t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
fromURL = "25 from " + asnLimitPercentURL
|
||||||
|
fromSetting = "25 from " + asnLimitPercent
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env map[string]string
|
||||||
|
file string
|
||||||
|
// limitPercent and bytesPercent are the upload's, as percentText
|
||||||
|
// gives them.
|
||||||
|
limitPercent, bytesPercent string
|
||||||
|
}{
|
||||||
|
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
|
||||||
|
{
|
||||||
|
"the file's, lower than the setting's",
|
||||||
|
map[string]string{asnLimitPercent: asnDEHalf}, asnDEQuarter, fromURL, fromURL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"the setting's, lower than the file's",
|
||||||
|
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEHalf,
|
||||||
|
fromSetting, fromSetting,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"the setting's, the first of two alike",
|
||||||
|
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEQuarter,
|
||||||
|
fromSetting, fromSetting,
|
||||||
|
},
|
||||||
|
{"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",
|
||||||
|
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := map[string]string{asnLimitPercentURL: asnURL}
|
||||||
|
maps.Copy(env, tc.env)
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, env)
|
||||||
|
loadLists(t, server, map[string][]string{asnURL: {"# by AS number", tc.file}})
|
||||||
|
|
||||||
|
line := s.uploadFrom(fromDE)
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
|
tc.limitPercent)
|
||||||
|
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
||||||
|
tc.bytesPercent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const emptyURL = "https://lists.example/empty.txt"
|
||||||
|
|
||||||
|
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
|
||||||
|
blocklistURLs: dropURL + "," + torURL + "," + emptyURL,
|
||||||
|
blocklistAction: actionLog,
|
||||||
|
metricsToken: token,
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{dropURL: {fromDE}, torURL: {fromDE}})
|
||||||
|
|
||||||
|
// The second request's alerts are repeats, which the cooldown holds
|
||||||
|
// back.
|
||||||
|
for range 2 {
|
||||||
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
dropURL, torURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
hit := func(source string) alerts.Alert {
|
||||||
|
return alerts.Alert{
|
||||||
|
Instance: alertInstance,
|
||||||
|
Time: clk.Now(),
|
||||||
|
Event: alerts.EventReputationHit,
|
||||||
|
Client: netip.MustParseAddr(fromDE),
|
||||||
|
Netblock: netip.MustParsePrefix(fromDE + "/32"),
|
||||||
|
ASN: asnDE,
|
||||||
|
ASName: asNameDE,
|
||||||
|
Country: "DE",
|
||||||
|
Reason: "listed by a blocklist",
|
||||||
|
Detail: map[string]any{"source": source},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wantAlerts(t, queue, hit(dropURL), hit(torURL))
|
||||||
|
|
||||||
|
if queue.Suppressed() != 2 {
|
||||||
|
t.Errorf("%d alerts held back, want the second request's 2", queue.Suppressed())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each list's hits, none of its fetches failed, and when its copy was
|
||||||
|
// fetched, 0 for the one without.
|
||||||
|
metrics := s.scrape(unplaced)
|
||||||
|
fetched := float64(listsFetched().Unix())
|
||||||
|
|
||||||
|
for listURL, want := range map[string]struct{ hits, fetched float64 }{
|
||||||
|
dropURL: {2, fetched}, torURL: {2, fetched}, emptyURL: {0, 0},
|
||||||
|
} {
|
||||||
|
labels := `{instance="` + alertInstance + `",source="` + listURL + `"}`
|
||||||
|
|
||||||
|
if want.hits == 0 {
|
||||||
|
wantNoSeries(t, metrics, "smallwebwaf_reputation_hits_total"+labels)
|
||||||
|
} else {
|
||||||
|
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, want.hits)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
||||||
|
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels,
|
||||||
|
want.fetched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The DNSBL settings.
|
||||||
|
const (
|
||||||
|
dnsblZones = "SWWAF_DNSBL_ZONES"
|
||||||
|
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
||||||
|
reputationAction = "SWWAF_REPUTATION_ACTION"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The DNSBL zones these tests name, which are never asked about the
|
||||||
|
// clients the tests send requests from: each test puts in the verdicts it
|
||||||
|
// needs, as reputation.json would at start. A query a test does start is
|
||||||
|
// sent to noResolver, where nothing listens, so that none leaves the host.
|
||||||
|
const (
|
||||||
|
dnsblZone = "dnsbl.example"
|
||||||
|
otherZone = "other.example"
|
||||||
|
noResolver = "127.0.0.1:9"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
forward, denied := requestlog.ActionForward, requestlog.ActionDenied
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
action string
|
||||||
|
// statuses and actions are those of a listed client's three
|
||||||
|
// requests, and percent their limit_percent, as percentText gives it.
|
||||||
|
statuses []int
|
||||||
|
actions []string
|
||||||
|
percent string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
actionDeny, []int{http.StatusForbidden, http.StatusForbidden, http.StatusForbidden},
|
||||||
|
[]string{denied, denied, denied}, none,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Half of 4 requests a minute: the third breaks the limit.
|
||||||
|
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
||||||
|
[]string{forward, forward, requestlog.ActionRateLimited},
|
||||||
|
"50 from " + reputationAction,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
|
||||||
|
[]string{forward, forward, forward}, none,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.action, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
rateLimitPerMinute: fourAMinute, dnsblZones: dnsblZone + "," + otherZone,
|
||||||
|
dnsblResolver: noResolver, reputationAction: tc.action,
|
||||||
|
})
|
||||||
|
listedBy := map[string][]string{
|
||||||
|
fromDE: {dnsblZone, otherZone}, fromKP: {otherZone}, unplaced: nil,
|
||||||
|
}
|
||||||
|
loadVerdicts(server, listedBy)
|
||||||
|
|
||||||
|
for _, from := range []string{fromDE, fromKP} {
|
||||||
|
for i := range 3 {
|
||||||
|
line := s.get(from, tc.statuses[i], tc.actions[i])
|
||||||
|
// In the order SWWAF_DNSBL_ZONES names them.
|
||||||
|
wantReputation(t, line, listedBy[from]...)
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent,
|
||||||
|
line.LimitPercentSetting, tc.percent)
|
||||||
|
|
||||||
|
// A request refused for the verdict is not counted.
|
||||||
|
counted := line.fields["counts"] != nil
|
||||||
|
if counted != (tc.actions[i] != denied) {
|
||||||
|
t.Errorf("request from %s counted %t, logged %s", from, counted,
|
||||||
|
tc.actions[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client no zone lists has the whole limit.
|
||||||
|
for range 3 {
|
||||||
|
line := s.get(unplaced, http.StatusOK, forward)
|
||||||
|
wantReputation(t, line)
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
|
none)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal for the verdict makes no ban, and every client had its
|
||||||
|
// verdicts, so no zone was asked.
|
||||||
|
if held := server.Ledger.Snapshot(); tc.action == actionDeny && len(held) != 0 {
|
||||||
|
t.Errorf("bans %+v, want none", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
if queries := server.DNSBL.Queries(dnsblZone); queries != 0 {
|
||||||
|
t.Errorf("%d queries, want none", queries)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, queue := startWithLookups(t, map[string]string{
|
||||||
|
blocklistURLs: dropURL, dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
||||||
|
reputationAction: actionDeny, allowNets: fromDE,
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{dropURL: {fromKP}})
|
||||||
|
loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}})
|
||||||
|
|
||||||
|
// The blocklist refuses fromKP before its verdict is looked at, and
|
||||||
|
// fromDE, in SWWAF_ALLOW_NETS, is not checked at all: neither is noted
|
||||||
|
// for the zone, nor alerted, nor asked about.
|
||||||
|
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
|
||||||
|
dropURL)
|
||||||
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 1 || waiting[0].Detail["source"] != dropURL {
|
||||||
|
t.Errorf("alerts waiting %+v, want the blocklist's reputation_hit alone", waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
if queries := server.DNSBL.Queries(dnsblZone); queries != 0 {
|
||||||
|
t.Errorf("%d queries, want none", queries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, queue := startWithLookups(t, map[string]string{
|
||||||
|
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionDeny,
|
||||||
|
mode: observe,
|
||||||
|
})
|
||||||
|
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
||||||
|
|
||||||
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantWouldAction(t, line, requestlog.ActionDenied)
|
||||||
|
wantReputation(t, line, dnsblZone)
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit {
|
||||||
|
t.Errorf("alerts waiting %+v, want a reputation_hit alert", waiting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
blocklistAction, reputationAction string
|
||||||
|
// want is the request's limit_percent and bytes_percent, as
|
||||||
|
// percentText gives them.
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{limitHalf, limitQuarter, "25 from " + reputationAction},
|
||||||
|
{limitQuarter, limitHalf, "25 from " + blocklistAction},
|
||||||
|
// The blocklist's, the first of two alike.
|
||||||
|
{limitQuarter, limitQuarter, "25 from " + blocklistAction},
|
||||||
|
{actionLog, limitQuarter, "25 from " + reputationAction},
|
||||||
|
{actionLog, actionLog, none},
|
||||||
|
} {
|
||||||
|
t.Run(tc.blocklistAction+" "+tc.reputationAction, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
blocklistURLs: dropURL, blocklistAction: tc.blocklistAction,
|
||||||
|
dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
||||||
|
reputationAction: tc.reputationAction,
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
||||||
|
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
||||||
|
|
||||||
|
// Named by the blocklist, then by the zone.
|
||||||
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantReputation(t, line, dropURL, dnsblZone)
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
|
tc.want)
|
||||||
|
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
||||||
|
tc.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
|
||||||
|
dnsblZones: dnsblZone + "," + otherZone, dnsblResolver: noResolver,
|
||||||
|
reputationAction: actionLog, metricsToken: token,
|
||||||
|
})
|
||||||
|
loadVerdicts(server, map[string][]string{
|
||||||
|
fromDE: {dnsblZone, otherZone}, unplaced: nil,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The second request's alerts are repeats, which the cooldown holds
|
||||||
|
// back.
|
||||||
|
for range 2 {
|
||||||
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
dnsblZone, otherZone)
|
||||||
|
}
|
||||||
|
|
||||||
|
hit := func(zone string) alerts.Alert {
|
||||||
|
return alerts.Alert{
|
||||||
|
Instance: alertInstance,
|
||||||
|
Time: clk.Now(),
|
||||||
|
Event: alerts.EventReputationHit,
|
||||||
|
Client: netip.MustParseAddr(fromDE),
|
||||||
|
Netblock: netip.MustParsePrefix(fromDE + "/32"),
|
||||||
|
ASN: asnDE,
|
||||||
|
ASName: asNameDE,
|
||||||
|
Country: "DE",
|
||||||
|
Reason: "listed by a DNSBL zone",
|
||||||
|
Detail: map[string]any{"source": zone},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wantAlerts(t, queue, hit(dnsblZone), hit(otherZone))
|
||||||
|
|
||||||
|
if queue.Suppressed() != 2 {
|
||||||
|
t.Errorf("%d alerts held back, want the second request's 2", queue.Suppressed())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each zone's hits, and its queries and their failures, none, since
|
||||||
|
// every client had its verdicts.
|
||||||
|
metrics := s.scrape(unplaced)
|
||||||
|
|
||||||
|
for _, zone := range []string{dnsblZone, otherZone} {
|
||||||
|
labels := `{instance="` + alertInstance + `",source="` + zone + `"}`
|
||||||
|
|
||||||
|
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2)
|
||||||
|
wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0)
|
||||||
|
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
key = "abcdefghijklmnopqrstuvwxyz"
|
||||||
|
keyed = key + ".xbl.dq.spamhaus.net"
|
||||||
|
masked = "********.xbl.dq.spamhaus.net"
|
||||||
|
)
|
||||||
|
|
||||||
|
s, server, queue := startWithLookups(t, map[string]string{
|
||||||
|
dnsblZones: keyed, dnsblResolver: noResolver, reputationAction: actionLog,
|
||||||
|
metricsToken: token,
|
||||||
|
})
|
||||||
|
loadVerdicts(server, map[string][]string{fromDE: {keyed}, unplaced: nil})
|
||||||
|
|
||||||
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward), masked)
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 1 || waiting[0].Detail["source"] != masked {
|
||||||
|
t.Errorf("alerts waiting %+v, want a reputation_hit alert from %s", waiting,
|
||||||
|
masked)
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics := s.scrape(unplaced)
|
||||||
|
wantMetric(t, metrics, `smallwebwaf_reputation_hits_total{instance="`+
|
||||||
|
alertInstance+`",source="`+masked+`"}`, 1)
|
||||||
|
|
||||||
|
for name, shown := range map[string]string{
|
||||||
|
"the log": s.out.text(), "the metrics": metrics,
|
||||||
|
} {
|
||||||
|
if strings.Contains(shown, key) {
|
||||||
|
t.Errorf("%s shows the key:\n%s", name, shown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
dnsblZones: dnsblZone + "," + otherZone, dnsblResolver: noResolver,
|
||||||
|
})
|
||||||
|
server.DNSBL.Load([]reputation.Verdict{{
|
||||||
|
Zone: otherZone, Client: netip.MustParseAddr(fromDE), Listed: true,
|
||||||
|
Fetched: verdictsFetched(),
|
||||||
|
}})
|
||||||
|
|
||||||
|
// The verdict of the other zone is used, and dnsbl.example, which has
|
||||||
|
// none, is asked about the client in the background, once: the second
|
||||||
|
// request finds the query under way, or the zone left alone after it
|
||||||
|
// failed, since nothing answers at noResolver.
|
||||||
|
for range 2 {
|
||||||
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward), otherZone)
|
||||||
|
}
|
||||||
|
|
||||||
|
if queries := server.DNSBL.Queries(dnsblZone); queries != 1 {
|
||||||
|
t.Errorf("%d queries to %s, want 1", queries, dnsblZone)
|
||||||
|
}
|
||||||
|
|
||||||
|
if queries := server.DNSBL.Queries(otherZone); queries != 0 {
|
||||||
|
t.Errorf("%d queries to %s, want none", queries, otherZone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The AbuseIPDB settings, and accountKey, the key the tests set.
|
||||||
|
const (
|
||||||
|
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
|
||||||
|
accountKey = "abuseipdb-key-0123456789abcdef"
|
||||||
|
)
|
||||||
|
|
||||||
|
// abuseipdb is how the request log, the alerts and the metrics name
|
||||||
|
// AbuseIPDB.
|
||||||
|
const abuseipdb = reputation.AbuseIPDBSource
|
||||||
|
|
||||||
|
// abuseIPDBURL is where newProxy has clients checked with AbuseIPDB: at
|
||||||
|
// abuseIPDBStandIn, which TestMain registers with Go's default transport,
|
||||||
|
// through which AbuseIPDB is asked.
|
||||||
|
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
|
||||||
|
|
||||||
|
// abuseIPDBStandIn is a stand-in for AbuseIPDB that gives every client the
|
||||||
|
// score 100, at once and without the network.
|
||||||
|
type abuseIPDBStandIn struct{}
|
||||||
|
|
||||||
|
// RoundTrip answers req with the score 100.
|
||||||
|
func (abuseIPDBStandIn) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Status: "200 OK",
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"data":{"abuseConfidenceScore":100}}`)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
forward := requestlog.ActionForward
|
||||||
|
|
||||||
|
s, clk, server, _ := startWithLookupsAndClock(t, map[string]string{
|
||||||
|
abuseIPDBKey: accountKey, rateLimitPerMinute: "2", reputationAction: actionLog,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Neither fromDE, until it breaks a rate limit, nor fromKP, which never
|
||||||
|
// does, is checked, nor fromDE under the ban that makes.
|
||||||
|
s.get(fromDE, http.StatusOK, forward)
|
||||||
|
s.get(fromDE, http.StatusOK, forward)
|
||||||
|
s.get(fromKP, http.StatusOK, forward)
|
||||||
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
wantAbuseIPDBChecks(t, server, 0)
|
||||||
|
|
||||||
|
// Once the ban has ended, fromDE's first request has it checked in the
|
||||||
|
// background, and goes on without its score, which its next request
|
||||||
|
// finds.
|
||||||
|
clk.advance(time.Hour)
|
||||||
|
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
||||||
|
wantAbuseIPDBChecks(t, server, 1)
|
||||||
|
waitUntil(func() bool { return len(server.AbuseIPDB.Snapshot().Scores) == 1 })
|
||||||
|
wantReputation(t, s.get(fromDE, http.StatusOK, forward), abuseipdb)
|
||||||
|
|
||||||
|
s.get(fromKP, http.StatusOK, forward)
|
||||||
|
wantAbuseIPDBChecks(t, server, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
forward := requestlog.ActionForward
|
||||||
|
|
||||||
|
// 15 addresses of 2001:db8:1:2::/64, one client, each in a part of it
|
||||||
|
// of its own.
|
||||||
|
var addresses []string
|
||||||
|
for i := 1; i < 16; i++ {
|
||||||
|
addresses = append(addresses, fmt.Sprintf("2001:db8:1:2:%x::9", i<<12))
|
||||||
|
}
|
||||||
|
|
||||||
|
s, clk, server := startWithClock(t, "", map[string]string{
|
||||||
|
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
||||||
|
rateLimitPerMinute: strconv.Itoa(len(addresses)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// The client breaks the rate limit from its first address, which bans
|
||||||
|
// it for an hour.
|
||||||
|
for range addresses {
|
||||||
|
s.get(addresses[0], http.StatusOK, forward)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.get(addresses[0], http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
clk.advance(time.Hour)
|
||||||
|
|
||||||
|
// Once the ban has ended, which set its counters back to zero, a
|
||||||
|
// request from each of its addresses has it checked once.
|
||||||
|
for _, address := range addresses {
|
||||||
|
s.get(address, http.StatusOK, forward)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantAbuseIPDBChecks(t, server, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probePath is the path the ban rule of testRules, probe, matches.
|
||||||
|
const probePath = "/.env"
|
||||||
|
|
||||||
|
func TestClientARuleRefusedIsCheckedWithAbuseIPDBAtItsNextRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
// path is what the client asks for, status and action what that
|
||||||
|
// request is answered and logged with, and want the offences its
|
||||||
|
// history then counts.
|
||||||
|
path string
|
||||||
|
status int
|
||||||
|
action string
|
||||||
|
want ratelimit.Offences
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"a block rule", "/blocked", http.StatusForbidden, requestlog.ActionRuleBlocked,
|
||||||
|
ratelimit.Offences{RuleBlocked: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a ban rule", probePath, http.StatusForbidden, requestlog.ActionBanned,
|
||||||
|
ratelimit.Offences{Attack: 1},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server := startWithClock(t, "", map[string]string{
|
||||||
|
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
||||||
|
rulesDir: writeRules(t, testRules), attackBanDuration: "1h",
|
||||||
|
})
|
||||||
|
|
||||||
|
s.request(client, tc.path, tc.status, tc.action)
|
||||||
|
wantAbuseIPDBChecks(t, server, 0)
|
||||||
|
|
||||||
|
if got := historyOf(t, server, client).Offences; got != tc.want {
|
||||||
|
t.Errorf("history counts the offences %+v, want %+v", got, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Its next request, once a ban rule's ban has ended, has it
|
||||||
|
// checked.
|
||||||
|
clk.advance(time.Hour)
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantAbuseIPDBChecks(t, server, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
forward, denied := requestlog.ActionForward, requestlog.ActionDenied
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
action string
|
||||||
|
// statuses and actions are those of fromDE's three requests, and
|
||||||
|
// percent their limit_percent, as percentText gives it.
|
||||||
|
statuses []int
|
||||||
|
actions []string
|
||||||
|
percent string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
actionDeny, []int{http.StatusForbidden, http.StatusForbidden, http.StatusForbidden},
|
||||||
|
[]string{denied, denied, denied}, none,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Half of 4 requests a minute: the third breaks the limit.
|
||||||
|
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
||||||
|
[]string{forward, forward, requestlog.ActionRateLimited},
|
||||||
|
"50 from " + reputationAction,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
|
||||||
|
[]string{forward, forward, forward}, none,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.action, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
|
||||||
|
reputationAction: tc.action,
|
||||||
|
})
|
||||||
|
// At SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
|
||||||
|
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
|
||||||
|
|
||||||
|
for i := range 3 {
|
||||||
|
line := s.get(fromDE, tc.statuses[i], tc.actions[i])
|
||||||
|
wantReputation(t, line, abuseipdb)
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
|
tc.percent)
|
||||||
|
|
||||||
|
// A request refused for the score is not counted.
|
||||||
|
counted := line.fields["counts"] != nil
|
||||||
|
if counted != (tc.actions[i] != denied) {
|
||||||
|
t.Errorf("request counted %t, logged %s", counted, tc.actions[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fromKP's score is no hit, and it has the whole limit.
|
||||||
|
for range 3 {
|
||||||
|
line := s.get(fromKP, http.StatusOK, forward)
|
||||||
|
wantReputation(t, line)
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
|
none)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal for the score makes no ban.
|
||||||
|
if held := server.Ledger.Snapshot(); tc.action == actionDeny && len(held) != 0 {
|
||||||
|
t.Errorf("bans %+v, want none", held)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, queue := startWithLookups(t, map[string]string{
|
||||||
|
abuseIPDBKey: accountKey, reputationAction: actionLog, metricsToken: token,
|
||||||
|
})
|
||||||
|
loadScores(server, map[string]int64{fromDE: 90})
|
||||||
|
|
||||||
|
// The second request's alert is a repeat, which the cooldown holds back.
|
||||||
|
for range 2 {
|
||||||
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
abuseipdb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The alert is made as a DNSBL zone's is, with the score besides.
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
|
||||||
|
waiting[0].Reason != "scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE" ||
|
||||||
|
waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) ||
|
||||||
|
queue.Suppressed() != 1 {
|
||||||
|
t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
|
||||||
|
"with the score 90, and 1", waiting, queue.Suppressed())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hits, and the checks, none, since no client committed an
|
||||||
|
// offence, so that the whole budget is left.
|
||||||
|
metrics := s.scrape(unplaced)
|
||||||
|
labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}`
|
||||||
|
|
||||||
|
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2)
|
||||||
|
wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0)
|
||||||
|
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
||||||
|
wantMetric(t, metrics, "smallwebwaf_reputation_daily_budget_remaining"+labels, 900)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
forward := requestlog.ActionForward
|
||||||
|
|
||||||
|
s, clk, server, _ := startWithLookupsAndClock(t, map[string]string{
|
||||||
|
rateLimitPerMinute: "1", metricsToken: token,
|
||||||
|
})
|
||||||
|
loadScores(server, map[string]int64{fromDE: 100})
|
||||||
|
|
||||||
|
// fromDE's score is not used, and once it has committed an offence it
|
||||||
|
// is not checked either.
|
||||||
|
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
||||||
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
clk.advance(time.Hour)
|
||||||
|
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
||||||
|
wantAbuseIPDBChecks(t, server, 0)
|
||||||
|
|
||||||
|
wantNoSeries(t, s.scrape(unplaced), `smallwebwaf_reputation_daily_budget_remaining{`+
|
||||||
|
`instance="`+alertInstance+`",source="`+abuseipdb+`"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listsFetched is when loadLists has the copies fetched.
|
||||||
|
func listsFetched() time.Time {
|
||||||
|
return time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verdictsFetched is when loadVerdicts has the verdicts fetched: half a
|
||||||
|
// day before the time the tests' clock is set to, so that they are in use
|
||||||
|
// until half a day later.
|
||||||
|
func verdictsFetched() time.Time {
|
||||||
|
return time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadVerdicts puts into server's DNSBL, for each client listedBy names,
|
||||||
|
// a verdict of each zone SWWAF_DNSBL_ZONES names, fetched at
|
||||||
|
// 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
|
||||||
|
// each other zone.
|
||||||
|
func loadVerdicts(server *proxy.Server, listedBy map[string][]string) {
|
||||||
|
verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones()))
|
||||||
|
|
||||||
|
for client, zones := range listedBy {
|
||||||
|
for _, zone := range server.DNSBL.Zones() {
|
||||||
|
verdicts = append(verdicts, reputation.Verdict{
|
||||||
|
Zone: zone, Client: netip.MustParseAddr(client),
|
||||||
|
Listed: slices.Contains(zones, zone), Fetched: verdictsFetched(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.DNSBL.Load(verdicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadScores puts into server's AbuseIPDB the score scores gives each
|
||||||
|
// client, an IPv4 address, fetched at verdictsFetched, as reputation.json
|
||||||
|
// would at start.
|
||||||
|
func loadScores(server *proxy.Server, scores map[string]int64) {
|
||||||
|
kept := make([]reputation.Score, 0, len(scores))
|
||||||
|
for client, score := range scores {
|
||||||
|
kept = append(kept, reputation.Score{
|
||||||
|
Client: netip.MustParsePrefix(client + "/32"), Score: score,
|
||||||
|
Fetched: verdictsFetched(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
server.AbuseIPDB.Load(reputation.Checks{Scores: kept})
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantAbuseIPDBChecks checks how many clients server has checked with
|
||||||
|
// AbuseIPDB.
|
||||||
|
func wantAbuseIPDBChecks(t *testing.T, server *proxy.Server, want int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if got := server.AbuseIPDB.Checked(); got != want {
|
||||||
|
t.Errorf("%d clients checked with AbuseIPDB, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadLists puts copies of lists into server's lists, by URL, each with
|
||||||
|
// its lines, fetched at listsFetched, as reputation.json would at start.
|
||||||
|
func loadLists(t *testing.T, server *proxy.Server, copies map[string][]string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
lists := make([]reputation.List, 0, len(copies))
|
||||||
|
for listURL, lines := range copies {
|
||||||
|
lists = append(lists, reputation.List{
|
||||||
|
URL: listURL, Fetched: listsFetched(), Lines: lines,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
err := server.Lists.Load(lists)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load the lists: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantReputation checks the URLs of the blocklists the log line names in
|
||||||
|
// its reputation.
|
||||||
|
func wantReputation(t *testing.T, line logLine, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if !slices.Equal(line.Reputation, want) {
|
||||||
|
t.Errorf("log line has reputation %v, want %v", line.Reputation, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
+320
-52
@@ -3,15 +3,22 @@ package proxy
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptrace"
|
"net/http/httptrace"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"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,8 +52,29 @@ type request struct {
|
|||||||
client netip.Addr
|
client netip.Addr
|
||||||
peer netip.Addr
|
peer netip.Addr
|
||||||
peerTrusted bool
|
peerTrusted bool
|
||||||
|
// lookedUp is true once the client's AS number and country have been
|
||||||
|
// looked up, whether or not an answer was there, and lookupAnswer is
|
||||||
|
// what the lookup gave then, the zero Answer while GeoJS had given none.
|
||||||
|
lookedUp bool
|
||||||
|
lookupAnswer lookup.Answer
|
||||||
|
// counted is true for a request the rate limits counted, whose bytes
|
||||||
|
// the byte limits count once it has ended. limitPercent and
|
||||||
|
// bytesPercent are then its client's limit percentages for the rate
|
||||||
|
// limits and for the byte limits.
|
||||||
|
counted bool
|
||||||
|
limitPercent, bytesPercent percentage
|
||||||
|
// attack is true for a request that matched a ban rule, and
|
||||||
|
// ruleBlocked for one a block rule refused, each an offence its
|
||||||
|
// client's history counts.
|
||||||
|
attack, ruleBlocked 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
|
start time.Time
|
||||||
// upstreamStart is when the request was handed to the app.
|
// checked is when the checks were done, and upstreamStart when the
|
||||||
|
// request was handed to the app.
|
||||||
|
checked time.Time
|
||||||
upstreamStart time.Time
|
upstreamStart time.Time
|
||||||
// cancel ends the request to the app.
|
// cancel ends the request to the app.
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -54,28 +82,39 @@ type request struct {
|
|||||||
refused atomic.Pointer[refusal]
|
refused atomic.Pointer[refusal]
|
||||||
// complete is true once the app's whole answer has been passed on.
|
// complete is true once the app's whole answer has been passed on.
|
||||||
complete bool
|
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
|
// mu guards what follows. The timeouts run on goroutines of their
|
||||||
// own, and the transport starts and stops them from its own; once
|
// own, and the transport starts and stops them, and notes the times
|
||||||
// timersStopped is set, none of them acts any more.
|
// below, from its own; once timersStopped is set, none of the timeouts
|
||||||
|
// acts any more.
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
timersStopped bool
|
timersStopped bool
|
||||||
clientRequestTimer *time.Timer
|
clientRequestTimer *time.Timer
|
||||||
upstreamRequestTimer *time.Timer
|
upstreamRequestTimer *time.Timer
|
||||||
upstreamResponseTimer *time.Timer
|
upstreamResponseTimer *time.Timer
|
||||||
// requestSent is when the app had been sent the whole request.
|
// 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
|
requestSent time.Time
|
||||||
|
answerStarted time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRequest starts handling r: it notes the time, counts the request as
|
// newRequest starts handling r: it notes the time, counts the request as
|
||||||
// under way, and works out the client.
|
// under way, works out the client, and starts the log line with what is
|
||||||
|
// known of the request.
|
||||||
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
||||||
h.metrics.RequestStarted()
|
h.metrics.RequestStarted()
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
peer := peerAddress(r)
|
peer := peerAddress(r)
|
||||||
trusted := h.config.TrustedProxies
|
trusted := h.config.TrustedProxies
|
||||||
client := clientAddress(peer, r.Header.Values("X-Forwarded-For"), trusted)
|
peerTrusted := isInside(peer, trusted)
|
||||||
|
forwardedFor := r.Header.Values("X-Forwarded-For")
|
||||||
|
client := clientAddress(peer, forwardedFor, trusted)
|
||||||
|
|
||||||
rq := &request{
|
rq := &request{
|
||||||
h: h,
|
h: h,
|
||||||
@@ -84,22 +123,37 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
|||||||
out: &responseWriter{ResponseWriter: w},
|
out: &responseWriter{ResponseWriter: w},
|
||||||
client: client,
|
client: client,
|
||||||
peer: peer,
|
peer: peer,
|
||||||
peerTrusted: isInside(peer, trusted),
|
peerTrusted: peerTrusted,
|
||||||
start: start,
|
start: start,
|
||||||
line: requestlog.Line{
|
line: requestlog.Line{
|
||||||
Time: requestlog.FormatTime(start),
|
Time: requestlog.FormatTime(start),
|
||||||
|
Instance: h.config.InstanceName,
|
||||||
ClientIP: client.String(),
|
ClientIP: client.String(),
|
||||||
PeerIP: peer.String(),
|
|
||||||
Method: r.Method,
|
Method: r.Method,
|
||||||
|
Scheme: scheme(r, peerTrusted),
|
||||||
Host: r.Host,
|
Host: r.Host,
|
||||||
Path: r.URL.EscapedPath(),
|
Path: r.URL.EscapedPath(),
|
||||||
Query: r.URL.RawQuery,
|
Query: r.URL.RawQuery,
|
||||||
Protocol: r.Proto,
|
Protocol: r.Proto,
|
||||||
Referer: r.Referer(),
|
Referer: r.Referer(),
|
||||||
UserAgent: r.UserAgent(),
|
UserAgent: r.UserAgent(),
|
||||||
|
RequestID: requestID(r, peerTrusted),
|
||||||
|
PeerIP: peer.String(),
|
||||||
|
ForwardedFor: strings.Join(forwardedFor, ", "),
|
||||||
|
ClientGroup: 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,
|
Action: requestlog.ActionForward,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A length of -1 is a body whose length was not announced.
|
||||||
|
if r.ContentLength > 0 {
|
||||||
|
rq.line.ContentLength = r.ContentLength
|
||||||
|
}
|
||||||
|
|
||||||
if r.Body != http.NoBody {
|
if r.Body != http.NoBody {
|
||||||
rq.body = &requestBody{body: limitBody(r.Body, h.config.RequestMaxBytes), rq: rq}
|
rq.body = &requestBody{body: limitBody(r.Body, h.config.RequestMaxBytes), rq: rq}
|
||||||
}
|
}
|
||||||
@@ -107,40 +161,50 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
|||||||
return 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
|
// check is the one place where a request can be refused once its client
|
||||||
// is known, before its body is read or anything reaches the app. It
|
// is known, before its body is read or anything reaches the app. It
|
||||||
// returns nil to let the request through. A client in SWWAF_ALLOW_NETS
|
// returns nil to let the request through. The checks of checkClient come
|
||||||
// skips every check but the size limit. For any other client,
|
// first, answered with SWWAF_BAN_RESPONSE, or 403 for a block rule, and
|
||||||
// SWWAF_DENY_NETS comes first, then a ban on its netblock, so that a
|
// then the size limit, so that a request the rate limits count is counted
|
||||||
// client either refuses is not looked up, and then the country lists; a
|
// even when it is refused for its size. In observe mode a request
|
||||||
// request any of them refuses is not counted for the rate limits. Then
|
// checkClient refuses goes on to the size limit like any other. ctx is
|
||||||
// come the rate limits, unless the client is in
|
// the request's own context.
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_NETS, so that every other request is counted,
|
|
||||||
// one refused for its size too. Every refusal but the size limit's is
|
|
||||||
// answered with SWWAF_BAN_RESPONSE. ctx is the request's own context.
|
|
||||||
func (rq *request) check(ctx context.Context) *refusal {
|
func (rq *request) check(ctx context.Context) *refusal {
|
||||||
cfg := rq.h.config
|
action := rq.checkClient(ctx)
|
||||||
allowed := isInside(rq.client, cfg.AllowNets)
|
|
||||||
exempt := isInside(rq.client, cfg.RateLimitExemptNets)
|
|
||||||
now := rq.h.now()
|
|
||||||
|
|
||||||
if !allowed && isInside(rq.client, cfg.DenyNets) {
|
switch {
|
||||||
return rq.banResponse(requestlog.ActionDenied)
|
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:
|
||||||
|
return &refusal{status: http.StatusForbidden, action: action}
|
||||||
|
default:
|
||||||
|
return rq.banResponse(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !allowed && rq.banned(now) {
|
maxBytes := rq.h.config.RequestMaxBytes
|
||||||
return rq.banResponse(requestlog.ActionBanned)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !allowed && rq.countryDenied(ctx) {
|
|
||||||
return rq.banResponse(requestlog.ActionCountryDenied)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !allowed && !exempt && rq.limitBroken(now) {
|
|
||||||
return rq.banResponse(requestlog.ActionRateLimited)
|
|
||||||
}
|
|
||||||
|
|
||||||
maxBytes := cfg.RequestMaxBytes
|
|
||||||
if maxBytes > 0 && rq.in.ContentLength > maxBytes {
|
if maxBytes > 0 && rq.in.ContentLength > maxBytes {
|
||||||
return &refusal{
|
return &refusal{
|
||||||
status: http.StatusRequestEntityTooLarge,
|
status: http.StatusRequestEntityTooLarge,
|
||||||
@@ -152,6 +216,89 @@ func (rq *request) check(ctx context.Context) *refusal {
|
|||||||
return nil
|
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 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, and last the rule files. 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)
|
||||||
|
|
||||||
|
if rq.countryDenied() {
|
||||||
|
return requestlog.ActionCountryDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
if rq.blocklistDenied() {
|
||||||
|
return requestlog.ActionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
if rq.dnsblDenied(ctx) || rq.abuseIPDBDenied(ctx) {
|
||||||
|
return requestlog.ActionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.counted = !isInside(rq.client, cfg.RateLimitExemptNets) &&
|
||||||
|
!pathExempt(rq.in.URL, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
return rq.checkRules(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathExempt reports whether the rate limits leave out a request for u
|
||||||
|
// because of SWWAF_RATE_LIMIT_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(u *url.URL, prefixes []string) bool {
|
||||||
|
decoded := u.Path
|
||||||
|
// EscapedPath is the path as the app receives it, not decoded.
|
||||||
|
sent := u.EscapedPath()
|
||||||
|
|
||||||
|
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
|
// forward passes the request to the app and the app's answer back. ctx
|
||||||
// is the request's own context.
|
// is the request's own context.
|
||||||
func (rq *request) forward(ctx context.Context) {
|
func (rq *request) forward(ctx context.Context) {
|
||||||
@@ -160,7 +307,9 @@ func (rq *request) forward(ctx context.Context) {
|
|||||||
|
|
||||||
rq.cancel = cancel
|
rq.cancel = cancel
|
||||||
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
|
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
|
||||||
|
GotConn: rq.gotConn,
|
||||||
WroteRequest: rq.wroteRequest,
|
WroteRequest: rq.wroteRequest,
|
||||||
|
GotFirstResponseByte: rq.gotFirstResponseByte,
|
||||||
})
|
})
|
||||||
|
|
||||||
out := rq.in.WithContext(ctx)
|
out := rq.in.WithContext(ctx)
|
||||||
@@ -183,7 +332,10 @@ func (rq *request) forward(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rewrite makes the request the app receives: the client's request,
|
// rewrite makes the request the app receives: the client's request,
|
||||||
// unchanged, sent to SWWAF_UPSTREAM_URL, with the forwarded headers set.
|
// 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) {
|
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
||||||
upstream := rq.h.config.UpstreamURL
|
upstream := rq.h.config.UpstreamURL
|
||||||
pr.Out.URL.Scheme = upstream.Scheme
|
pr.Out.URL.Scheme = upstream.Scheme
|
||||||
@@ -192,6 +344,13 @@ func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
|||||||
// the query as the client sent it.
|
// the query as the client sent it.
|
||||||
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
|
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
|
||||||
setForwardedHeaders(pr.In, pr.Out, rq.peer, rq.peerTrusted)
|
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
|
// modifyResponse looks at the app's answer before ReverseProxy passes it
|
||||||
@@ -202,9 +361,17 @@ func (rq *request) modifyResponse(res *http.Response) error {
|
|||||||
if res.StatusCode == http.StatusSwitchingProtocols {
|
if res.StatusCode == http.StatusSwitchingProtocols {
|
||||||
// An upgraded connection, such as a WebSocket, is not cut by the
|
// An upgraded connection, such as a WebSocket, is not cut by the
|
||||||
// timeouts. ReverseProxy writes this answer straight to the
|
// timeouts. ReverseProxy writes this answer straight to the
|
||||||
// connection it takes over, not through rq.out.
|
// connection it takes over, not through rq.out, and then copies
|
||||||
|
// what passes each way through res.Body, the connection to the app.
|
||||||
rq.stopTimers()
|
rq.stopTimers()
|
||||||
rq.out.status = res.StatusCode
|
rq.out.status = res.StatusCode
|
||||||
|
rq.line.Websocket = true
|
||||||
|
|
||||||
|
conn, ok := res.Body.(io.ReadWriteCloser)
|
||||||
|
if ok {
|
||||||
|
rq.upgraded = &upgradedConn{ReadWriteCloser: conn}
|
||||||
|
res.Body = rq.upgraded
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -281,11 +448,15 @@ func (rq *request) answer(r refusal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// refuse records r, unless an earlier refusal was, and ends the request
|
// refuse records r, unless an earlier refusal was, and ends the request
|
||||||
// to the app.
|
// to the app, if one was made: smallwebwaf reads the body of a request
|
||||||
|
// it answers itself too.
|
||||||
func (rq *request) refuse(r refusal) {
|
func (rq *request) refuse(r refusal) {
|
||||||
rq.refused.CompareAndSwap(nil, &r)
|
rq.refused.CompareAndSwap(nil, &r)
|
||||||
|
|
||||||
|
if rq.cancel != nil {
|
||||||
rq.cancel()
|
rq.cancel()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// finish ends the request's timeouts, counts it in the metrics and writes
|
// finish ends the request's timeouts, counts it in the metrics and writes
|
||||||
// its log line.
|
// its log line.
|
||||||
@@ -300,10 +471,11 @@ func (rq *request) finish() {
|
|||||||
line := &rq.line
|
line := &rq.line
|
||||||
line.Status = rq.out.status
|
line.Status = rq.out.status
|
||||||
line.ResponseBytes = rq.out.bytes
|
line.ResponseBytes = rq.out.bytes
|
||||||
|
header := rq.out.Header()
|
||||||
if rq.body != nil {
|
line.ResponseContentType = header.Get("Content-Type")
|
||||||
line.RequestBytes = rq.body.bytes.Load()
|
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.
|
// limit is the setting whose size or time limit the request passed.
|
||||||
var limit string
|
var limit string
|
||||||
@@ -324,12 +496,18 @@ func (rq *request) finish() {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
duration := now.Sub(rq.start)
|
duration := now.Sub(rq.start)
|
||||||
line.DurationTotal = requestlog.Milliseconds(duration)
|
line.DurationTotal = requestlog.Milliseconds(duration)
|
||||||
|
line.DurationChecks = timing(rq.start, rq.checked)
|
||||||
|
|
||||||
var upstreamDuration time.Duration
|
var upstreamDuration time.Duration
|
||||||
|
|
||||||
if !rq.upstreamStart.IsZero() {
|
if !rq.upstreamStart.IsZero() {
|
||||||
upstreamDuration = now.Sub(rq.upstreamStart)
|
upstreamDuration = now.Sub(rq.upstreamStart)
|
||||||
line.DurationUpstreamTotal = requestlog.Milliseconds(upstreamDuration)
|
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
|
// Counted before the log line is written, so that the metrics count
|
||||||
@@ -342,25 +520,97 @@ func (rq *request) finish() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// addToHistory adds the request, which has ended, to its client's
|
// timing is the time from start to end in milliseconds, for one of the
|
||||||
// history.
|
// log line's timings, or nil when end is zero: what it times never
|
||||||
func (rq *request) addToHistory() {
|
// happened.
|
||||||
var requestBytes int64
|
func timing(start, end time.Time) *float64 {
|
||||||
if rq.body != nil {
|
if end.IsZero() {
|
||||||
requestBytes = rq.body.bytes.Load()
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new(requestlog.Milliseconds(end.Sub(start)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// addToHistory adds the request, which has ended, to its client's
|
||||||
|
// history, 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()
|
forwarded := !rq.upstreamStart.IsZero()
|
||||||
|
|
||||||
rq.h.limiter.AddToHistory(clientGroup(rq.client), rq.h.now(), ratelimit.Request{
|
rq.h.limiter.AddToHistory(clientGroup(rq.client), rq.h.now(), ratelimit.Request{
|
||||||
Country: rq.line.Country,
|
|
||||||
Forwarded: forwarded,
|
Forwarded: forwarded,
|
||||||
Refused: !forwarded && rq.refused.Load() != nil,
|
Refused: !forwarded && rq.refused.Load() != nil,
|
||||||
Status: rq.out.status,
|
Status: rq.out.status,
|
||||||
RequestBytes: requestBytes,
|
RequestBytes: rq.requestBytes(),
|
||||||
ResponseBytes: rq.out.bytes,
|
ResponseBytes: rq.out.bytes,
|
||||||
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
||||||
|
Attack: rq.attack,
|
||||||
|
RuleBlocked: rq.ruleBlocked,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
answer, found := rq.answerAtTheEnd()
|
||||||
|
if found {
|
||||||
|
rq.h.addLookup(answer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countAnomalies counts the request, which has ended, and its bytes, as
|
||||||
|
// countedBytes gives them, for the anomaly thresholds, whatever was done
|
||||||
|
// with it: a request refused, one from a client in SWWAF_ALLOW_NETS or
|
||||||
|
// SWWAF_RATE_LIMIT_EXEMPT_NETS, and one for a path in
|
||||||
|
// SWWAF_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its
|
||||||
|
// client's AS number when answerAtTheEnd gives one. With every anomaly
|
||||||
|
// threshold off, the default, it does nothing.
|
||||||
|
func (rq *request) countAnomalies() {
|
||||||
|
if !anomalyThresholdsSet(rq.h.config) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
answer, _ := rq.answerAtTheEnd()
|
||||||
|
|
||||||
|
rq.h.anomalies.Count(rq.h.now(), anomaly.Request{
|
||||||
|
Client: rq.client,
|
||||||
|
ClientGroup: 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(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
|
// clientRequestDeadline is when the client must have sent its whole
|
||||||
@@ -451,6 +701,24 @@ func (rq *request) bodyReceived() {
|
|||||||
stopTimer(rq.clientRequestTimer)
|
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:
|
// wroteRequest is called once the app has been sent the whole request:
|
||||||
// the request timeouts end and SWWAF_UPSTREAM_RESPONSE_TIMEOUT starts.
|
// the request timeouts end and SWWAF_UPSTREAM_RESPONSE_TIMEOUT starts.
|
||||||
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
|
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
package proxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// requestIDHeader carries the request's id.
|
||||||
|
requestIDHeader = "X-Request-ID"
|
||||||
|
// instance is the SWWAF_INSTANCE_NAME a test sets.
|
||||||
|
instance = "fsn1app1/gitea"
|
||||||
|
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
|
||||||
|
// limits count it as.
|
||||||
|
ipv6Client = "2001:db8::7"
|
||||||
|
ipv6Group = "2001:db8::/64"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogLineHasEachFieldWhereItApplies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
received := make(chan string, 2) // the request ids the app received
|
||||||
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
received <- r.Header.Get(requestIDHeader)
|
||||||
|
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
|
||||||
|
if r.URL.Path != "/full" {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Location", "/elsewhere")
|
||||||
|
w.WriteHeader(http.StatusFound)
|
||||||
|
_, _ = io.WriteString(w, "moved")
|
||||||
|
})
|
||||||
|
addr, out := startProxy(t, app.URL, map[string]string{
|
||||||
|
trustedProxies: trustLocalhost,
|
||||||
|
rateLimitExemptNets: localhost,
|
||||||
|
instanceName: instance,
|
||||||
|
logRequestHeaders: "Accept,x-custom,Authorization,cookie,SET-COOKIE",
|
||||||
|
})
|
||||||
|
|
||||||
|
// This request comes from ipv6Client through a trusted proxy, with a
|
||||||
|
// body and each header the log line looks at, and is answered with a
|
||||||
|
// redirect.
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, "POST /full HTTP/1.1\r\nHost: "+appHost+"\r\n"+
|
||||||
|
forwardedFor+": 198.51.100.7, "+ipv6Client+"\r\n"+
|
||||||
|
forwardedProto+": "+secure+"\r\n"+requestIDHeader+": from-traefik\r\n"+
|
||||||
|
"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: 3\r\n"+
|
||||||
|
"Accept: text/html\r\nX-Custom: one\r\nX-Custom: two\r\n"+
|
||||||
|
"Authorization: Bearer secret-token\r\nCookie: session=secret-cookie\r\n"+
|
||||||
|
"Set-Cookie: secret-set-cookie\r\n\r\na=b")
|
||||||
|
wantStatus(t, readResponse(t, conn), http.StatusFound)
|
||||||
|
|
||||||
|
// A request's log line can come after its answer: each is waited for
|
||||||
|
// before the next request, so that the lines are in order.
|
||||||
|
full := out.requestLines(t, 1)[0]
|
||||||
|
|
||||||
|
// This one comes from 127.0.0.1, which the rate limits do not count,
|
||||||
|
// with a body of 4 bytes whose length it does not announce, so that its
|
||||||
|
// request_bytes is not its content_length, and no header the log line
|
||||||
|
// looks at, and is answered with 204 and no header.
|
||||||
|
conn = dial(t, addr)
|
||||||
|
send(t, conn, "POST /bare HTTP/1.1\r\nHost: "+appHost+"\r\n"+
|
||||||
|
"Transfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n")
|
||||||
|
wantStatus(t, readResponse(t, conn), http.StatusNoContent)
|
||||||
|
|
||||||
|
bare := out.requestLines(t, 2)[1]
|
||||||
|
|
||||||
|
wantFullLine(t, full)
|
||||||
|
wantBareLine(t, bare)
|
||||||
|
|
||||||
|
for _, line := range []logLine{full, bare} {
|
||||||
|
got := <-received
|
||||||
|
if got != line.RequestID {
|
||||||
|
t.Errorf("the app received request id %q, the log line has %q",
|
||||||
|
got, line.RequestID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(out.text(), "secret") {
|
||||||
|
t.Errorf("a value of Authorization, Cookie or Set-Cookie is logged:\n%s",
|
||||||
|
out.text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantFullLine checks the log line of the request with every header the
|
||||||
|
// line looks at. Its timings are checked by TestTimingsAreInOrder.
|
||||||
|
func wantFullLine(t *testing.T, line logLine) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
|
||||||
|
|
||||||
|
want := withTimings(line, requestlog.Line{
|
||||||
|
Type: requestType, Time: line.Time, Instance: instance,
|
||||||
|
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
|
||||||
|
Host: appHost, Path: "/full", Protocol: protocol,
|
||||||
|
Status: http.StatusFound, RequestBytes: 3, ResponseBytes: 5,
|
||||||
|
RequestID: "from-traefik", PeerIP: localhost,
|
||||||
|
ForwardedFor: "198.51.100.7, " + ipv6Client, ClientGroup: ipv6Group,
|
||||||
|
ContentType: "application/x-www-form-urlencoded", ContentLength: 3,
|
||||||
|
RequestHeaders: headers, HasAuthorization: true, HasCookie: true,
|
||||||
|
ResponseContentType: "text/html", UpstreamStatus: http.StatusFound,
|
||||||
|
CacheControl: "no-store", Location: "/elsewhere",
|
||||||
|
Action: requestlog.ActionForward,
|
||||||
|
// Its 3 bytes in and 5 out, each way counted by default.
|
||||||
|
Counts: ratelimit.Counts{
|
||||||
|
Minute: 1, Hour: 1, Day: 1, MinuteBytes: 8, HourBytes: 8, DayBytes: 8,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !reflect.DeepEqual(line.Line, want) {
|
||||||
|
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantBareLine checks the log line of the request with none of them, and
|
||||||
|
// that the fields that do not apply to it are left out.
|
||||||
|
func wantBareLine(t *testing.T, line logLine) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
want := withTimings(line, requestlog.Line{
|
||||||
|
Type: requestType, Time: line.Time, Instance: instance,
|
||||||
|
ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
|
||||||
|
Host: appHost, Path: "/bare", Protocol: protocol,
|
||||||
|
Status: http.StatusNoContent, RequestBytes: 4, RequestID: line.RequestID,
|
||||||
|
PeerIP: localhost, ClientGroup: localhost + "/32",
|
||||||
|
UpstreamStatus: http.StatusNoContent, Action: requestlog.ActionForward,
|
||||||
|
})
|
||||||
|
if !reflect.DeepEqual(line.Line, want) || line.RequestID == "" {
|
||||||
|
t.Errorf("log line\n%+v\nwant\n%+v, with a request id", line.Line, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{
|
||||||
|
"forwarded_for", "content_type", "content_length", "request_headers",
|
||||||
|
"has_authorization", "has_cookie", "websocket", "response_content_type",
|
||||||
|
"cache_control", "location", "counts",
|
||||||
|
} {
|
||||||
|
_, present := line.fields[name]
|
||||||
|
if present {
|
||||||
|
t.Errorf("log line has %s, which does not apply", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withTimings returns want with the timings of line.
|
||||||
|
func withTimings(line logLine, want requestlog.Line) requestlog.Line {
|
||||||
|
want.DurationTotal = line.DurationTotal
|
||||||
|
want.DurationChecks = line.DurationChecks
|
||||||
|
want.DurationUpstreamConnect = line.DurationUpstreamConnect
|
||||||
|
want.DurationUpstreamFirstByte = line.DurationUpstreamFirstByte
|
||||||
|
want.DurationUpstreamTotal = line.DurationUpstreamTotal
|
||||||
|
|
||||||
|
return want
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const hasAuthorization, hasCookie = "has_authorization", "has_cookie"
|
||||||
|
|
||||||
|
for _, tc := range []struct{ header, field, other string }{
|
||||||
|
{"Authorization", hasAuthorization, hasCookie},
|
||||||
|
{"Cookie", hasCookie, hasAuthorization},
|
||||||
|
} {
|
||||||
|
t.Run("only "+tc.header, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
|
addr, out := startProxy(t, app.URL, nil)
|
||||||
|
|
||||||
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
|
req.Header.Set(tc.header, "secret")
|
||||||
|
wantStatus(t, do(t, req), http.StatusOK)
|
||||||
|
|
||||||
|
line := out.requestLine(t)
|
||||||
|
|
||||||
|
_, otherPresent := line.fields[tc.other]
|
||||||
|
if line.fields[tc.field] != true || otherPresent {
|
||||||
|
t.Errorf("log line has %s %v and %s %v, want true and none",
|
||||||
|
tc.field, line.fields[tc.field], tc.other, line.fields[tc.other])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||||
|
|
||||||
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
// The pauses set the times apart; a hold-up of the test only
|
||||||
|
// lengthens them.
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = http.NewResponseController(w).Flush()
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
|
||||||
|
_, _ = io.WriteString(w, "done")
|
||||||
|
})
|
||||||
|
addr, out := startProxy(t, app.URL, map[string]string{
|
||||||
|
trustedProxies: trustLocalhost,
|
||||||
|
denyNets: denied,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each log line is waited for before the next request, so that the
|
||||||
|
// lines are in order.
|
||||||
|
wantStatus(t, get(t, addr, "/"), http.StatusOK)
|
||||||
|
forwarded := out.requestLines(t, 1)[0]
|
||||||
|
|
||||||
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
|
req.Header.Set(forwardedFor, denied)
|
||||||
|
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||||
|
refused := out.requestLines(t, 2)[1]
|
||||||
|
|
||||||
|
wantStatus(t, get(t, addr, proxy.HealthPath), http.StatusOK)
|
||||||
|
health := out.requestLines(t, 3)[2]
|
||||||
|
|
||||||
|
// A request passed to the app has every timing; one refused, none of
|
||||||
|
// the app's; the health check, which runs no check, only the total.
|
||||||
|
wantTimings(t, forwarded, "duration_total", "duration_checks",
|
||||||
|
"duration_upstream_connect", "duration_upstream_first_byte",
|
||||||
|
"duration_upstream_total")
|
||||||
|
wantTimings(t, refused, "duration_total", "duration_checks")
|
||||||
|
wantTimings(t, health, "duration_total")
|
||||||
|
|
||||||
|
if t.Failed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// In whole microseconds, as they are logged, so that the sum below is
|
||||||
|
// exact.
|
||||||
|
total := microseconds(forwarded.DurationTotal)
|
||||||
|
checks := microseconds(*forwarded.DurationChecks)
|
||||||
|
connect := microseconds(*forwarded.DurationUpstreamConnect)
|
||||||
|
firstByte := microseconds(*forwarded.DurationUpstreamFirstByte)
|
||||||
|
upstream := microseconds(*forwarded.DurationUpstreamTotal)
|
||||||
|
|
||||||
|
// The checks end before the request is handed to the app, and the
|
||||||
|
// connection comes before the answer, which the app ends after a
|
||||||
|
// pause.
|
||||||
|
if checks+upstream > total || connect >= firstByte || firstByte >= 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 {
|
||||||
|
t.Errorf("refused request's checks took %v of %v milliseconds",
|
||||||
|
*refused.DurationChecks, refused.DurationTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantTimings checks that the timings named are the only ones line has.
|
||||||
|
func wantTimings(t *testing.T, line logLine, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
|
||||||
|
for name := range line.fields {
|
||||||
|
if strings.HasPrefix(name, "duration_") {
|
||||||
|
got = append(got, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(got)
|
||||||
|
slices.Sort(want)
|
||||||
|
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("log line of %s has timings %v, want %v", line.Path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// microseconds is a timing in whole microseconds.
|
||||||
|
func microseconds(milliseconds float64) int64 {
|
||||||
|
return int64(math.Round(milliseconds * 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogsAnUpgradedConnection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app := startApp(t, echoAfterUpgrade)
|
||||||
|
addr, out := startProxy(t, app.URL, nil)
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
||||||
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||||
|
wantStatus(t, readResponse(t, conn), http.StatusSwitchingProtocols)
|
||||||
|
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
line := out.requestLine(t)
|
||||||
|
if line.fields["websocket"] != true {
|
||||||
|
t.Errorf("log line has websocket %v, want true", line.fields["websocket"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package proxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testRules are the rules most tests here load: a block rule for
|
||||||
|
// /blocked and a ban rule for /.env.
|
||||||
|
const testRules = `
|
||||||
|
blocked path block ^/blocked$
|
||||||
|
probe path ban ^/\.env$
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestEachRuleAction(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server := startWithClock(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
|
||||||
|
banResponse: "429",
|
||||||
|
})
|
||||||
|
start := clk.Now()
|
||||||
|
|
||||||
|
// A log rule notes its match, and lets the request through.
|
||||||
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantRuleIDs(t, line, "noted")
|
||||||
|
|
||||||
|
// A block rule refuses with 403, whatever SWWAF_BAN_RESPONSE is, and
|
||||||
|
// bans no one.
|
||||||
|
line = s.request(client, "/blocked", http.StatusForbidden,
|
||||||
|
requestlog.ActionRuleBlocked)
|
||||||
|
wantRuleIDs(t, line, "noted", "blocked")
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
// A ban rule refuses with SWWAF_BAN_RESPONSE, and bans the client for
|
||||||
|
// seven days, the default.
|
||||||
|
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||||
|
wantRuleIDs(t, line, "noted", "probe")
|
||||||
|
|
||||||
|
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
|
||||||
|
t.Errorf("log line has ban_expires %q, want seven days on", line.BanExpires)
|
||||||
|
}
|
||||||
|
|
||||||
|
netblock := netip.MustParsePrefix(client + "/32")
|
||||||
|
want := bans.Ban{
|
||||||
|
Netblock: netblock,
|
||||||
|
Start: start,
|
||||||
|
Expires: start.Add(7 * 24 * time.Hour),
|
||||||
|
Cause: bans.CauseAttack,
|
||||||
|
Reason: "matched the rule probe",
|
||||||
|
Notes: bans.Notes{
|
||||||
|
RuleID: "probe",
|
||||||
|
Target: "path",
|
||||||
|
Request: bans.Request{
|
||||||
|
Time: start,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Host: appHost,
|
||||||
|
Path: "/.env",
|
||||||
|
Status: http.StatusTooManyRequests,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
},
|
||||||
|
// The four requests up to and including the probe.
|
||||||
|
Requests: 4,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := server.Ledger.Bans(netblock)
|
||||||
|
if len(got) != 1 || got[0] != want {
|
||||||
|
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next request is refused under the ban, without being checked
|
||||||
|
// against the rules, and makes the ban permanent.
|
||||||
|
clk.advance(time.Hour)
|
||||||
|
|
||||||
|
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||||
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
|
if line.BanExpires != permanent {
|
||||||
|
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
||||||
|
}
|
||||||
|
|
||||||
|
clk.advance(365 * 24 * time.Hour)
|
||||||
|
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, testRules),
|
||||||
|
attackBanDuration: "1h",
|
||||||
|
})
|
||||||
|
|
||||||
|
// The first probe bans for SWWAF_ATTACK_BAN_DURATION.
|
||||||
|
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
|
||||||
|
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once that ban has run out without a request, the client is served,
|
||||||
|
// and its next probe bans it for good.
|
||||||
|
clk.advance(time.Hour)
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
if line.BanExpires != permanent {
|
||||||
|
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
||||||
|
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||||
|
)
|
||||||
|
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, testRules),
|
||||||
|
allowNets: allowed,
|
||||||
|
rateLimitExemptNets: exempt,
|
||||||
|
rateLimitPerMinute: "1",
|
||||||
|
})
|
||||||
|
|
||||||
|
// A client in SWWAF_ALLOW_NETS is not checked.
|
||||||
|
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
|
// A probe over the rate limit breaks the limit before any rule sees
|
||||||
|
// it.
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
|
limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||||
|
if len(limitBan) != 1 || limitBan[0].Cause != bans.CauseLimit {
|
||||||
|
t.Errorf("bans %+v, want one for a broken limit", limitBan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client the rate limits do not apply to is still checked.
|
||||||
|
s.get(exempt, http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, testRules),
|
||||||
|
mode: observe,
|
||||||
|
})
|
||||||
|
|
||||||
|
line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
|
||||||
|
wantRuleIDs(t, line, "blocked")
|
||||||
|
|
||||||
|
line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||||
|
wantRuleIDs(t, line, "probe")
|
||||||
|
|
||||||
|
if line.BanExpires != "" {
|
||||||
|
t.Errorf("log line has ban_expires %q, want none", line.BanExpires)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No ban was made.
|
||||||
|
line = s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantWouldAction(t, line, "")
|
||||||
|
|
||||||
|
if got := server.Ledger.Snapshot(); len(got) != 0 {
|
||||||
|
t.Errorf("bans %+v, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const scraper = "192.0.2.200"
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, testRules),
|
||||||
|
metricsToken: token,
|
||||||
|
})
|
||||||
|
|
||||||
|
s.request(client, "/blocked", http.StatusForbidden, requestlog.ActionRuleBlocked)
|
||||||
|
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
|
metrics := s.scrape(scraper)
|
||||||
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
|
||||||
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
|
||||||
|
wantMetric(t, metrics, `smallwebwaf_rules_loaded{instance="app"}`, 2)
|
||||||
|
wantMetric(t, metrics, `smallwebwaf_requests_total{action="rule_blocked",`+
|
||||||
|
`instance="app",status_class="4xx"}`, 1)
|
||||||
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack",instance="app"}`, 1)
|
||||||
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 0)
|
||||||
|
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRules writes content as a rule file into a new directory, and
|
||||||
|
// returns the directory.
|
||||||
|
func writeRules(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
err := os.WriteFile(filepath.Join(dir, "test.rules"), []byte(content), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write the rule file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantRuleIDs checks the request log line's rule_ids.
|
||||||
|
func wantRuleIDs(t *testing.T, line logLine, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if !slices.Equal(line.RuleIDs, want) {
|
||||||
|
t.Errorf("log line has rule_ids %v, want %v", line.RuleIDs, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkRules checks the request against the rules of the rule files at
|
||||||
|
// now, notes the ids of those it matches in the log line, and returns the
|
||||||
|
// action of the rule that refuses it, ActionRuleBlocked for a block rule
|
||||||
|
// and ActionBanned for a ban rule, or "" when none does. A ban rule bans
|
||||||
|
// the client's netblock for a clear sign of attack, or in observe mode
|
||||||
|
// raises the alert for the ban it would have made. Either rule's match
|
||||||
|
// is noted as an offence, for the client's history.
|
||||||
|
func (rq *request) checkRules(now time.Time) string {
|
||||||
|
matched := rq.h.rules.Match(rq.in)
|
||||||
|
|
||||||
|
for _, rule := range matched {
|
||||||
|
rq.line.RuleIDs = append(rq.line.RuleIDs, rule.ID)
|
||||||
|
rq.h.metrics.RuleMatched(rule.ID, rule.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matched) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the last rule matched can refuse the request.
|
||||||
|
switch last := matched[len(matched)-1]; last.Action {
|
||||||
|
case rules.ActionBlock:
|
||||||
|
rq.ruleBlocked = true
|
||||||
|
|
||||||
|
return requestlog.ActionRuleBlocked
|
||||||
|
case rules.ActionBan:
|
||||||
|
rq.attack = true
|
||||||
|
rq.banForAttack(now, last)
|
||||||
|
|
||||||
|
return requestlog.ActionBanned
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,10 +16,10 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
|
|||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
for i, r := range []ratelimit.Request{
|
for i, r := range []ratelimit.Request{
|
||||||
{Country: "DE", Forwarded: true, Status: 200, RequestBytes: 10, ResponseBytes: 100},
|
{Forwarded: true, Status: 200, RequestBytes: 10, ResponseBytes: 100},
|
||||||
{Forwarded: true, Status: 101},
|
{Forwarded: true, Status: 101},
|
||||||
{Forwarded: true, Status: 304, RequestBytes: 5},
|
{Forwarded: true, Status: 304, RequestBytes: 5},
|
||||||
{Country: "FR", Refused: true, Status: 403, ResponseBytes: 10, BrokeLimit: true},
|
{Refused: true, Status: 403, ResponseBytes: 10, BrokeLimit: true},
|
||||||
{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},
|
||||||
@@ -33,8 +33,6 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
|
|||||||
want := ratelimit.History{
|
want := ratelimit.History{
|
||||||
FirstSeen: start,
|
FirstSeen: start,
|
||||||
LastSeen: start.Add(6 * time.Minute),
|
LastSeen: start.Add(6 * time.Minute),
|
||||||
Country: "FR",
|
|
||||||
LookedUp: start.Add(3 * time.Minute),
|
|
||||||
Requests: 7,
|
Requests: 7,
|
||||||
Forwarded: 4,
|
Forwarded: 4,
|
||||||
Refused: 2,
|
Refused: 2,
|
||||||
@@ -52,6 +50,43 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLookupReachesTheHistoryOfAClientInTheTable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(ratelimit.Limits{})
|
||||||
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
other := netip.MustParsePrefix("198.51.100.7/32")
|
||||||
|
start := midnight()
|
||||||
|
|
||||||
|
limiter.AddToHistory(client, start, ratelimit.Request{Forwarded: true})
|
||||||
|
limiter.AddLookup(client, start, "AS64496", "Example Net", "DE")
|
||||||
|
|
||||||
|
// A later answer replaces it, and one for a client the table does not
|
||||||
|
// hold adds no client.
|
||||||
|
limiter.AddLookup(client, start.Add(time.Hour), "AS64497", "Other Net", "FR")
|
||||||
|
limiter.AddLookup(other, start, "AS64496", "Example Net", "DE")
|
||||||
|
|
||||||
|
want := ratelimit.History{
|
||||||
|
FirstSeen: start,
|
||||||
|
LastSeen: start,
|
||||||
|
ASN: "AS64497",
|
||||||
|
ASName: "Other Net",
|
||||||
|
Country: "FR",
|
||||||
|
LookedUp: start.Add(time.Hour),
|
||||||
|
Requests: 1,
|
||||||
|
Forwarded: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := historyOf(t, limiter, client)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("history\n%+v\nwant\n%+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if clients := limiter.Snapshot(); len(clients) != 1 {
|
||||||
|
t.Errorf("the table holds %+v, want %s alone", clients, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResetKeepsTheHistory(t *testing.T) {
|
func TestResetKeepsTheHistory(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+234
-70
@@ -1,9 +1,9 @@
|
|||||||
// Package ratelimit keeps the table of clients: each client's requests
|
// Package ratelimit keeps the table of clients: each client's requests
|
||||||
// counted over a minute, an hour and a day, as the "Counting method"
|
// and bytes counted over a minute, an hour and a day, as the "Counting
|
||||||
// section of SPEC.md describes, which tell when a request takes the client
|
// method" section of SPEC.md describes, which tell when a request takes
|
||||||
// over a rate limit, and each client's history since it was first seen.
|
// the client over a rate limit or a byte limit, and each client's history
|
||||||
// At most 20,000 clients are kept, in memory, and written to clients.json
|
// since it was first seen. At most 20,000 clients are kept, in memory, and
|
||||||
// and read from it by the state package.
|
// written to clients.json and read from it by the state package.
|
||||||
package ratelimit
|
package ratelimit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -23,19 +23,30 @@ const maxClients = 20000
|
|||||||
|
|
||||||
const day = 24 * time.Hour
|
const day = 24 * time.Hour
|
||||||
|
|
||||||
|
// The kinds of limits, as the metrics name them.
|
||||||
|
const (
|
||||||
|
// KindRequests is a rate limit, on a client's requests.
|
||||||
|
KindRequests = "requests"
|
||||||
|
// KindBytes is a byte limit, on a client's bytes.
|
||||||
|
KindBytes = "bytes"
|
||||||
|
)
|
||||||
|
|
||||||
// Limits are the most requests a client may make in a minute, an hour and
|
// Limits are the most requests a client may make in a minute, an hour and
|
||||||
// a day. Zero is no limit.
|
// a day, and the most bytes. Zero is no limit.
|
||||||
type Limits struct {
|
type Limits struct {
|
||||||
PerMinute int64
|
PerMinute int64
|
||||||
PerHour int64
|
PerHour int64
|
||||||
PerDay int64
|
PerDay int64
|
||||||
|
BytesPerMinute int64
|
||||||
|
BytesPerHour int64
|
||||||
|
BytesPerDay int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limiter counts each client's requests against the limits, and keeps
|
// Limiter counts each client's requests and bytes against the limits, and
|
||||||
// its history. It is safe for concurrent use.
|
// keeps its history. It is safe for concurrent use.
|
||||||
type Limiter struct {
|
type Limiter struct {
|
||||||
// windows are the minute, the hour and the day, in the order of
|
// windows are the minute, the hour and the day, in the order of
|
||||||
// Client.buckets.
|
// Client.buckets and Client.byteBuckets.
|
||||||
windows [3]window
|
windows [3]window
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -43,17 +54,23 @@ type Limiter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Client is a client in the table, as clients.json holds it: its buckets
|
// Client is a client in the table, as clients.json holds it: its buckets
|
||||||
// in each window, and its history.
|
// of requests and of bytes in each window, and its history.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||||
type Client struct {
|
type Client struct {
|
||||||
Client netip.Prefix `json:"client"`
|
Client netip.Prefix `json:"client"`
|
||||||
Minute Buckets `json:"minute"`
|
Minute Buckets `json:"minute"`
|
||||||
Hour Buckets `json:"hour"`
|
Hour Buckets `json:"hour"`
|
||||||
Day Buckets `json:"day"`
|
Day Buckets `json:"day"`
|
||||||
|
MinuteBytes Buckets `json:"minute_bytes"`
|
||||||
|
HourBytes Buckets `json:"hour_bytes"`
|
||||||
|
DayBytes Buckets `json:"day_bytes"`
|
||||||
History History `json:"history"`
|
History History `json:"history"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buckets are a client's two buckets in one window: the requests in the
|
// Buckets are a client's two buckets in one window: the requests, or the
|
||||||
// bucket under way, which began at Start, and in the bucket before it.
|
// bytes, in the bucket under way, which began at Start, and in the bucket
|
||||||
|
// before it.
|
||||||
type Buckets struct {
|
type Buckets struct {
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
Current int64 `json:"current"`
|
Current int64 `json:"current"`
|
||||||
@@ -66,8 +83,12 @@ type Buckets struct {
|
|||||||
type History struct {
|
type History struct {
|
||||||
FirstSeen time.Time `json:"first_seen"`
|
FirstSeen time.Time `json:"first_seen"`
|
||||||
LastSeen time.Time `json:"last_seen"`
|
LastSeen time.Time `json:"last_seen"`
|
||||||
// Country is the client's country as it was last looked up, and
|
// ASN, ASName and Country are the client's AS number, AS name and
|
||||||
// LookedUp when that was; both are empty while it never was.
|
// country as last looked up, each empty when the lookup could not
|
||||||
|
// find it, and LookedUp is when the lookup gave that answer; all are
|
||||||
|
// empty while the client never was looked up.
|
||||||
|
ASN string `json:"asn,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 passed to
|
||||||
@@ -96,15 +117,19 @@ type Responses struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Offences are a client's offences, by kind.
|
// Offences are a client's offences, by kind.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||||
type Offences struct {
|
type Offences struct {
|
||||||
// Limit is its requests that broke a rate limit.
|
// Limit is its requests that broke a rate limit or a byte limit,
|
||||||
|
// Attack those that matched a ban rule, a clear sign of attack, and
|
||||||
|
// RuleBlocked those a block rule refused.
|
||||||
Limit int64 `json:"limit"`
|
Limit int64 `json:"limit"`
|
||||||
|
Attack int64 `json:"attack"`
|
||||||
|
RuleBlocked int64 `json:"rule_blocked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
// Country is the client's country, when the request looked it up.
|
|
||||||
Country string
|
|
||||||
// Forwarded is true for a request passed to the app, Refused for one
|
// Forwarded is true for a request passed to the app, Refused for one
|
||||||
// refused before anything reached it, a 401 at smallwebwaf's own
|
// refused before anything reached it, a 401 at smallwebwaf's own
|
||||||
// endpoints included. Both are false for any other request smallwebwaf
|
// endpoints included. Both are false for any other request smallwebwaf
|
||||||
@@ -117,8 +142,12 @@ type Request struct {
|
|||||||
// and of its response.
|
// and of its response.
|
||||||
RequestBytes int64
|
RequestBytes int64
|
||||||
ResponseBytes int64
|
ResponseBytes int64
|
||||||
// BrokeLimit is true for a request that broke a rate limit.
|
// BrokeLimit is true for a request that broke a rate limit or a byte
|
||||||
|
// limit, Attack for one that matched a ban rule, and RuleBlocked for
|
||||||
|
// one a block rule refused.
|
||||||
BrokeLimit bool
|
BrokeLimit bool
|
||||||
|
Attack bool
|
||||||
|
RuleBlocked bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Limiter for limits, with no client counted yet.
|
// New returns a Limiter for limits, with no client counted yet.
|
||||||
@@ -130,49 +159,74 @@ func New(limits Limits) *Limiter {
|
|||||||
|
|
||||||
return &Limiter{
|
return &Limiter{
|
||||||
windows: [3]window{
|
windows: [3]window{
|
||||||
{name: "minute", length: time.Minute, limit: limits.PerMinute},
|
{
|
||||||
{name: "hour", length: time.Hour, limit: limits.PerHour},
|
name: "minute", length: time.Minute,
|
||||||
{name: "day", length: day, limit: limits.PerDay},
|
limit: limits.PerMinute, byteLimit: limits.BytesPerMinute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hour", length: time.Hour,
|
||||||
|
limit: limits.PerHour, byteLimit: limits.BytesPerHour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "day", length: day,
|
||||||
|
limit: limits.PerDay, byteLimit: limits.BytesPerDay,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
clients: clients,
|
clients: clients,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hit is a request that takes a client over a rate limit.
|
// Hit is a request that takes a client over a rate limit, or whose bytes
|
||||||
|
// take it over a byte limit.
|
||||||
type Hit struct {
|
type Hit struct {
|
||||||
|
// Kind is KindRequests for a rate limit, KindBytes for a byte limit.
|
||||||
|
Kind string
|
||||||
// Window is "minute", "hour" or "day".
|
// Window is "minute", "hour" or "day".
|
||||||
Window string
|
Window string
|
||||||
// Limit is the window's limit.
|
// Limit is the window's limit, as the client's percentage of it.
|
||||||
Limit int64
|
Limit int64
|
||||||
// Requests is the client's requests counted in the window, this one
|
// Count is the client's requests, or bytes, counted in the window,
|
||||||
// included.
|
// this request's included.
|
||||||
Requests float64
|
Count float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts are a client's requests and bytes in the minute, the hour and
|
||||||
|
// the day that end at a request, that request's included.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // SPEC.md's request log names its fields in snake_case
|
||||||
|
type Counts struct {
|
||||||
|
Minute float64 `json:"minute"`
|
||||||
|
Hour float64 `json:"hour"`
|
||||||
|
Day float64 `json:"day"`
|
||||||
|
MinuteBytes float64 `json:"minute_bytes"`
|
||||||
|
HourBytes float64 `json:"hour_bytes"`
|
||||||
|
DayBytes float64 `json:"day_bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count counts a request from client at now, in every window, whether or
|
// Count counts a request from client at now, in every window, whether or
|
||||||
// not it is refused. It reports whether the request takes the client over
|
// not it is refused, and returns the client's counts in each window. It
|
||||||
// a limit, and the window whose limit it goes over, the shortest if it is
|
// reports whether the request takes the client over a rate limit, of
|
||||||
// over several.
|
// which the client gets the percentage percent, rounded down, and the hit:
|
||||||
func (l *Limiter) Count(client netip.Prefix, now time.Time) (Hit, bool) {
|
// the window whose limit it goes over, the shortest if it is over
|
||||||
l.mu.Lock()
|
// several. A limit that is off stays off.
|
||||||
defer l.mu.Unlock()
|
func (l *Limiter) Count(
|
||||||
|
client netip.Prefix, now time.Time, percent int64,
|
||||||
var hit Hit
|
) (Counts, Hit, bool) {
|
||||||
|
return l.count(client, now, 1, 0, percent)
|
||||||
for i, b := range l.get(client).buckets() {
|
|
||||||
w := l.windows[i]
|
|
||||||
|
|
||||||
requests := b.add(now, w.length)
|
|
||||||
if hit.Window == "" && w.limit > 0 && requests > float64(w.limit) {
|
|
||||||
hit = Hit{Window: w.name, Limit: w.limit, Requests: requests}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return hit, hit.Window != ""
|
// 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.
|
||||||
|
// It reports whether the bytes take the client over a byte limit, of which
|
||||||
|
// the client gets the percentage percent, and the hit, as Count does.
|
||||||
|
func (l *Limiter) CountBytes(
|
||||||
|
client netip.Prefix, now time.Time, bytes, percent int64,
|
||||||
|
) (Counts, Hit, bool) {
|
||||||
|
return l.count(client, now, 0, bytes, percent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset sets client's counts in every window back to zero. Its history
|
// Reset sets client's counts of requests and of bytes in every window
|
||||||
// keeps its totals.
|
// back to zero. Its history keeps its totals.
|
||||||
func (l *Limiter) Reset(client netip.Prefix) {
|
func (l *Limiter) Reset(client netip.Prefix) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
@@ -180,6 +234,7 @@ func (l *Limiter) Reset(client netip.Prefix) {
|
|||||||
c, seen := l.clients.Peek(client)
|
c, seen := l.clients.Peek(client)
|
||||||
if seen {
|
if seen {
|
||||||
c.Minute, c.Hour, c.Day = Buckets{}, Buckets{}, Buckets{}
|
c.Minute, c.Hour, c.Day = Buckets{}, Buckets{}, Buckets{}
|
||||||
|
c.MinuteBytes, c.HourBytes, c.DayBytes = Buckets{}, Buckets{}, Buckets{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,11 +251,6 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
|
|||||||
|
|
||||||
h.LastSeen = now
|
h.LastSeen = now
|
||||||
|
|
||||||
if r.Country != "" {
|
|
||||||
h.Country = r.Country
|
|
||||||
h.LookedUp = now
|
|
||||||
}
|
|
||||||
|
|
||||||
h.Requests++
|
h.Requests++
|
||||||
if r.Forwarded {
|
if r.Forwarded {
|
||||||
h.Forwarded++
|
h.Forwarded++
|
||||||
@@ -217,6 +267,33 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
|
|||||||
if r.BrokeLimit {
|
if r.BrokeLimit {
|
||||||
h.Offences.Limit++
|
h.Offences.Limit++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if r.Attack {
|
||||||
|
h.Offences.Attack++
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.RuleBlocked {
|
||||||
|
h.Offences.RuleBlocked++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddLookup gives client's history its AS number, AS name and country, as
|
||||||
|
// the lookup gave them at lookedUp, if the table of clients holds the
|
||||||
|
// client.
|
||||||
|
// It does not make the client the most recently seen.
|
||||||
|
func (l *Limiter) AddLookup(
|
||||||
|
client netip.Prefix, lookedUp time.Time, asn, asName, country string,
|
||||||
|
) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
c, held := l.clients.Peek(client)
|
||||||
|
if !held {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &c.History
|
||||||
|
h.ASN, h.ASName, h.Country, h.LookedUp = asn, asName, country, lookedUp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Requests returns how many requests the clients inside netblock have
|
// Requests returns how many requests the clients inside netblock have
|
||||||
@@ -242,6 +319,20 @@ func (l *Limiter) Requests(netblock netip.Prefix) int64 {
|
|||||||
return requests
|
return requests
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Client returns client as the table holds it, and whether it does. It is
|
||||||
|
// not a request from client, and leaves when it was last seen unchanged.
|
||||||
|
func (l *Limiter) Client(client netip.Prefix) (Client, bool) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
c, seen := l.clients.Peek(client)
|
||||||
|
if !seen {
|
||||||
|
return Client{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return *c, true
|
||||||
|
}
|
||||||
|
|
||||||
// Len returns how many clients are in the table.
|
// Len returns how many clients are in the table.
|
||||||
func (l *Limiter) Len() int {
|
func (l *Limiter) Len() int {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
@@ -269,32 +360,79 @@ func (l *Limiter) Snapshot() []Client {
|
|||||||
return clients
|
return clients
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load puts clients read from clients.json into a table that holds none
|
// Load puts clients read from clients.json into the table, in place of
|
||||||
// yet, in the order they were last seen, so that the least recently seen
|
// the clients it holds, in the order they were last seen, so that the
|
||||||
// is dropped first. Buckets whose time has passed at now are emptied.
|
// least recently seen is dropped first. Buckets whose time has passed at
|
||||||
|
// now are emptied.
|
||||||
func (l *Limiter) Load(clients []Client, now time.Time) {
|
func (l *Limiter) Load(clients []Client, now time.Time) {
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
|
|
||||||
clients = slices.Clone(clients)
|
clients = slices.Clone(clients)
|
||||||
slices.SortStableFunc(clients, func(a, b Client) int {
|
slices.SortStableFunc(clients, func(a, b Client) int {
|
||||||
return a.History.LastSeen.Compare(b.History.LastSeen)
|
return a.History.LastSeen.Compare(b.History.LastSeen)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
l.clients.Purge()
|
||||||
|
|
||||||
for _, c := range clients {
|
for _, c := range clients {
|
||||||
for i, b := range c.buckets() {
|
for i, w := range l.windows {
|
||||||
// The window that ends at now covers neither bucket once it
|
for _, b := range []*Buckets{c.buckets()[i], c.byteBuckets()[i]} {
|
||||||
// begins after the bucket under way has ended.
|
if b.Passed(now, w.length) {
|
||||||
length := l.windows[i].length
|
|
||||||
if !now.Add(-length).Before(b.Start.Add(length)) {
|
|
||||||
*b = Buckets{}
|
*b = Buckets{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
l.clients.Add(c.Client, &c)
|
l.clients.Add(c.Client, &c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// count adds requests and bytes from client at now to its buckets in
|
||||||
|
// every window, and returns its counts. A limit is broken only by what is
|
||||||
|
// added to it, so that a request whose bytes are counted after another of
|
||||||
|
// the client's requests broke a rate limit does not break it too. The
|
||||||
|
// client gets the percentage percent of each limit.
|
||||||
|
func (l *Limiter) count(
|
||||||
|
client netip.Prefix, now time.Time, requests, bytes, percent int64,
|
||||||
|
) (Counts, Hit, bool) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
c := l.get(client)
|
||||||
|
requestBuckets, byteBuckets := c.buckets(), c.byteBuckets()
|
||||||
|
|
||||||
|
var (
|
||||||
|
requestCounts, byteCounts [3]float64
|
||||||
|
hit Hit
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, w := range l.windows {
|
||||||
|
requestCounts[i] = requestBuckets[i].Add(now, w.length, requests)
|
||||||
|
byteCounts[i] = byteBuckets[i].Add(now, w.length, bytes)
|
||||||
|
limit, byteLimit := percentOf(w.limit, percent), percentOf(w.byteLimit, percent)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case hit.Window != "":
|
||||||
|
case requests > 0 && w.limit > 0 && requestCounts[i] > float64(limit):
|
||||||
|
hit = Hit{
|
||||||
|
Kind: KindRequests, Window: w.name, Limit: limit, Count: requestCounts[i],
|
||||||
|
}
|
||||||
|
case bytes > 0 && w.byteLimit > 0 && byteCounts[i] > float64(byteLimit):
|
||||||
|
hit = Hit{
|
||||||
|
Kind: KindBytes, Window: w.name, Limit: byteLimit, Count: byteCounts[i],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := Counts{
|
||||||
|
Minute: requestCounts[0], Hour: requestCounts[1], Day: requestCounts[2],
|
||||||
|
MinuteBytes: byteCounts[0], HourBytes: byteCounts[1], DayBytes: byteCounts[2],
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts, hit, hit.Window != ""
|
||||||
|
}
|
||||||
|
|
||||||
// get returns client's entry in the table, a new one if it has none, and
|
// get returns client's entry in the table, a new one if it has none, and
|
||||||
// makes it the most recently seen.
|
// makes it the most recently seen.
|
||||||
func (l *Limiter) get(client netip.Prefix) *Client {
|
func (l *Limiter) get(client netip.Prefix) *Client {
|
||||||
@@ -307,30 +445,48 @@ func (l *Limiter) get(client netip.Prefix) *Client {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// buckets returns c's buckets in the minute, the hour and the day.
|
// buckets returns c's buckets of requests in the minute, the hour and the
|
||||||
|
// day.
|
||||||
func (c *Client) buckets() [3]*Buckets {
|
func (c *Client) buckets() [3]*Buckets {
|
||||||
return [3]*Buckets{&c.Minute, &c.Hour, &c.Day}
|
return [3]*Buckets{&c.Minute, &c.Hour, &c.Day}
|
||||||
}
|
}
|
||||||
|
|
||||||
// window is a length of time over which requests are counted, and the
|
// byteBuckets returns c's buckets of bytes in the minute, the hour and the
|
||||||
// most requests a client may make in it.
|
// day.
|
||||||
|
func (c *Client) byteBuckets() [3]*Buckets {
|
||||||
|
return [3]*Buckets{&c.MinuteBytes, &c.HourBytes, &c.DayBytes}
|
||||||
|
}
|
||||||
|
|
||||||
|
// window is a length of time over which requests and bytes are counted,
|
||||||
|
// and the most requests and the most bytes a client may have in it.
|
||||||
type window struct {
|
type window struct {
|
||||||
name string
|
name string
|
||||||
length time.Duration
|
length time.Duration
|
||||||
limit int64
|
limit int64
|
||||||
|
byteLimit int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// add counts a request at now in a window of length, and returns the
|
// percentOf returns the percentage percent of limit, rounded down. It is
|
||||||
// client's requests in the window that ends at now: those in the bucket
|
// written as limit's hundreds times percent, plus the rest's share, since
|
||||||
// under way, and those in the bucket before it weighted by how much of
|
// limit*percent can overflow for a byte limit.
|
||||||
// that bucket the window still covers.
|
func percentOf(limit, percent int64) int64 {
|
||||||
|
const hundred = 100
|
||||||
|
|
||||||
|
return limit/hundred*percent + limit%hundred*percent/hundred
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add counts n requests, or n bytes, at now in a window of length, and
|
||||||
|
// returns the count in the window that ends at now: what is in the bucket
|
||||||
|
// under way, and what is in the bucket before it weighted by how much of
|
||||||
|
// that bucket the window still covers. With n zero it counts nothing, and
|
||||||
|
// returns the count. The anomaly counters count in Buckets too.
|
||||||
//
|
//
|
||||||
// Concurrent requests can be counted out of order, so now can be a moment
|
// Concurrent requests can be counted out of order, so now can be a moment
|
||||||
// before the bucket under way began; such a request is counted in that
|
// before the bucket under way began; such a request is counted in that
|
||||||
// bucket. A request dated more than a second before it means the clock
|
// bucket. A request dated more than a second before it means the clock
|
||||||
// was set back, and the buckets start afresh: otherwise the bucket before
|
// was set back, and the buckets start afresh: otherwise the bucket before
|
||||||
// would keep its full weight until the clock caught up.
|
// would keep its full weight until the clock caught up.
|
||||||
func (b *Buckets) add(now time.Time, length time.Duration) float64 {
|
func (b *Buckets) Add(now time.Time, length time.Duration, n int64) float64 {
|
||||||
if now.Before(b.Start.Add(-time.Second)) {
|
if now.Before(b.Start.Add(-time.Second)) {
|
||||||
*b = Buckets{}
|
*b = Buckets{}
|
||||||
}
|
}
|
||||||
@@ -347,7 +503,7 @@ func (b *Buckets) add(now time.Time, length time.Duration) float64 {
|
|||||||
b.Current = 0
|
b.Current = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
b.Current++
|
b.Current += n
|
||||||
|
|
||||||
elapsed := max(now.Sub(b.Start), 0)
|
elapsed := max(now.Sub(b.Start), 0)
|
||||||
covered := 1 - float64(elapsed)/float64(length)
|
covered := 1 - float64(elapsed)/float64(length)
|
||||||
@@ -355,6 +511,14 @@ func (b *Buckets) add(now time.Time, length time.Duration) float64 {
|
|||||||
return float64(b.Previous)*covered + float64(b.Current)
|
return float64(b.Previous)*covered + float64(b.Current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Passed reports whether the window of length that ends at now covers
|
||||||
|
// neither of b's buckets: it begins after the bucket under way has ended.
|
||||||
|
// What they hold then counts no more, and a state file read at now drops
|
||||||
|
// it.
|
||||||
|
func (b *Buckets) Passed(now time.Time, length time.Duration) bool {
|
||||||
|
return !now.Add(-length).Before(b.Start.Add(length))
|
||||||
|
}
|
||||||
|
|
||||||
// add counts a response with status in its class. A status of 0, for
|
// add counts a response with status in its class. A status of 0, for
|
||||||
// nothing sent, is not a response.
|
// nothing sent, is not a response.
|
||||||
func (r *Responses) add(status int) {
|
func (r *Responses) add(status int) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ratelimit_test
|
package ratelimit_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,6 +12,10 @@ import (
|
|||||||
// limit is the limit the tests set.
|
// limit is the limit the tests set.
|
||||||
const limit = 3
|
const limit = 3
|
||||||
|
|
||||||
|
// whole is the percentage of each limit a client gets when nothing lowers
|
||||||
|
// its limits.
|
||||||
|
const whole = 100
|
||||||
|
|
||||||
// The windows, as Count names them.
|
// The windows, as Count names them.
|
||||||
const (
|
const (
|
||||||
minute = "minute"
|
minute = "minute"
|
||||||
@@ -62,22 +67,203 @@ func TestHitGivesTheLimitAndTheRequestsCounted(t *testing.T) {
|
|||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
for range limit {
|
for range limit {
|
||||||
_, over := limiter.Count(client, start)
|
_, _, over := limiter.Count(client, start, whole)
|
||||||
if over {
|
if over {
|
||||||
t.Fatal("a request within the limit is over it")
|
t.Fatal("a request within the limit is over it")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Over both limits; the minute's is named, with the four requests.
|
// Over both limits; the minute's is named, with the four requests.
|
||||||
hit, over := limiter.Count(client, start)
|
_, hit, over := limiter.Count(client, start, whole)
|
||||||
|
|
||||||
want := ratelimit.Hit{Window: minute, Limit: limit, Requests: limit + 1}
|
want := ratelimit.Hit{
|
||||||
|
Kind: ratelimit.KindRequests, Window: minute, Limit: limit, Count: limit + 1,
|
||||||
|
}
|
||||||
if !over || hit != want {
|
if !over || hit != want {
|
||||||
t.Errorf("request over the limit gives %+v and %t, want %+v and true",
|
t.Errorf("request over the limit gives %+v and %t, want %+v and true",
|
||||||
hit, over, want)
|
hit, over, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientGetsItsPercentageOfEachLimitRoundedDown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(ratelimit.Limits{PerMinute: 5, BytesPerDay: math.MaxInt64})
|
||||||
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
start := midnight()
|
||||||
|
|
||||||
|
// Half of 5 requests is 2.5, rounded down to 2: the third is over.
|
||||||
|
for range 2 {
|
||||||
|
_, _, over := limiter.Count(client, start, 50)
|
||||||
|
if over {
|
||||||
|
t.Fatal("a request within half the limit is over it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, hit, over := limiter.Count(client, start, 50)
|
||||||
|
|
||||||
|
want := ratelimit.Hit{Kind: ratelimit.KindRequests, Window: minute, Limit: 2, Count: 3}
|
||||||
|
if !over || hit != want {
|
||||||
|
t.Errorf("the third request gives %+v and %t, want %+v and true", hit, over, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Half of the largest byte limit is still far above a TiB: working it
|
||||||
|
// out does not overflow.
|
||||||
|
_, hit, over = limiter.CountBytes(client, start, 1<<40, 50)
|
||||||
|
if over {
|
||||||
|
t.Errorf("a TiB is over half the largest byte limit: %+v", hit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroPercentIsAZeroAllowanceAndALimitOffStaysOff(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Only the hour has limits: the minute's and the day's are off.
|
||||||
|
limiter := ratelimit.New(ratelimit.Limits{PerHour: limit, BytesPerHour: 1000})
|
||||||
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
start := midnight()
|
||||||
|
|
||||||
|
// At 0 percent, the first request and the first byte are over the
|
||||||
|
// hour's limits, which are 0; the minute's, which are off, stay off.
|
||||||
|
_, hit, _ := limiter.Count(client, start, 0)
|
||||||
|
|
||||||
|
want := ratelimit.Hit{Kind: ratelimit.KindRequests, Window: hour, Limit: 0, Count: 1}
|
||||||
|
if hit != want {
|
||||||
|
t.Errorf("the first request gives %+v, want %+v", hit, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, hit, _ = limiter.CountBytes(client, start, 1, 0)
|
||||||
|
|
||||||
|
want = ratelimit.Hit{Kind: ratelimit.KindBytes, Window: hour, Limit: 0, Count: 1}
|
||||||
|
if hit != want {
|
||||||
|
t.Errorf("the first byte gives %+v, want %+v", hit, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachByteLimitIsBrokenByTheBytesCounted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const byteLimit = 1000
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
window string
|
||||||
|
limits ratelimit.Limits
|
||||||
|
}{
|
||||||
|
{minute, ratelimit.Limits{BytesPerMinute: byteLimit}},
|
||||||
|
{hour, ratelimit.Limits{BytesPerHour: byteLimit}},
|
||||||
|
{"day", ratelimit.Limits{BytesPerDay: byteLimit}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.window, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(tc.limits)
|
||||||
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
|
// 600 bytes are within the limit, 600 more over it.
|
||||||
|
_, _, over := limiter.CountBytes(client, midnight(), 600, whole)
|
||||||
|
if over {
|
||||||
|
t.Fatal("600 bytes are over the limit of 1000")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, hit, over := limiter.CountBytes(client, midnight(), 600, whole)
|
||||||
|
|
||||||
|
want := ratelimit.Hit{
|
||||||
|
Kind: ratelimit.KindBytes, Window: tc.window, Limit: byteLimit, Count: 1200,
|
||||||
|
}
|
||||||
|
if !over || hit != want {
|
||||||
|
t.Errorf("1200 bytes give %+v and %t, want %+v and true", hit, over, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestALimitIsBrokenOnlyByWhatIsAddedToIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(ratelimit.Limits{PerMinute: 2, BytesPerMinute: 1000})
|
||||||
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
other := netip.MustParsePrefix("203.0.113.10/32")
|
||||||
|
start := midnight()
|
||||||
|
|
||||||
|
// The third request breaks the rate limit. The bytes of a request
|
||||||
|
// counted after it, within the byte limit, do not break it again.
|
||||||
|
for range 2 {
|
||||||
|
wantCount(t, limiter, client, start, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
wantCount(t, limiter, client, start, minute)
|
||||||
|
wantBytesCount(t, limiter, client, start, 500, "")
|
||||||
|
wantBytesCount(t, limiter, client, start, 600, ratelimit.KindBytes)
|
||||||
|
|
||||||
|
// Bytes over the byte limit do not have the next request break it, nor
|
||||||
|
// the rate limit, which that request is within.
|
||||||
|
wantBytesCount(t, limiter, other, start, 1200, ratelimit.KindBytes)
|
||||||
|
wantCount(t, limiter, other, start, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountGivesTheBytesInEachWindow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(ratelimit.Limits{})
|
||||||
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
start := midnight()
|
||||||
|
|
||||||
|
limiter.CountBytes(client, start, 300, whole)
|
||||||
|
|
||||||
|
// A quarter into the next hour, the minute has only these 100 bytes.
|
||||||
|
// The hour still covers three quarters of the bucket before, whose 300
|
||||||
|
// bytes count 225, and these: 325. The day covers all 400.
|
||||||
|
later := start.Add(time.Hour + time.Hour/4)
|
||||||
|
limiter.CountBytes(client, later, 100, whole)
|
||||||
|
|
||||||
|
// A request's counts give the bytes counted so far too.
|
||||||
|
counts, _, _ := limiter.Count(client, later, whole)
|
||||||
|
|
||||||
|
want := ratelimit.Counts{
|
||||||
|
Minute: 1, Hour: 1, Day: 1, MinuteBytes: 100, HourBytes: 325, DayBytes: 400,
|
||||||
|
}
|
||||||
|
if counts != want {
|
||||||
|
t.Errorf("counts %+v, want %+v", counts, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetSetsTheBytesBackToZero(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(ratelimit.Limits{BytesPerDay: 1000})
|
||||||
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
start := midnight()
|
||||||
|
|
||||||
|
wantBytesCount(t, limiter, client, start, 1200, ratelimit.KindBytes)
|
||||||
|
limiter.Reset(client)
|
||||||
|
|
||||||
|
// The client has its whole allowance of bytes again.
|
||||||
|
wantBytesCount(t, limiter, client, start, 1000, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountGivesTheRequestsInEachWindow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(ratelimit.Limits{})
|
||||||
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
start := midnight()
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
limiter.Count(client, start, whole)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A quarter into the next hour, the minute has only this request. The
|
||||||
|
// hour still covers three quarters of the bucket before, with its three
|
||||||
|
// requests, which count 2.25, and this one: 3.25. The day covers all
|
||||||
|
// four.
|
||||||
|
counts, _, _ := limiter.Count(client, start.Add(time.Hour+time.Hour/4), whole)
|
||||||
|
|
||||||
|
want := ratelimit.Counts{Minute: 1, Hour: 3.25, Day: 4}
|
||||||
|
if counts != want {
|
||||||
|
t.Errorf("counts %+v, want %+v", counts, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResetSetsTheCountsBackToZero(t *testing.T) {
|
func TestResetSetsTheCountsBackToZero(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -238,9 +424,24 @@ func wantCount(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
hit, _ := limiter.Count(client, now)
|
_, hit, _ := limiter.Count(client, now, whole)
|
||||||
if hit.Window != want {
|
if hit.Window != want {
|
||||||
t.Errorf("request from %s at %s is over %q, want %q",
|
t.Errorf("request from %s at %s is over %q, want %q",
|
||||||
client, now.Format(time.RFC3339), hit.Window, want)
|
client, now.Format(time.RFC3339), hit.Window, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wantBytesCount counts bytes from client at now, and checks the kind of
|
||||||
|
// the limit they break, "" for none.
|
||||||
|
func wantBytesCount(
|
||||||
|
t *testing.T, limiter *ratelimit.Limiter, client netip.Prefix, now time.Time,
|
||||||
|
bytes int64, want string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, hit, _ := limiter.CountBytes(client, now, bytes, whole)
|
||||||
|
if hit.Kind != want {
|
||||||
|
t.Errorf("%d bytes from %s at %s break a limit on %q, want %q",
|
||||||
|
bytes, client, now.Format(time.RFC3339), hit.Kind, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func TestSnapshotListsTheClientsByAddress(t *testing.T) {
|
|||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{})
|
limiter := ratelimit.New(ratelimit.Limits{})
|
||||||
for _, i := range []int{2, 3, 0, 1} {
|
for _, i := range []int{2, 3, 0, 1} {
|
||||||
limiter.Count(netip.MustParsePrefix(want[i]), midnight())
|
limiter.Count(netip.MustParsePrefix(want[i]), midnight(), whole)
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot := limiter.Snapshot()
|
snapshot := limiter.Snapshot()
|
||||||
@@ -63,7 +63,8 @@ func TestLoadEmptiesBucketsWhoseTimeHasPassed(t *testing.T) {
|
|||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{})
|
limiter := ratelimit.New(ratelimit.Limits{})
|
||||||
limiter.Count(client, start)
|
limiter.Count(client, start, whole)
|
||||||
|
limiter.CountBytes(client, start, 5, whole)
|
||||||
limiter.AddToHistory(client, start, ratelimit.Request{Forwarded: true})
|
limiter.AddToHistory(client, start, ratelimit.Request{Forwarded: true})
|
||||||
|
|
||||||
loaded := func(now time.Time) ratelimit.Client {
|
loaded := func(now time.Time) ratelimit.Client {
|
||||||
@@ -76,19 +77,25 @@ func TestLoadEmptiesBucketsWhoseTimeHasPassed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Two minutes on, the window that ends then covers neither of the
|
// Two minutes on, the window that ends then covers neither of the
|
||||||
// minute's buckets, which are emptied; the hour's and the day's stay,
|
// minute's buckets, of requests and of bytes, which are emptied; the
|
||||||
// and so does the history.
|
// hour's and the day's stay, and so does the history.
|
||||||
got := loaded(start.Add(2 * time.Minute))
|
got := loaded(start.Add(2 * time.Minute))
|
||||||
if got.Minute != (ratelimit.Buckets{}) || got.Hour.Current != 1 ||
|
if got.Minute != (ratelimit.Buckets{}) || got.Hour.Current != 1 ||
|
||||||
got.Day.Current != 1 || got.History.Requests != 1 {
|
got.Day.Current != 1 || got.History.Requests != 1 {
|
||||||
t.Errorf("loaded two minutes on as %+v", got)
|
t.Errorf("loaded two minutes on as %+v", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if got.MinuteBytes != (ratelimit.Buckets{}) || got.HourBytes.Current != 5 ||
|
||||||
|
got.DayBytes.Current != 5 {
|
||||||
|
t.Errorf("loaded two minutes on with buckets of bytes %+v, %+v and %+v",
|
||||||
|
got.MinuteBytes, got.HourBytes, got.DayBytes)
|
||||||
|
}
|
||||||
|
|
||||||
// A moment before, the window still covers some of the earlier one.
|
// A moment before, the window still covers some of the earlier one.
|
||||||
got = loaded(start.Add(2*time.Minute - time.Nanosecond))
|
got = loaded(start.Add(2*time.Minute - time.Nanosecond))
|
||||||
if got.Minute.Current != 1 {
|
if got.Minute.Current != 1 || got.MinuteBytes.Current != 5 {
|
||||||
t.Errorf("loaded just under two minutes on with minute buckets %+v",
|
t.Errorf("loaded just under two minutes on with minute buckets %+v and %+v",
|
||||||
got.Minute)
|
got.Minute, got.MinuteBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
// Package remotelog sends the lines smallwebwaf writes on stdout to the
|
||||||
|
// remote log endpoint, SWWAF_LOG_REMOTE_URL, as the "Request log" section
|
||||||
|
// of SPEC.md describes: each line as the message of an RFC 5424 syslog
|
||||||
|
// record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
|
||||||
|
// or unreachable endpoint never holds up a request or stdout.
|
||||||
|
package remotelog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The forms of SWWAF_LOG_REMOTE_URL, by its scheme.
|
||||||
|
const (
|
||||||
|
SchemeUDP = "syslog+udp"
|
||||||
|
SchemeTCP = "syslog+tcp"
|
||||||
|
SchemeTLS = "syslog+tls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A record's priority is the number of its facility times the number of
|
||||||
|
// severities there are, plus the number of its severity. Every record's
|
||||||
|
// severity is informational.
|
||||||
|
const (
|
||||||
|
severities = 8
|
||||||
|
informational = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// dialTimeout bounds connecting to the endpoint, the TLS handshake
|
||||||
|
// included.
|
||||||
|
dialTimeout = 10 * time.Second
|
||||||
|
// After a failed attempt to connect, or a connection on which a record
|
||||||
|
// fails, the next attempt to connect is made a second later, and
|
||||||
|
// retryDelayFactor times as long after each further failure in a row,
|
||||||
|
// up to a minute. A connection that fails after it has stayed up for
|
||||||
|
// resetRetryDelayAfter ends the row.
|
||||||
|
firstRetryDelay = time.Second
|
||||||
|
retryDelayFactor = 2
|
||||||
|
maxRetryDelay = time.Minute
|
||||||
|
resetRetryDelayAfter = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// Params are what New needs.
|
||||||
|
type Params struct {
|
||||||
|
// URL is the endpoint (SWWAF_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
|
||||||
|
// SchemeTLS, a host and a port.
|
||||||
|
URL *url.URL
|
||||||
|
// RootCAs are the certificates a SchemeTLS endpoint's certificate
|
||||||
|
// must chain to (SWWAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
||||||
|
RootCAs *x509.CertPool
|
||||||
|
// Buffer is the most lines held while they wait to be sent
|
||||||
|
// (SWWAF_LOG_REMOTE_BUFFER).
|
||||||
|
Buffer int
|
||||||
|
// Facility is the number of the records' syslog facility
|
||||||
|
// (SWWAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
||||||
|
// (SWWAF_LOG_REMOTE_APP_NAME).
|
||||||
|
Facility int
|
||||||
|
AppName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender sends lines to the endpoint. Write puts them in its buffer, and
|
||||||
|
// Run sends them from there.
|
||||||
|
type Sender struct {
|
||||||
|
url *url.URL
|
||||||
|
tlsConfig *tls.Config
|
||||||
|
// beforeTime and afterTime are the parts of every record's header
|
||||||
|
// before and after its time, as RFC 5424 lays the header out.
|
||||||
|
beforeTime string
|
||||||
|
afterTime string
|
||||||
|
// records is the buffer: each line's record, framed to be sent.
|
||||||
|
records chan []byte
|
||||||
|
sent atomic.Int64
|
||||||
|
dropped atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Sender for the endpoint params.URL.
|
||||||
|
func New(params Params) *Sender {
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil || hostname == "" {
|
||||||
|
hostname = "-" // RFC 5424's value for a field that has none
|
||||||
|
}
|
||||||
|
|
||||||
|
priority := params.Facility*severities + informational
|
||||||
|
|
||||||
|
return &Sender{
|
||||||
|
url: params.URL,
|
||||||
|
tlsConfig: &tls.Config{
|
||||||
|
RootCAs: params.RootCAs,
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
},
|
||||||
|
// The 1 is the version of the format. The process id, the message
|
||||||
|
// id and the structured data have no value.
|
||||||
|
beforeTime: "<" + strconv.Itoa(priority) + ">1 ",
|
||||||
|
afterTime: " " + hostname + " " + params.AppName + " - - - ",
|
||||||
|
records: make(chan []byte, params.Buffer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write puts each line in p in the buffer, as the message of a record of
|
||||||
|
// its own, and never waits: when the buffer is full, the oldest record in
|
||||||
|
// it is dropped to make room. It is safe for concurrent use.
|
||||||
|
func (s *Sender) Write(p []byte) (int, error) {
|
||||||
|
at := requestlog.FormatTime(time.Now())
|
||||||
|
|
||||||
|
for line := range bytes.Lines(p) {
|
||||||
|
line = bytes.TrimSuffix(line, []byte("\n"))
|
||||||
|
if len(line) > 0 {
|
||||||
|
s.put(s.record(at, line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent is how many records have been sent.
|
||||||
|
func (s *Sender) Sent() int64 {
|
||||||
|
return s.sent.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropped is how many records were dropped: the oldest in a full buffer,
|
||||||
|
// and those whose sending failed.
|
||||||
|
func (s *Sender) Dropped() int64 {
|
||||||
|
return s.dropped.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth is how many records are in the buffer.
|
||||||
|
func (s *Sender) Depth() int {
|
||||||
|
return len(s.records)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run connects to the endpoint and sends each record as it comes into the
|
||||||
|
// buffer, until ctx is done. Then it sends the records still in the buffer,
|
||||||
|
// on the connection open at that time or, if there is none, on a new one,
|
||||||
|
// until none is left or one fails, and returns. How long it may take over
|
||||||
|
// that is for the caller to bound.
|
||||||
|
//
|
||||||
|
// A connection on which a record fails is closed and the record dropped.
|
||||||
|
// That failure, like a failed attempt to connect, is logged to processLog
|
||||||
|
// and followed by the next attempt after firstRetryDelay, retryDelayFactor
|
||||||
|
// times as long after each further failure in a row up to maxRetryDelay,
|
||||||
|
// and firstRetryDelay again after a connection that stayed up for
|
||||||
|
// resetRetryDelayAfter. Meanwhile the records wait in the buffer.
|
||||||
|
func (s *Sender) Run(ctx context.Context, processLog *slog.Logger) {
|
||||||
|
conn := s.send(ctx, processLog)
|
||||||
|
if conn == nil && len(s.records) > 0 {
|
||||||
|
conn, _ = s.dial(context.WithoutCancel(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
if conn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case record := <-s.records:
|
||||||
|
if s.write(conn, record) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// record returns line as an RFC 5424 record made at the time at, framed
|
||||||
|
// for the endpoint: on its own over UDP, since each datagram holds one,
|
||||||
|
// and over TCP and TLS after its length in bytes and a space, the
|
||||||
|
// octet-counted framing of RFC 6587 and RFC 5425.
|
||||||
|
func (s *Sender) record(at string, line []byte) []byte {
|
||||||
|
record := make([]byte, 0, len(s.beforeTime)+len(at)+len(s.afterTime)+len(line))
|
||||||
|
record = append(record, s.beforeTime...)
|
||||||
|
record = append(record, at...)
|
||||||
|
record = append(record, s.afterTime...)
|
||||||
|
record = append(record, line...)
|
||||||
|
|
||||||
|
if s.url.Scheme == SchemeUDP {
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
return append([]byte(strconv.Itoa(len(record))+" "), record...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// put adds record to the buffer, first dropping the oldest record in it
|
||||||
|
// while it is full.
|
||||||
|
func (s *Sender) put(record []byte) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case s.records <- record:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-s.records:
|
||||||
|
s.dropped.Add(1)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// send connects to the endpoint and sends each record as it comes into
|
||||||
|
// the buffer, until ctx is done, and returns the connection then open, or
|
||||||
|
// nil.
|
||||||
|
func (s *Sender) send(ctx context.Context, processLog *slog.Logger) net.Conn {
|
||||||
|
delay := firstRetryDelay
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn, err := s.dial(ctx)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
connected := time.Now()
|
||||||
|
|
||||||
|
err = s.sendOn(ctx, conn)
|
||||||
|
if err == nil {
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
if time.Since(connected) >= resetRetryDelayAfter {
|
||||||
|
delay = firstRetryDelay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processLog.Warn("sending to SWWAF_LOG_REMOTE_URL failed",
|
||||||
|
"error", err.Error(), "connecting_again_in", delay.String())
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(delay):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
delay = min(retryDelayFactor*delay, maxRetryDelay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendOn sends each record on conn as it comes into the buffer, until one
|
||||||
|
// fails, whose error it returns, or ctx is done.
|
||||||
|
func (s *Sender) sendOn(ctx context.Context, conn net.Conn) error {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case record := <-s.records:
|
||||||
|
err := s.write(conn, record)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write sends record on conn, and counts it as sent or, if that fails,
|
||||||
|
// as dropped. A record too long for one UDP datagram is dropped without
|
||||||
|
// an error, since the connection has not failed: a long request must not
|
||||||
|
// hold up the lines after it.
|
||||||
|
func (s *Sender) write(conn net.Conn, record []byte) error {
|
||||||
|
_, err := conn.Write(record)
|
||||||
|
if err != nil {
|
||||||
|
s.dropped.Add(1)
|
||||||
|
|
||||||
|
if errors.Is(err, syscall.EMSGSIZE) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("send a record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sent.Add(1)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dial connects to the endpoint.
|
||||||
|
func (s *Sender) dial(ctx context.Context) (net.Conn, error) {
|
||||||
|
dialer := &net.Dialer{Timeout: dialTimeout}
|
||||||
|
|
||||||
|
switch s.url.Scheme {
|
||||||
|
case SchemeUDP:
|
||||||
|
return dialer.DialContext(ctx, "udp", s.url.Host)
|
||||||
|
case SchemeTLS:
|
||||||
|
tlsDialer := &tls.Dialer{NetDialer: dialer, Config: s.tlsConfig}
|
||||||
|
|
||||||
|
return tlsDialer.DialContext(ctx, "tcp", s.url.Host)
|
||||||
|
default:
|
||||||
|
return dialer.DialContext(ctx, "tcp", s.url.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,640 @@
|
|||||||
|
package remotelog_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The tests run in a synctest bubble, where the time package runs on a
|
||||||
|
// clock of the test's own, which starts at 2000-01-01T00:00:00Z: a wait
|
||||||
|
// lasts exactly as long as it should, however slowly the test process
|
||||||
|
// runs, and synctest.Wait returns once the sender has done all it can
|
||||||
|
// before time passes. The endpoint is a listener on the loopback address.
|
||||||
|
// A test reads from it only once the records are on their way, and checks
|
||||||
|
// the sender's counts first, since a goroutine of the bubble that waits on
|
||||||
|
// the network keeps that clock from moving on. For the same reason the
|
||||||
|
// endpoint that refuses connections, a tlsEndpoint, runs outside the
|
||||||
|
// bubble: a sender connecting over TLS waits on the endpoint's answer.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// started is the time a record made as a test starts gives.
|
||||||
|
started = "2000-01-01T00:00:00.000Z"
|
||||||
|
appName = "fsn1app1/gitea"
|
||||||
|
// local0 is the number of the default facility, and local0Info the
|
||||||
|
// priority of its records.
|
||||||
|
local0 = 16
|
||||||
|
local0Info = "<134>"
|
||||||
|
// loopback is where the endpoints listen.
|
||||||
|
loopback = "127.0.0.1:0"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecordsOverUDPGoOnePerDatagram(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
endpoint, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", loopback)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = endpoint.Close() })
|
||||||
|
|
||||||
|
sender, _, _ := run(t, params(remotelog.SchemeUDP, endpoint.LocalAddr()))
|
||||||
|
|
||||||
|
_, _ = sender.Write([]byte(`{"type":"request"}` + "\n" + `{"type":"process"}` + "\n"))
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
wantCounts(t, sender, 2, 0, 0)
|
||||||
|
|
||||||
|
for _, line := range []string{`{"type":"request"}`, `{"type":"process"}`} {
|
||||||
|
datagram := make([]byte, 1024)
|
||||||
|
|
||||||
|
n, _, err := endpoint.ReadFrom(datagram)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := record(t, local0Info, appName, line)
|
||||||
|
if string(datagram[:n]) != want {
|
||||||
|
t.Errorf("datagram %q, want %q", datagram[:n], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordsOverTCPAreOctetCountedWithTheirFacility(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
endpoint := listen(t)
|
||||||
|
endpointParams := params(remotelog.SchemeTCP, endpoint.Addr())
|
||||||
|
endpointParams.Facility = 19 // local3
|
||||||
|
endpointParams.AppName = "gitea"
|
||||||
|
sender, _, _ := run(t, endpointParams)
|
||||||
|
|
||||||
|
_, _ = sender.Write([]byte("first\nsecond\n"))
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
wantCounts(t, sender, 2, 0, 0)
|
||||||
|
|
||||||
|
frames := bufio.NewReader(accept(t, endpoint))
|
||||||
|
wantFrame(t, frames, record(t, "<158>", "gitea", "first"))
|
||||||
|
wantFrame(t, frames, record(t, "<158>", "gitea", "second"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStalledEndpointHoldsUpNoWriteAndOldestRecordsAreDropped(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
certificate, roots := testCertificate(t)
|
||||||
|
endpoint := listen(t)
|
||||||
|
endpointParams := params(remotelog.SchemeTLS, endpoint.Addr())
|
||||||
|
endpointParams.RootCAs = roots
|
||||||
|
endpointParams.Buffer = 3
|
||||||
|
sender, _, _ := run(t, endpointParams)
|
||||||
|
|
||||||
|
// The sender connects, and its TLS handshake waits for an answer
|
||||||
|
// the endpoint does not give yet.
|
||||||
|
conn := accept(t, endpoint)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
|
||||||
|
out := io.MultiWriter(&stdout, sender)
|
||||||
|
for i := range 5 {
|
||||||
|
_, _ = fmt.Fprintf(out, "line %d\n", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stdout.String() != "line 1\nline 2\nline 3\nline 4\nline 5\n" {
|
||||||
|
t.Errorf("stdout has %q", stdout.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
wantCounts(t, sender, 0, 2, 3)
|
||||||
|
|
||||||
|
// Once the endpoint answers, the three newest records are sent.
|
||||||
|
server := tls.Server(conn, &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{certificate},
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
})
|
||||||
|
|
||||||
|
err := server.HandshakeContext(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handshake: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
wantCounts(t, sender, 3, 2, 0)
|
||||||
|
|
||||||
|
frames := bufio.NewReader(server)
|
||||||
|
for _, line := range []string{"line 3", "line 4", "line 5"} {
|
||||||
|
wantFrame(t, frames, record(t, local0Info, appName, line))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconnectsWithBackoffAfterTheEndpointGoesAway(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
certificate, roots := testCertificate(t)
|
||||||
|
endpoint := startTLSEndpoint(t, certificate)
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
endpointParams := params(remotelog.SchemeTLS, endpoint.addr)
|
||||||
|
endpointParams.RootCAs = roots
|
||||||
|
sender, logged, _ := run(t, endpointParams)
|
||||||
|
|
||||||
|
_, _ = sender.Write([]byte("one\n"))
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
wantCounts(t, sender, 1, 0, 0)
|
||||||
|
|
||||||
|
conn := endpoint.next(t)
|
||||||
|
wantFrame(t, bufio.NewReader(conn), record(t, local0Info, appName, "one"))
|
||||||
|
|
||||||
|
// The endpoint goes away: it closes the connection, and refuses the
|
||||||
|
// next ones. The sender notices when a record fails, and tries to
|
||||||
|
// connect again a second later, then two seconds after that.
|
||||||
|
endpoint.refusing.Store(true)
|
||||||
|
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
writeUntilDropped(t, sender, 1)
|
||||||
|
sent := sender.Sent()
|
||||||
|
|
||||||
|
_, _ = sender.Write([]byte("two\n"))
|
||||||
|
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
endpoint.refusing.Store(false)
|
||||||
|
|
||||||
|
time.Sleep(2*time.Second - time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantCounts(t, sender, sent, 1, 1)
|
||||||
|
|
||||||
|
// The endpoint is back, and the record waiting is sent.
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantCounts(t, sender, sent+1, 1, 0)
|
||||||
|
|
||||||
|
conn = endpoint.next(t)
|
||||||
|
wantFrame(t, bufio.NewReader(conn), record(t, local0Info, appName, "two"))
|
||||||
|
wantRetries(t, logged, "1s", "2s")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAConnectionClosedAtOnceIsMadeAgainAfterAGrowingDelay(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
endpoint := listen(t)
|
||||||
|
sender, logged, _ := run(t, params(remotelog.SchemeTCP, endpoint.Addr()))
|
||||||
|
|
||||||
|
// The endpoint closes each connection as soon as it takes it. The
|
||||||
|
// sender notices when a record fails, and connects again a second
|
||||||
|
// later, then two seconds after that, then four.
|
||||||
|
delays := []time.Duration{time.Second, 2 * time.Second, 4 * time.Second}
|
||||||
|
for i, delay := range delays {
|
||||||
|
_ = accept(t, endpoint).Close()
|
||||||
|
|
||||||
|
writeUntilDropped(t, sender, int64(i+1))
|
||||||
|
wantConnectedAgainAfter(t, sender, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantRetries(t, logged, "1s", "2s", "4s")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheDelayStartsAgainAfterAConnectionThatStayedUpAMinute(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
endpoint := listen(t)
|
||||||
|
sender, logged, _ := run(t, params(remotelog.SchemeTCP, endpoint.Addr()))
|
||||||
|
|
||||||
|
_ = accept(t, endpoint).Close()
|
||||||
|
|
||||||
|
writeUntilDropped(t, sender, 1)
|
||||||
|
wantConnectedAgainAfter(t, sender, time.Second)
|
||||||
|
|
||||||
|
// A connection that fails just short of a minute after it was made
|
||||||
|
// leaves the delay growing.
|
||||||
|
conn := accept(t, endpoint)
|
||||||
|
|
||||||
|
time.Sleep(time.Minute - time.Nanosecond)
|
||||||
|
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
writeUntilDropped(t, sender, 2)
|
||||||
|
wantConnectedAgainAfter(t, sender, 2*time.Second)
|
||||||
|
|
||||||
|
// One that fails a minute after it was made starts it again from a
|
||||||
|
// second.
|
||||||
|
conn = accept(t, endpoint)
|
||||||
|
|
||||||
|
time.Sleep(time.Minute)
|
||||||
|
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
writeUntilDropped(t, sender, 3)
|
||||||
|
wantConnectedAgainAfter(t, sender, time.Second)
|
||||||
|
wantRetries(t, logged, "1s", "2s", "1s")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestALineTooLongForADatagramIsDroppedAlone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
endpoint, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", loopback)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = endpoint.Close() })
|
||||||
|
|
||||||
|
sender, logged, _ := run(t, params(remotelog.SchemeUDP, endpoint.LocalAddr()))
|
||||||
|
|
||||||
|
// With its header, the first line's record is longer than the 65507
|
||||||
|
// bytes a UDP datagram over IPv4 holds. It is dropped, nothing is
|
||||||
|
// logged, and the next line is sent at once.
|
||||||
|
_, _ = sender.Write([]byte(strings.Repeat("x", 65507) + "\nnext\n"))
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
wantCounts(t, sender, 1, 1, 0)
|
||||||
|
wantRetries(t, logged)
|
||||||
|
|
||||||
|
datagram := make([]byte, 1024)
|
||||||
|
|
||||||
|
n, _, err := endpoint.ReadFrom(datagram)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := record(t, local0Info, appName, "next")
|
||||||
|
if string(datagram[:n]) != want {
|
||||||
|
t.Errorf("datagram %q, want %q", datagram[:n], want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordsWaitingAtTheStopAreSent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
certificate, roots := testCertificate(t)
|
||||||
|
endpoint := startTLSEndpoint(t, certificate)
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
// The endpoint refuses the sender's first connection: it fails to
|
||||||
|
// connect, and waits a second to try again.
|
||||||
|
endpoint.refusing.Store(true)
|
||||||
|
|
||||||
|
endpointParams := params(remotelog.SchemeTLS, endpoint.addr)
|
||||||
|
endpointParams.RootCAs = roots
|
||||||
|
sender, logged, stop := run(t, endpointParams)
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
wantRetries(t, logged, "1s")
|
||||||
|
|
||||||
|
_, _ = sender.Write([]byte("one\ntwo\n"))
|
||||||
|
|
||||||
|
endpoint.refusing.Store(false)
|
||||||
|
|
||||||
|
// Stopped before that second is over, it connects to send them.
|
||||||
|
stop()
|
||||||
|
wantCounts(t, sender, 2, 0, 0)
|
||||||
|
|
||||||
|
frames := bufio.NewReader(endpoint.next(t))
|
||||||
|
wantFrame(t, frames, record(t, local0Info, appName, "one"))
|
||||||
|
wantFrame(t, frames, record(t, local0Info, appName, "two"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// output collects what the sender logs.
|
||||||
|
type output struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write adds lines the sender logs.
|
||||||
|
func (o *output) Write(p []byte) (int, error) {
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
|
||||||
|
return o.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// text returns everything logged so far.
|
||||||
|
func (o *output) text() string {
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
|
||||||
|
return o.buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// params returns the settings of a Sender for the endpoint at addr, in
|
||||||
|
// the form scheme names: room for ten lines, the default facility, and
|
||||||
|
// appName.
|
||||||
|
func params(scheme string, addr net.Addr) remotelog.Params {
|
||||||
|
return remotelog.Params{
|
||||||
|
URL: &url.URL{Scheme: scheme, Host: addr.String()},
|
||||||
|
Buffer: 10,
|
||||||
|
Facility: local0,
|
||||||
|
AppName: appName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run runs a Sender with settings until the test ends or the function
|
||||||
|
// it returns is called, which waits for Run to return. It returns the
|
||||||
|
// Sender, and what it logs.
|
||||||
|
func run(t *testing.T, settings remotelog.Params) (*remotelog.Sender, *output, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sender := remotelog.New(settings)
|
||||||
|
logged := &output{}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
ran := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
sender.Run(ctx, slog.New(slog.NewJSONHandler(logged, nil)))
|
||||||
|
close(ran)
|
||||||
|
}()
|
||||||
|
|
||||||
|
stop := func() {
|
||||||
|
cancel()
|
||||||
|
<-ran
|
||||||
|
}
|
||||||
|
t.Cleanup(stop)
|
||||||
|
|
||||||
|
return sender, logged, stop
|
||||||
|
}
|
||||||
|
|
||||||
|
// listen returns a TCP listener on the loopback address, closed when the
|
||||||
|
// test ends.
|
||||||
|
func listen(t *testing.T) net.Listener {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", loopback)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = listener.Close() })
|
||||||
|
|
||||||
|
return listener
|
||||||
|
}
|
||||||
|
|
||||||
|
// accept returns the next connection to listener, closed when the test
|
||||||
|
// ends.
|
||||||
|
func accept(t *testing.T, listener net.Listener) net.Conn {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("accept: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// tlsEndpoint is a syslog+tls endpoint on the loopback address, which a
|
||||||
|
// test starts outside its bubble. It keeps its listener until the test
|
||||||
|
// ends, and either takes each connection or refuses it.
|
||||||
|
type tlsEndpoint struct {
|
||||||
|
addr net.Addr
|
||||||
|
// refusing is set while the endpoint closes each connection before the
|
||||||
|
// TLS handshake, which fails the sender's attempt to connect.
|
||||||
|
refusing atomic.Bool
|
||||||
|
// conns are the connections it has taken, after the handshake.
|
||||||
|
conns chan net.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTLSEndpoint starts a tlsEndpoint with certificate, which takes
|
||||||
|
// connections until it is told to refuse them.
|
||||||
|
func startTLSEndpoint(t *testing.T, certificate tls.Certificate) *tlsEndpoint {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener := listen(t)
|
||||||
|
endpoint := &tlsEndpoint{addr: listener.Addr(), conns: make(chan net.Conn, 10)}
|
||||||
|
config := &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{certificate},
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server := tls.Server(conn, config)
|
||||||
|
if endpoint.refusing.Load() || server.HandshakeContext(t.Context()) != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint.conns <- server
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
// next returns the next connection the endpoint has taken, closed when
|
||||||
|
// the test ends.
|
||||||
|
func (e *tlsEndpoint) next(t *testing.T) net.Conn {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
conn := <-e.conns
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// record returns the record of line made as the test started, with the
|
||||||
|
// priority and the app name given.
|
||||||
|
func record(t *testing.T, priority, app, line string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil || hostname == "" {
|
||||||
|
hostname = "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
return priority + "1 " + started + " " + hostname + " " + app + " - - - " + line
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantFrame reads the next octet-counted frame from frames, and checks
|
||||||
|
// that it holds want.
|
||||||
|
func wantFrame(t *testing.T, frames *bufio.Reader, want string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
count, err := frames.ReadString(' ')
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read a frame's length: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
length, err := strconv.Atoi(strings.TrimSuffix(count, " "))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("frame starts %q, not with its length", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]byte, length)
|
||||||
|
|
||||||
|
_, err = io.ReadFull(frames, got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read a frame: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("frame %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantCounts checks the records sender has sent, dropped and holds in
|
||||||
|
// its buffer.
|
||||||
|
func wantCounts(
|
||||||
|
t *testing.T, sender *remotelog.Sender, sent, dropped int64, depth int,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if sender.Sent() != sent || sender.Dropped() != dropped || sender.Depth() != depth {
|
||||||
|
t.Fatalf("sent %d, dropped %d, %d in the buffer; want %d, %d and %d",
|
||||||
|
sender.Sent(), sender.Dropped(), sender.Depth(), sent, dropped, depth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeUntilDropped writes a line at a time until the count of records
|
||||||
|
// sender has dropped reaches dropped. The records it sends on a
|
||||||
|
// connection the endpoint has closed are lost before one fails; how many
|
||||||
|
// depends on when the endpoint's host answers that the connection is
|
||||||
|
// gone.
|
||||||
|
func writeUntilDropped(t *testing.T, sender *remotelog.Sender, dropped int64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for sender.Dropped() < dropped {
|
||||||
|
_, _ = sender.Write([]byte("lost\n"))
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantConnectedAgainAfter writes a line while the sender waits to connect
|
||||||
|
// again, and checks that it connects, and takes the line from the buffer,
|
||||||
|
// only once delay is over.
|
||||||
|
func wantConnectedAgainAfter(
|
||||||
|
t *testing.T, sender *remotelog.Sender, delay time.Duration,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, _ = sender.Write([]byte("waiting\n"))
|
||||||
|
|
||||||
|
time.Sleep(delay - time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
if sender.Depth() != 1 {
|
||||||
|
t.Fatalf("connected again before %v", delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
if sender.Depth() != 0 {
|
||||||
|
t.Fatalf("not connected again after %v", delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantRetries checks that the sender logged a failure, of an attempt to
|
||||||
|
// connect or of a connection, for each of delays, the time until the next
|
||||||
|
// attempt, in order, and logged nothing else.
|
||||||
|
func wantRetries(t *testing.T, logged *output, delays ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
|
||||||
|
for line := range strings.Lines(logged.text()) {
|
||||||
|
var fields map[string]any
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(line), &fields)
|
||||||
|
if err != nil || fields["msg"] != "sending to SWWAF_LOG_REMOTE_URL failed" {
|
||||||
|
t.Fatalf("logged %q", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
delay, _ := fields["connecting_again_in"].(string)
|
||||||
|
got = append(got, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, delays) {
|
||||||
|
t.Errorf("logged failures to connect again in %v, want %v", got, delays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testCertificate returns a certificate for 127.0.0.1 that is its own
|
||||||
|
// CA, and a pool that holds it. It is valid on the bubble's clock, which
|
||||||
|
// starts at 2000-01-01T00:00:00Z.
|
||||||
|
func testCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate a key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "smallwebwaf test CA"},
|
||||||
|
NotBefore: time.Date(1999, 12, 31, 0, 0, 0, 0, time.UTC),
|
||||||
|
NotAfter: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||||
|
IsCA: true,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
|
||||||
|
}
|
||||||
|
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, template, template,
|
||||||
|
&key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create a certificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certificate, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse the certificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
roots := x509.NewCertPool()
|
||||||
|
roots.AddCert(certificate)
|
||||||
|
|
||||||
|
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, roots
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package reputation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AbuseIPDBURL is where clients are checked: the check endpoint of
|
||||||
|
// AbuseIPDB's API.
|
||||||
|
AbuseIPDBURL = "https://api.abuseipdb.com/api/v2/check"
|
||||||
|
// AbuseIPDBSource is how the request log, the alerts and the metrics
|
||||||
|
// name AbuseIPDB.
|
||||||
|
AbuseIPDBSource = "abuseipdb"
|
||||||
|
// maxAnswerBytes is the most of an answer of AbuseIPDB that is read.
|
||||||
|
maxAnswerBytes = 64 << 10
|
||||||
|
// day is the length of the day the checks are counted in, in UTC.
|
||||||
|
day = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errNoScore = errors.New("the answer gives no abuseConfidenceScore")
|
||||||
|
errBudgetUsedUp = errors.New(
|
||||||
|
"checks spent; none is made until the day ends at 00:00 UTC")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Score is what AbuseIPDB said about a client, as reputation.json holds
|
||||||
|
// it: the client, an IPv4 address or an IPv6 group, its abuse confidence
|
||||||
|
// score, from 0 to 100, and when AbuseIPDB answered.
|
||||||
|
type Score struct {
|
||||||
|
Client netip.Prefix `json:"client"`
|
||||||
|
Score int64 `json:"score"`
|
||||||
|
Fetched time.Time `json:"fetched"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks are what reputation.json keeps of the checks of clients with
|
||||||
|
// AbuseIPDB: the day, in UTC, of the checks Spent counts, zero before the
|
||||||
|
// first, and the scores still in use.
|
||||||
|
type Checks struct {
|
||||||
|
Day time.Time `json:"day,omitzero"`
|
||||||
|
Spent int `json:"spent"`
|
||||||
|
Scores []Score `json:"scores"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AbuseIPDBParams are what NewAbuseIPDB needs.
|
||||||
|
type AbuseIPDBParams struct {
|
||||||
|
// URL is where clients are checked, normally AbuseIPDBURL, with Key,
|
||||||
|
// the account's key (SWWAF_ABUSEIPDB_KEY).
|
||||||
|
URL string
|
||||||
|
Key string
|
||||||
|
// MinScore is the least score that is a hit (SWWAF_ABUSEIPDB_MIN_SCORE),
|
||||||
|
// and DailyBudget the most checks made in a day, in UTC
|
||||||
|
// (SWWAF_ABUSEIPDB_DAILY_BUDGET).
|
||||||
|
MinScore int64
|
||||||
|
DailyBudget int
|
||||||
|
// CacheTTL is how long a score is used after it was fetched
|
||||||
|
// (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take
|
||||||
|
// (SWWAF_REPUTATION_TIMEOUT).
|
||||||
|
CacheTTL time.Duration
|
||||||
|
Timeout time.Duration
|
||||||
|
// Now tells the time, normally time.Now in UTC.
|
||||||
|
Now func() time.Time
|
||||||
|
// ProcessLog receives each check that fails, and why, and the day's
|
||||||
|
// budget used up.
|
||||||
|
ProcessLog *slog.Logger
|
||||||
|
// Alerts receive a source_failure alert for each.
|
||||||
|
Alerts *alerts.Queue
|
||||||
|
}
|
||||||
|
|
||||||
|
// AbuseIPDB checks clients with AbuseIPDB, in the background, and keeps
|
||||||
|
// their scores. It is safe for concurrent use.
|
||||||
|
type AbuseIPDB struct {
|
||||||
|
params AbuseIPDBParams
|
||||||
|
httpClient *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
// scores are by client. Each is added as it is fetched and never moved
|
||||||
|
// up, so that the one fetched longest ago is the first dropped.
|
||||||
|
scores *simplelru.LRU[netip.Prefix, Score]
|
||||||
|
// checking are the clients whose check is under way.
|
||||||
|
checking map[netip.Prefix]bool
|
||||||
|
// day is the day, in UTC, of the checks spent counts.
|
||||||
|
day time.Time
|
||||||
|
spent int
|
||||||
|
// checks and failures count the checks made and those that failed,
|
||||||
|
// and retryAt is when a client may be checked again after the last
|
||||||
|
// check failed.
|
||||||
|
checks int
|
||||||
|
failures int
|
||||||
|
retryAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAbuseIPDB returns an AbuseIPDB with no score yet, and no check spent.
|
||||||
|
func NewAbuseIPDB(params AbuseIPDBParams) *AbuseIPDB {
|
||||||
|
scores, err := simplelru.NewLRU[netip.Prefix, Score](maxVerdicts, nil)
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // NewLRU fails only for a size below one
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AbuseIPDB{
|
||||||
|
params: params,
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
scores: scores,
|
||||||
|
checking: map[netip.Prefix]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hit returns AbuseIPDB's score of client, an IPv4 address or an IPv6
|
||||||
|
// group, and whether it is a hit: MinScore or more. A score is used until
|
||||||
|
// CacheTTL has passed since it was fetched, whichever of the client's
|
||||||
|
// addresses its request comes from. A client without one is checked in
|
||||||
|
// the background, by addr, the address its request came from, if
|
||||||
|
// offender, if it has committed an offence, unless its check is under
|
||||||
|
// way, a check failed less than failureDelay ago, or the day's checks
|
||||||
|
// have used up DailyBudget; Hit never waits for a check. The check that
|
||||||
|
// uses the budget up is logged and raised as a source_failure alert. ctx
|
||||||
|
// is the context of the client's request, and a check goes on after the
|
||||||
|
// request ends.
|
||||||
|
func (a *AbuseIPDB) Hit(
|
||||||
|
ctx context.Context, client netip.Prefix, addr netip.Addr, offender bool,
|
||||||
|
) (int64, bool) {
|
||||||
|
a.mu.Lock()
|
||||||
|
|
||||||
|
now := a.params.Now()
|
||||||
|
|
||||||
|
kept, found := a.scores.Peek(client)
|
||||||
|
if found && now.Sub(kept.Fetched) < a.params.CacheTTL {
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
return kept.Score, kept.Score >= a.params.MinScore
|
||||||
|
}
|
||||||
|
|
||||||
|
if today := now.Truncate(day); !a.day.Equal(today) {
|
||||||
|
a.day, a.spent = today, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
check := offender && !a.checking[client] && !now.Before(a.retryAt) &&
|
||||||
|
a.spent < a.params.DailyBudget
|
||||||
|
if check {
|
||||||
|
a.checking[client] = true
|
||||||
|
a.checks++
|
||||||
|
a.spent++
|
||||||
|
|
||||||
|
go a.check(context.WithoutCancel(ctx), client, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
usedUp := check && a.spent == a.params.DailyBudget
|
||||||
|
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if usedUp {
|
||||||
|
a.alert("the daily budget of AbuseIPDB checks is used up",
|
||||||
|
fmt.Errorf("%d %w", a.params.DailyBudget, errBudgetUsedUp))
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked returns how many checks were made.
|
||||||
|
func (a *AbuseIPDB) Checked() int {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
return a.checks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failures returns how many checks failed.
|
||||||
|
func (a *AbuseIPDB) Failures() int {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
return a.failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// BudgetLeft returns how many checks the day's budget has left.
|
||||||
|
func (a *AbuseIPDB) BudgetLeft() int {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
if !a.day.Equal(a.params.Now().Truncate(day)) {
|
||||||
|
return a.params.DailyBudget
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(a.params.DailyBudget-a.spent, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns the checks spent and every score still in use, sorted
|
||||||
|
// by client, as reputation.json keeps them.
|
||||||
|
func (a *AbuseIPDB) Snapshot() Checks {
|
||||||
|
a.mu.Lock()
|
||||||
|
|
||||||
|
now := a.params.Now()
|
||||||
|
checks := Checks{Day: a.day, Spent: a.spent, Scores: make([]Score, 0, a.scores.Len())}
|
||||||
|
|
||||||
|
for _, kept := range a.scores.Values() {
|
||||||
|
if now.Sub(kept.Fetched) < a.params.CacheTTL {
|
||||||
|
checks.Scores = append(checks.Scores, kept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
slices.SortFunc(checks.Scores, func(x, y Score) int {
|
||||||
|
return x.Client.Compare(y.Client)
|
||||||
|
})
|
||||||
|
|
||||||
|
return checks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load keeps checks, read from reputation.json, in place of those it
|
||||||
|
// keeps, but for the scores past maxVerdicts, those fetched longest ago.
|
||||||
|
// One fetched CacheTTL ago or more is neither used nor written, as for any
|
||||||
|
// score.
|
||||||
|
func (a *AbuseIPDB) Load(checks Checks) {
|
||||||
|
scores := slices.Clone(checks.Scores)
|
||||||
|
slices.SortStableFunc(scores, func(x, y Score) int {
|
||||||
|
return x.Fetched.Compare(y.Fetched)
|
||||||
|
})
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
a.day, a.spent = checks.Day, checks.Spent
|
||||||
|
a.scores.Purge()
|
||||||
|
|
||||||
|
for _, kept := range scores {
|
||||||
|
a.scores.Add(kept.Client, kept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check checks client with AbuseIPDB by addr, one of its addresses, keeps
|
||||||
|
// the score as client's, and notes the check as no longer under way. A
|
||||||
|
// check that fails gives no score: it is counted, logged and raised as a
|
||||||
|
// source_failure alert, and no client is checked for failureDelay.
|
||||||
|
func (a *AbuseIPDB) check(ctx context.Context, client netip.Prefix, addr netip.Addr) {
|
||||||
|
score, err := a.ask(ctx, addr)
|
||||||
|
now := a.params.Now()
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
|
||||||
|
delete(a.checking, client)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
a.scores.Add(client, Score{Client: client, Score: score, Fetched: now})
|
||||||
|
} else {
|
||||||
|
a.failures++
|
||||||
|
a.retryAt = now.Add(failureDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
a.alert("checking a client with AbuseIPDB failed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask asks AbuseIPDB for addr's abuse confidence score, sending the key
|
||||||
|
// in the header Key. An answer other than 200, one that gives no score,
|
||||||
|
// and none within Timeout, fail.
|
||||||
|
func (a *AbuseIPDB) ask(ctx context.Context, addr netip.Addr) (int64, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, a.params.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
query := url.Values{"ipAddress": {addr.String()}}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||||
|
a.params.URL+"?"+query.Encode(), http.NoBody)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("make the request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Key", a.params.Key)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
res, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Do's error names the URL, which holds the client's address, which
|
||||||
|
// is not to be logged: only what went wrong is kept.
|
||||||
|
return 0, fmt.Errorf("check the client: %w", errors.Unwrap(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = res.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return 0, fmt.Errorf("%w %s", errStatus, res.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var answer struct {
|
||||||
|
Data struct {
|
||||||
|
AbuseConfidenceScore *int64 `json:"abuseConfidenceScore"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.NewDecoder(io.LimitReader(res.Body, maxAnswerBytes)).Decode(&answer)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("read the answer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if answer.Data.AbuseConfidenceScore == nil {
|
||||||
|
return 0, errNoScore
|
||||||
|
}
|
||||||
|
|
||||||
|
return *answer.Data.AbuseConfidenceScore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// alert raises a source_failure alert from AbuseIPDB with reason and err,
|
||||||
|
// and logs them.
|
||||||
|
func (a *AbuseIPDB) alert(reason string, err error) {
|
||||||
|
// Raised before it is logged, so that the alert is there once the log
|
||||||
|
// line is.
|
||||||
|
raiseFailure(a.params.Alerts, reason, AbuseIPDBSource, err)
|
||||||
|
a.params.ProcessLog.Warn(reason, "source", AbuseIPDBSource, "error", err.Error())
|
||||||
|
}
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
package reputation_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The tests of AbuseIPDB run in synctest bubbles, as those of the lists
|
||||||
|
// do, and AbuseIPDB is a stand-in reached without the network, for the
|
||||||
|
// same reason. A bubble's clock starts at midnight UTC, as a day the
|
||||||
|
// checks are counted in starts.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// key is the account's key the tests give, the only one the stand-in
|
||||||
|
// takes.
|
||||||
|
key = "abuseipdb-key-0123456789abcdef"
|
||||||
|
// suspect and other are clients that have committed an offence.
|
||||||
|
suspect = "203.0.113.9"
|
||||||
|
other = "2001:db8::9"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOnlyAnOffenderWithoutAScoreIsChecked(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
|
||||||
|
checker := newAbuseIPDB(abuseIPDB, abuseIPDBParams())
|
||||||
|
|
||||||
|
// A client that has committed no offence is not checked.
|
||||||
|
wantScore(t, checker, suspect, false, 0, false)
|
||||||
|
synctest.Wait()
|
||||||
|
wantChecked(t, abuseIPDB)
|
||||||
|
|
||||||
|
// An offender is, and from then on its score is used, whether or not
|
||||||
|
// it is an offender.
|
||||||
|
wantScore(t, checker, suspect, true, 0, false)
|
||||||
|
synctest.Wait()
|
||||||
|
wantScore(t, checker, suspect, true, 100, true)
|
||||||
|
wantScore(t, checker, suspect, false, 100, true)
|
||||||
|
synctest.Wait()
|
||||||
|
wantChecked(t, abuseIPDB, suspect)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIPv6ClientIsCheckedOnceAndItsScoreUsedForEachOfItsAddresses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
// 15 addresses of 2001:db8:1:2::/64, one client, each in a part of
|
||||||
|
// it of its own.
|
||||||
|
var addresses []string
|
||||||
|
for i := 1; i < 16; i++ {
|
||||||
|
addresses = append(addresses, fmt.Sprintf("2001:db8:1:2:%x::9", i<<12))
|
||||||
|
}
|
||||||
|
|
||||||
|
abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{addresses[0]: 100}}
|
||||||
|
checker := newAbuseIPDB(abuseIPDB, abuseIPDBParams())
|
||||||
|
|
||||||
|
// A request from each has the client checked once, by the first.
|
||||||
|
for _, address := range addresses {
|
||||||
|
hitFrom(t, checker, address, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
wantChecked(t, abuseIPDB, addresses[0])
|
||||||
|
|
||||||
|
// Its score is the whole client's.
|
||||||
|
for _, address := range addresses {
|
||||||
|
wantScore(t, checker, address, true, 100, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
wantChecked(t, abuseIPDB, addresses[0])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreAtOrOverTheMinimumIsAHit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
scores := map[string]int64{"192.0.2.74": 74, "192.0.2.75": 75, "192.0.2.100": 100}
|
||||||
|
p := abuseIPDBParams()
|
||||||
|
p.MinScore = 75
|
||||||
|
checker := newAbuseIPDB(&abuseIPDBStandIn{scores: scores}, p)
|
||||||
|
|
||||||
|
for client := range scores {
|
||||||
|
hitFrom(t, checker, client, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
for client, score := range scores {
|
||||||
|
wantScore(t, checker, client, true, score, score >= 75)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreUsedUntilTheCacheTTLHasPassedSinceItWasFetched(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
|
||||||
|
checker := newAbuseIPDB(abuseIPDB, abuseIPDBParams())
|
||||||
|
|
||||||
|
hitFrom(t, checker, suspect, true)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
// AbuseIPDB gives another score from now on, but the one kept is
|
||||||
|
// used, and the client is not checked again, until the TTL has
|
||||||
|
// passed.
|
||||||
|
abuseIPDB.setScore(suspect, 80)
|
||||||
|
time.Sleep(cacheTTL - time.Nanosecond)
|
||||||
|
wantScore(t, checker, suspect, true, 100, true)
|
||||||
|
synctest.Wait()
|
||||||
|
wantChecked(t, abuseIPDB, suspect)
|
||||||
|
|
||||||
|
// Then it is not used, and the client is checked again.
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
wantScore(t, checker, suspect, true, 0, false)
|
||||||
|
synctest.Wait()
|
||||||
|
wantScore(t, checker, suspect, true, 80, true)
|
||||||
|
wantChecked(t, abuseIPDB, suspect, suspect)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
var log bytes.Buffer
|
||||||
|
|
||||||
|
queue := newQueue()
|
||||||
|
p := abuseIPDBParams()
|
||||||
|
p.DailyBudget = 3
|
||||||
|
p.Alerts = queue
|
||||||
|
p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil))
|
||||||
|
abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
|
||||||
|
checker := newAbuseIPDB(abuseIPDB, p)
|
||||||
|
|
||||||
|
// At noon, the first three offenders spend the budget, and the
|
||||||
|
// fourth, unchecked, is not.
|
||||||
|
time.Sleep(12 * time.Hour)
|
||||||
|
|
||||||
|
const unchecked = "192.0.2.4"
|
||||||
|
|
||||||
|
clients := []string{suspect, "192.0.2.2", "192.0.2.3", unchecked}
|
||||||
|
for _, client := range clients {
|
||||||
|
hitFrom(t, checker, client, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
wantChecked(t, abuseIPDB, clients[:3]...)
|
||||||
|
wantBudgetLeft(t, checker, 0)
|
||||||
|
|
||||||
|
// The check that used the budget up raised the alert, and logged it.
|
||||||
|
const usedUp = "the daily budget of AbuseIPDB checks is used up"
|
||||||
|
|
||||||
|
wantFailureAlert(t, queue, time.Now(), usedUp,
|
||||||
|
"3 checks spent; none is made until the day ends at 00:00 UTC", 0)
|
||||||
|
|
||||||
|
if !strings.Contains(log.String(), `"msg":"`+usedUp+`"`) {
|
||||||
|
t.Errorf("logged\n%s\nwant the budget used up", log.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restarted with what reputation.json keeps, it uses the scores, and
|
||||||
|
// checks no client until the day ends.
|
||||||
|
restarted := &abuseIPDBStandIn{}
|
||||||
|
again := newAbuseIPDB(restarted, p)
|
||||||
|
again.Load(checker.Snapshot())
|
||||||
|
|
||||||
|
wantScore(t, again, suspect, true, 100, true)
|
||||||
|
wantBudgetLeft(t, again, 0)
|
||||||
|
time.Sleep(12*time.Hour - time.Nanosecond)
|
||||||
|
wantScore(t, again, unchecked, true, 0, false)
|
||||||
|
synctest.Wait()
|
||||||
|
wantChecked(t, restarted)
|
||||||
|
|
||||||
|
// At midnight the budget is whole again.
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
wantBudgetLeft(t, again, 3)
|
||||||
|
wantScore(t, again, unchecked, true, 0, false)
|
||||||
|
synctest.Wait()
|
||||||
|
wantChecked(t, restarted, unchecked)
|
||||||
|
wantBudgetLeft(t, again, 2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
// key is the key sent, status and body what AbuseIPDB answers with,
|
||||||
|
// and error the failure.
|
||||||
|
key, body string
|
||||||
|
status int
|
||||||
|
error string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"a refusal, past AbuseIPDB's own limit", key,
|
||||||
|
`{"errors":[{"detail":"Daily rate limit of 1000 requests exceeded"}]}`,
|
||||||
|
http.StatusTooManyRequests, "the server answered 429 Too Many Requests",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a refusal of a wrong key", "wrong-key-0123456789abcdef", "", 0,
|
||||||
|
"the server answered 401 Unauthorized",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a server failure", key, "", http.StatusInternalServerError,
|
||||||
|
"the server answered 500 Internal Server Error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"an answer without a score", key, `{"data":{"ipAddress":"` + suspect + `"}}`,
|
||||||
|
http.StatusOK, "the answer gives no abuseConfidenceScore",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"an answer that is not JSON", key, "<html>", http.StatusOK,
|
||||||
|
"read the answer: invalid character '<' looking for beginning of value",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
var log bytes.Buffer
|
||||||
|
|
||||||
|
queue := newQueue()
|
||||||
|
p := abuseIPDBParams()
|
||||||
|
p.Key = tc.key
|
||||||
|
p.Alerts = queue
|
||||||
|
p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil))
|
||||||
|
abuseIPDB := &abuseIPDBStandIn{status: tc.status, body: tc.body}
|
||||||
|
checker := newAbuseIPDB(abuseIPDB, p)
|
||||||
|
|
||||||
|
// The failure gives no score, and no client is checked within a
|
||||||
|
// minute of it.
|
||||||
|
wantScore(t, checker, suspect, true, 0, false)
|
||||||
|
synctest.Wait()
|
||||||
|
time.Sleep(time.Minute - time.Nanosecond)
|
||||||
|
wantScore(t, checker, other, true, 0, false)
|
||||||
|
synctest.Wait()
|
||||||
|
wantChecked(t, abuseIPDB, suspect)
|
||||||
|
wantFailures(t, checker, 1)
|
||||||
|
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
wantScore(t, checker, other, true, 0, false)
|
||||||
|
synctest.Wait()
|
||||||
|
wantChecked(t, abuseIPDB, suspect, other)
|
||||||
|
wantFailures(t, checker, 2)
|
||||||
|
|
||||||
|
if scores := checker.Snapshot().Scores; len(scores) != 0 {
|
||||||
|
t.Errorf("scores %+v, want none", scores)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One alert for the first failure; the cooldown holds back the
|
||||||
|
// second.
|
||||||
|
wantFailureAlert(t, queue, time.Now().Add(-time.Minute),
|
||||||
|
"checking a client with AbuseIPDB failed", tc.error, 1)
|
||||||
|
|
||||||
|
if !strings.Contains(log.String(), `"msg":"checking a client with `+
|
||||||
|
`AbuseIPDB failed","source":"abuseipdb","error":"`+tc.error) {
|
||||||
|
t.Errorf("logged\n%s\nwant the failures", log.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckNotAnsweredWithinTheTimeoutFailsAndHitNeverWaits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
queue := newQueue()
|
||||||
|
p := abuseIPDBParams()
|
||||||
|
p.Alerts = queue
|
||||||
|
abuseIPDB := &abuseIPDBStandIn{hanging: true}
|
||||||
|
checker := newAbuseIPDB(abuseIPDB, p)
|
||||||
|
began := time.Now()
|
||||||
|
|
||||||
|
// The second, while the first's check is under way, starts none.
|
||||||
|
wantScore(t, checker, suspect, true, 0, false)
|
||||||
|
wantScore(t, checker, suspect, true, 0, false)
|
||||||
|
|
||||||
|
if waited := time.Since(began); waited != 0 {
|
||||||
|
t.Errorf("waited %s for the check, want no wait", waited)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(timeout - time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantChecked(t, abuseIPDB, suspect)
|
||||||
|
wantFailures(t, checker, 0)
|
||||||
|
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantFailures(t, checker, 1)
|
||||||
|
wantFailureAlert(t, queue, time.Now(), "checking a client with AbuseIPDB failed",
|
||||||
|
"check the client: context deadline exceeded", 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyIsSentInTheKeyHeaderAndNeverShown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
var log bytes.Buffer
|
||||||
|
|
||||||
|
queue := newQueue()
|
||||||
|
p := abuseIPDBParams()
|
||||||
|
p.Alerts = queue
|
||||||
|
p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil))
|
||||||
|
abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
|
||||||
|
checker := newAbuseIPDB(abuseIPDB, p)
|
||||||
|
m := metrics.New(1, "app")
|
||||||
|
m.AddReputation(reputation.New(params()), reputation.NewDNSBL(dnsblParams()))
|
||||||
|
m.AddAbuseIPDB(checker)
|
||||||
|
|
||||||
|
// One check that AbuseIPDB answers, and one it refuses with an answer
|
||||||
|
// that names the key.
|
||||||
|
wantScore(t, checker, suspect, true, 0, false)
|
||||||
|
synctest.Wait()
|
||||||
|
wantScore(t, checker, suspect, true, 100, true)
|
||||||
|
|
||||||
|
abuseIPDB.answerWith(http.StatusUnauthorized, `{"errors":[{"detail":"`+key+`"}]}`)
|
||||||
|
wantScore(t, checker, other, true, 0, false)
|
||||||
|
synctest.Wait()
|
||||||
|
wantFailures(t, checker, 1)
|
||||||
|
|
||||||
|
abuseIPDB.mu.Lock()
|
||||||
|
sent := slices.Clone(abuseIPDB.keys)
|
||||||
|
abuseIPDB.mu.Unlock()
|
||||||
|
|
||||||
|
if !slices.Equal(sent, []string{key, key}) {
|
||||||
|
t.Errorf("checks sent the keys %v, want %s twice", sent, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
alerted, err := json.Marshal(waiting(queue))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode the alerts: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kept, err := json.Marshal(checker.Snapshot())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode the checks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, shown := range map[string]string{
|
||||||
|
"the log": log.String(), "the alerts": string(alerted),
|
||||||
|
"the metrics": scrapeMetrics(t, m), "reputation.json": string(kept),
|
||||||
|
} {
|
||||||
|
if strings.Contains(shown, key) {
|
||||||
|
t.Errorf("%s shows the key:\n%s", name, shown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricsCountTheChecksTheFailuresAndTheBudgetLeft(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
abuseIPDB := &abuseIPDBStandIn{}
|
||||||
|
p := abuseIPDBParams()
|
||||||
|
p.DailyBudget = 5
|
||||||
|
checker := newAbuseIPDB(abuseIPDB, p)
|
||||||
|
m := metrics.New(1, "app")
|
||||||
|
m.AddReputation(reputation.New(params()), reputation.NewDNSBL(dnsblParams()))
|
||||||
|
m.AddAbuseIPDB(checker)
|
||||||
|
|
||||||
|
// One check that AbuseIPDB answers, and one that fails.
|
||||||
|
hitFrom(t, checker, suspect, true)
|
||||||
|
synctest.Wait()
|
||||||
|
abuseIPDB.answerWith(http.StatusInternalServerError, "")
|
||||||
|
hitFrom(t, checker, other, true)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
scraped := scrapeMetrics(t, m)
|
||||||
|
|
||||||
|
for series, want := range map[string]string{
|
||||||
|
"queries_total": "2",
|
||||||
|
"failures_total": "1",
|
||||||
|
"daily_budget_remaining": "3",
|
||||||
|
} {
|
||||||
|
line := "\nsmallwebwaf_reputation_" + series +
|
||||||
|
`{instance="app",source="abuseipdb"} ` + want + "\n"
|
||||||
|
if !strings.Contains(scraped, line) {
|
||||||
|
t.Errorf("metrics\n%s\nwant%s", scraped, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreFetchedATTLAgoIsNeitherUsedNorKept(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
|
||||||
|
p := abuseIPDBParams()
|
||||||
|
p.Now = func() time.Time { return now }
|
||||||
|
checker := reputation.NewAbuseIPDB(p)
|
||||||
|
|
||||||
|
// The last score still in use, and one, of other's /64, fetched a TTL
|
||||||
|
// ago.
|
||||||
|
inUse := reputation.Score{
|
||||||
|
Client: netip.MustParsePrefix(suspect + "/32"), Score: 100,
|
||||||
|
Fetched: now.Add(-cacheTTL + time.Nanosecond),
|
||||||
|
}
|
||||||
|
stale := reputation.Score{
|
||||||
|
Client: netip.MustParsePrefix("2001:db8::/64"), Score: 100,
|
||||||
|
Fetched: now.Add(-cacheTTL),
|
||||||
|
}
|
||||||
|
|
||||||
|
checker.Load(reputation.Checks{Scores: []reputation.Score{stale, inUse}})
|
||||||
|
|
||||||
|
wantScore(t, checker, suspect, false, 100, true)
|
||||||
|
wantScore(t, checker, other, false, 0, false)
|
||||||
|
|
||||||
|
got := checker.Snapshot().Scores
|
||||||
|
if !reflect.DeepEqual(got, []reputation.Score{inUse}) {
|
||||||
|
t.Errorf("scores %+v, want only %+v", got, inUse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAtMost100000ScoresKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
|
||||||
|
p := abuseIPDBParams()
|
||||||
|
p.Now = func() time.Time { return now }
|
||||||
|
checker := reputation.NewAbuseIPDB(p)
|
||||||
|
|
||||||
|
// 100,001 scores, listed by client, as reputation.json lists them, each
|
||||||
|
// fetched a millisecond before the one before it: the last is one too
|
||||||
|
// many.
|
||||||
|
const count = 100001
|
||||||
|
|
||||||
|
scores := make([]reputation.Score, 0, count)
|
||||||
|
|
||||||
|
addr := netip.MustParseAddr("198.18.0.0")
|
||||||
|
for i := range count {
|
||||||
|
scores = append(scores, reputation.Score{
|
||||||
|
Client: netip.PrefixFrom(addr, 32),
|
||||||
|
Fetched: now.Add(-time.Duration(i) * time.Millisecond),
|
||||||
|
})
|
||||||
|
addr = addr.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
checker.Load(reputation.Checks{Scores: scores})
|
||||||
|
|
||||||
|
got := checker.Snapshot().Scores
|
||||||
|
if len(got) != count-1 || !slices.Contains(got, scores[0]) ||
|
||||||
|
slices.Contains(got, scores[count-1]) {
|
||||||
|
t.Errorf("%d scores kept, want all but the one fetched longest ago", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// abuseIPDBStandIn is a stand-in for AbuseIPDB. It answers a check sent
|
||||||
|
// with key by the client's score, as scores gives it, 0 for a client it
|
||||||
|
// does not give; a check sent with another key with 401; and, while
|
||||||
|
// status is not 0, every check with status and body; and while hanging,
|
||||||
|
// none at all. It notes each client checked, and the key sent.
|
||||||
|
type abuseIPDBStandIn struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
scores map[string]int64
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
hanging bool
|
||||||
|
checked []string
|
||||||
|
keys []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoundTrip has the stand-in answer req, in place of the network. A check
|
||||||
|
// abandoned before the stand-in answers fails, as over the network.
|
||||||
|
func (s *abuseIPDBStandIn) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
client := req.URL.Query().Get("ipAddress")
|
||||||
|
sent := req.Header.Get("Key")
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.checked = append(s.checked, client)
|
||||||
|
s.keys = append(s.keys, sent)
|
||||||
|
score := s.scores[client]
|
||||||
|
status, body, hanging := s.status, s.body, s.hanging
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case hanging:
|
||||||
|
<-req.Context().Done()
|
||||||
|
|
||||||
|
return nil, req.Context().Err()
|
||||||
|
case sent != key:
|
||||||
|
status = http.StatusUnauthorized
|
||||||
|
case status == 0:
|
||||||
|
status = http.StatusOK
|
||||||
|
body = fmt.Sprintf(`{"data":{"ipAddress":%q,"abuseConfidenceScore":%d}}`, client,
|
||||||
|
score)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setScore has the stand-in give client score.
|
||||||
|
func (s *abuseIPDBStandIn) setScore(client string, score int64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.scores[client] = score
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerWith has the stand-in answer every check with status and body.
|
||||||
|
func (s *abuseIPDBStandIn) answerWith(status int, body string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.status, s.body = status, body
|
||||||
|
}
|
||||||
|
|
||||||
|
// abuseIPDBParams returns the AbuseIPDBParams of the tests: key, a minimum
|
||||||
|
// score of 75, a daily budget of 900, and the cache TTL and timeout of the
|
||||||
|
// DNSBL tests, by the bubble's clock, with alerts to a queue that sends
|
||||||
|
// none.
|
||||||
|
func abuseIPDBParams() reputation.AbuseIPDBParams {
|
||||||
|
return reputation.AbuseIPDBParams{
|
||||||
|
URL: "https://abuseipdb.example/api/v2/check",
|
||||||
|
Key: key,
|
||||||
|
MinScore: 75,
|
||||||
|
DailyBudget: 900,
|
||||||
|
CacheTTL: cacheTTL,
|
||||||
|
Timeout: timeout,
|
||||||
|
Now: time.Now,
|
||||||
|
ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
|
Alerts: newQueue(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAbuseIPDB returns the AbuseIPDB of p, checking clients with
|
||||||
|
// abuseIPDB.
|
||||||
|
func newAbuseIPDB(
|
||||||
|
abuseIPDB *abuseIPDBStandIn, p reputation.AbuseIPDBParams,
|
||||||
|
) *reputation.AbuseIPDB {
|
||||||
|
checker := reputation.NewAbuseIPDB(p)
|
||||||
|
checker.SetTransport(abuseIPDB)
|
||||||
|
|
||||||
|
return checker
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantScore checks the score checker gives client, and whether it is a
|
||||||
|
// hit, as a request from client finds them, offender or not.
|
||||||
|
func wantScore(
|
||||||
|
t *testing.T, checker *reputation.AbuseIPDB, client string, offender bool,
|
||||||
|
score int64, hit bool,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
gotScore, gotHit := hitFrom(t, checker, client, offender)
|
||||||
|
if gotScore != score || gotHit != hit {
|
||||||
|
t.Errorf("%s has the score %d, a hit %t, want %d, %t", client, gotScore, gotHit,
|
||||||
|
score, hit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hitFrom is checker's Hit for a request from address, offender or not.
|
||||||
|
// Its client is address for an IPv4 address, and its /64 for an IPv6 one,
|
||||||
|
// as smallwebwaf counts clients.
|
||||||
|
func hitFrom(
|
||||||
|
t *testing.T, checker *reputation.AbuseIPDB, address string, offender bool,
|
||||||
|
) (int64, bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
addr := netip.MustParseAddr(address)
|
||||||
|
|
||||||
|
client := netip.PrefixFrom(addr, addr.BitLen())
|
||||||
|
if addr.Is6() {
|
||||||
|
client = netip.PrefixFrom(addr, 64).Masked()
|
||||||
|
}
|
||||||
|
|
||||||
|
return checker.Hit(t.Context(), client, addr, offender)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantChecked checks the clients the stand-in was asked about, in any
|
||||||
|
// order.
|
||||||
|
func wantChecked(t *testing.T, abuseIPDB *abuseIPDBStandIn, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
abuseIPDB.mu.Lock()
|
||||||
|
got := slices.Sorted(slices.Values(abuseIPDB.checked))
|
||||||
|
abuseIPDB.mu.Unlock()
|
||||||
|
|
||||||
|
want = slices.Sorted(slices.Values(want))
|
||||||
|
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("checked %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantFailures checks how many checks failed.
|
||||||
|
func wantFailures(t *testing.T, checker *reputation.AbuseIPDB, want int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if got := checker.Failures(); got != want {
|
||||||
|
t.Errorf("%d checks failed, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantFailureAlert checks that the one alert waiting in queue is a
|
||||||
|
// source_failure alert from AbuseIPDB, raised at raised, with reason and
|
||||||
|
// the error failure, and that the cooldown has held back held repeats of
|
||||||
|
// it.
|
||||||
|
func wantFailureAlert(
|
||||||
|
t *testing.T, queue *alerts.Queue, raised time.Time, reason, failure string,
|
||||||
|
held int64,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got := waiting(queue)
|
||||||
|
if len(got) != 1 || !got[0].Time.Equal(raised) ||
|
||||||
|
got[0].Event != alerts.EventSourceFailure || got[0].Reason != reason ||
|
||||||
|
got[0].Detail["source"] != reputation.AbuseIPDBSource ||
|
||||||
|
got[0].Detail["error"] != failure || queue.Suppressed() != held {
|
||||||
|
t.Errorf("alerts waiting %+v, %d held back, want only AbuseIPDB's %q with %q, "+
|
||||||
|
"and %d", got, queue.Suppressed(), reason, failure, held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantBudgetLeft checks how many checks the day's budget has left.
|
||||||
|
func wantBudgetLeft(t *testing.T, checker *reputation.AbuseIPDB, want int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if got := checker.BudgetLeft(); got != want {
|
||||||
|
t.Errorf("%d checks left, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrapeMetrics returns the metrics m serves.
|
||||||
|
func scrapeMetrics(t *testing.T, m *metrics.Metrics) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
scraped := httptest.NewRecorder()
|
||||||
|
m.ServeHTTP(scraped, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/",
|
||||||
|
http.NoBody))
|
||||||
|
|
||||||
|
return scraped.Body.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package reputation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxVerdicts is how many verdicts of the DNSBL zones are kept, and how
|
||||||
|
// many scores of AbuseIPDB. Past it, the one fetched longest ago is
|
||||||
|
// dropped.
|
||||||
|
maxVerdicts = 100000
|
||||||
|
// maxQueries is how many queries may be under way at once. Past it, a
|
||||||
|
// zone is not asked about a client until the client's next request, so
|
||||||
|
// that a swarm of new addresses cannot fill the memory.
|
||||||
|
maxQueries = 1000
|
||||||
|
// failureDelay is how long a zone is not asked again after a query to
|
||||||
|
// it fails, and no client is checked with AbuseIPDB after a check
|
||||||
|
// fails, so that a source refusing them is not asked on every request.
|
||||||
|
failureDelay = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errAsk = errors.New("ask the zone")
|
||||||
|
errRefused = errors.New("the zone refused the query")
|
||||||
|
errNotListing = errors.New("the answer is outside 127.0.0.0/8")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verdict is what a zone said about a client, as reputation.json holds
|
||||||
|
// it: the zone, the client's address, whether the zone lists it, and when
|
||||||
|
// the zone answered.
|
||||||
|
type Verdict struct {
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
Client netip.Addr `json:"client"`
|
||||||
|
Listed bool `json:"listed"`
|
||||||
|
Fetched time.Time `json:"fetched"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSBLParams are what NewDNSBL needs.
|
||||||
|
type DNSBLParams struct {
|
||||||
|
// Zones are the DNSBL zones clients are asked about in
|
||||||
|
// (SWWAF_DNSBL_ZONES).
|
||||||
|
Zones []string
|
||||||
|
// Resolver is the resolver they are asked through
|
||||||
|
// (SWWAF_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
|
||||||
|
// host's, as /etc/resolv.conf names it.
|
||||||
|
Resolver netip.AddrPort
|
||||||
|
// CacheTTL is how long a verdict is used after it was fetched
|
||||||
|
// (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
||||||
|
// (SWWAF_REPUTATION_TIMEOUT).
|
||||||
|
CacheTTL time.Duration
|
||||||
|
Timeout time.Duration
|
||||||
|
// Now tells the time, normally time.Now in UTC.
|
||||||
|
Now func() time.Time
|
||||||
|
// ProcessLog receives each query that fails, and why.
|
||||||
|
ProcessLog *slog.Logger
|
||||||
|
// Alerts receive a source_failure alert for each query that fails.
|
||||||
|
Alerts *alerts.Queue
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSBL asks the DNSBL zones about clients, in the background, and keeps
|
||||||
|
// their verdicts. It is safe for concurrent use.
|
||||||
|
type DNSBL struct {
|
||||||
|
params DNSBLParams
|
||||||
|
resolver *net.Resolver
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
// verdicts are by query. Each is added as it is fetched and never moved
|
||||||
|
// up, so that the one fetched longest ago is the first dropped.
|
||||||
|
verdicts *simplelru.LRU[query, Verdict]
|
||||||
|
// asking are the queries under way.
|
||||||
|
asking map[query]bool
|
||||||
|
// queries and failures count, by zone, the queries made and those that
|
||||||
|
// failed, and retryAt is when a zone whose last query failed may be
|
||||||
|
// asked again.
|
||||||
|
queries map[string]int
|
||||||
|
failures map[string]int
|
||||||
|
retryAt map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// query is a client's address, to ask a zone about.
|
||||||
|
type query struct {
|
||||||
|
zone string
|
||||||
|
client netip.Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDNSBL returns a DNSBL with no verdict yet.
|
||||||
|
func NewDNSBL(params DNSBLParams) *DNSBL {
|
||||||
|
verdicts, err := simplelru.NewLRU[query, Verdict](maxVerdicts, nil)
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // NewLRU fails only for a size below one
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver := &net.Resolver{}
|
||||||
|
if params.Resolver.IsValid() {
|
||||||
|
// Dial is used by Go's own resolver alone.
|
||||||
|
resolver.PreferGo = true
|
||||||
|
resolver.Dial = func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||||
|
var dialer net.Dialer
|
||||||
|
|
||||||
|
return dialer.DialContext(ctx, network, params.Resolver.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DNSBL{
|
||||||
|
params: params,
|
||||||
|
resolver: resolver,
|
||||||
|
verdicts: verdicts,
|
||||||
|
asking: map[query]bool{},
|
||||||
|
queries: map[string]int{},
|
||||||
|
failures: map[string]int{},
|
||||||
|
retryAt: map[string]time.Time{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zones returns the zones, in the order SWWAF_DNSBL_ZONES names them.
|
||||||
|
func (d *DNSBL) Zones() []string {
|
||||||
|
return slices.Clone(d.params.Zones)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListedBy returns the zones whose verdict on addr, a client's address,
|
||||||
|
// lists it, in the order SWWAF_DNSBL_ZONES names them, each with its key
|
||||||
|
// masked, as config.MaskZoneKey masks it, since they go to the request
|
||||||
|
// log, the alerts and the metrics. A verdict is used until CacheTTL has
|
||||||
|
// passed since it was fetched. Each zone without one is asked about addr
|
||||||
|
// in the background, unless a query about addr to it is under way, the
|
||||||
|
// zone is left alone after a failure, or maxQueries are under way;
|
||||||
|
// ListedBy never waits for a query. ctx is the context of the client's
|
||||||
|
// request, and a query goes on after the request ends.
|
||||||
|
func (d *DNSBL) ListedBy(ctx context.Context, addr netip.Addr) []string {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
now := d.params.Now()
|
||||||
|
|
||||||
|
var listedBy []string
|
||||||
|
|
||||||
|
for _, zone := range d.params.Zones {
|
||||||
|
q := query{zone: zone, client: addr}
|
||||||
|
|
||||||
|
kept, found := d.verdicts.Peek(q)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case found && now.Sub(kept.Fetched) < d.params.CacheTTL:
|
||||||
|
if kept.Listed {
|
||||||
|
listedBy = append(listedBy, config.MaskZoneKey(zone))
|
||||||
|
}
|
||||||
|
case !d.asking[q] && !now.Before(d.retryAt[zone]) && len(d.asking) < maxQueries:
|
||||||
|
d.asking[q] = true
|
||||||
|
d.queries[zone]++
|
||||||
|
|
||||||
|
go d.ask(context.WithoutCancel(ctx), q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listedBy
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queries returns how many queries were made to zone.
|
||||||
|
func (d *DNSBL) Queries(zone string) int {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
return d.queries[zone]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failures returns how many queries to zone failed.
|
||||||
|
func (d *DNSBL) Failures(zone string) int {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
return d.failures[zone]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns every verdict still in use, sorted by client, then by
|
||||||
|
// zone, as reputation.json lists them.
|
||||||
|
func (d *DNSBL) Snapshot() []Verdict {
|
||||||
|
d.mu.Lock()
|
||||||
|
|
||||||
|
now := d.params.Now()
|
||||||
|
verdicts := make([]Verdict, 0, d.verdicts.Len())
|
||||||
|
|
||||||
|
for _, kept := range d.verdicts.Values() {
|
||||||
|
if now.Sub(kept.Fetched) < d.params.CacheTTL {
|
||||||
|
verdicts = append(verdicts, kept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
slices.SortFunc(verdicts, func(a, b Verdict) int {
|
||||||
|
return cmp.Or(a.Client.Compare(b.Client), strings.Compare(a.Zone, b.Zone))
|
||||||
|
})
|
||||||
|
|
||||||
|
return verdicts
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
|
||||||
|
// more is neither used nor written, as for any verdict.
|
||||||
|
func (d *DNSBL) Load(verdicts []Verdict) {
|
||||||
|
verdicts = slices.Clone(verdicts)
|
||||||
|
slices.SortStableFunc(verdicts, func(a, b Verdict) int {
|
||||||
|
return a.Fetched.Compare(b.Fetched)
|
||||||
|
})
|
||||||
|
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
d.verdicts.Purge()
|
||||||
|
|
||||||
|
for _, kept := range verdicts {
|
||||||
|
if slices.Contains(d.params.Zones, kept.Zone) {
|
||||||
|
d.verdicts.Add(query{zone: kept.Zone, client: kept.Client}, kept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask asks q's zone about q's client, keeps the verdict, and notes the
|
||||||
|
// query as no longer under way. A query that fails gives no verdict: it
|
||||||
|
// is counted, logged and raised as a source_failure alert, which show the
|
||||||
|
// zone with its key masked, and the zone is not asked again for
|
||||||
|
// failureDelay.
|
||||||
|
func (d *DNSBL) ask(ctx context.Context, q query) {
|
||||||
|
listed, err := d.lookUp(ctx, q)
|
||||||
|
now := d.params.Now()
|
||||||
|
|
||||||
|
d.mu.Lock()
|
||||||
|
|
||||||
|
delete(d.asking, q)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
d.verdicts.Add(q, Verdict{
|
||||||
|
Zone: q.zone, Client: q.client, Listed: listed, Fetched: now,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
d.failures[q.zone]++
|
||||||
|
d.retryAt[q.zone] = now.Add(failureDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
const failed = "asking a DNSBL zone failed"
|
||||||
|
|
||||||
|
shown := config.MaskZoneKey(q.zone)
|
||||||
|
|
||||||
|
// Raised before it is logged, so that the alert is there once the
|
||||||
|
// log line is.
|
||||||
|
raiseFailure(d.params.Alerts, failed, shown, err)
|
||||||
|
d.params.ProcessLog.Warn(failed, "zone", shown, "error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookUp asks q's zone about q's client through the resolver, and returns
|
||||||
|
// whether the zone lists it, as readAnswer reads the answer. No such name
|
||||||
|
// is a client the zone does not list. A query not answered within Timeout
|
||||||
|
// fails.
|
||||||
|
func (d *DNSBL) lookUp(ctx context.Context, q query) (bool, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, d.params.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
answer, err := d.resolver.LookupNetIP(ctx, "ip4", queryName(q.zone, q.client))
|
||||||
|
|
||||||
|
var dnsErr *net.DNSError
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return readAnswer(answer)
|
||||||
|
case errors.As(err, &dnsErr) && dnsErr.IsNotFound:
|
||||||
|
return false, nil
|
||||||
|
case errors.As(err, &dnsErr):
|
||||||
|
// The error names the name asked about, which holds the client's
|
||||||
|
// address, which is not to be logged: only what went wrong is kept.
|
||||||
|
return false, fmt.Errorf("%w: %s", errAsk, dnsErr.Err)
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("%w: %w", errAsk, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryName returns the name a zone is asked about addr by, as RFC 5782
|
||||||
|
// builds it: the four numbers of an IPv4 address, or the 32 hex digits of
|
||||||
|
// an IPv6 address, in reverse order, each followed by a dot, then the zone
|
||||||
|
// and a dot, which makes it a full name, to which the resolver adds no
|
||||||
|
// search domain of /etc/resolv.conf.
|
||||||
|
func queryName(zone string, addr netip.Addr) string {
|
||||||
|
parts := strings.Split(addr.String(), ".")
|
||||||
|
if addr.Is6() {
|
||||||
|
parts = strings.Split(hex.EncodeToString(addr.AsSlice()), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Reverse(parts)
|
||||||
|
|
||||||
|
return strings.Join(parts, ".") + "." + zone + "."
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAnswer reads the addresses a zone answered with. An address in
|
||||||
|
// 127.0.0.0/8 lists the client, as RFC 5782 has zones answer, but one in
|
||||||
|
// 127.255.255.0/24 is how Spamhaus refuses a query, such as one sent
|
||||||
|
// through a public resolver or one past its limit, and is a failure. So is
|
||||||
|
// an address outside 127.0.0.0/8, such as a resolver gives that answers
|
||||||
|
// even for names that do not exist.
|
||||||
|
func readAnswer(answer []netip.Addr) (bool, error) {
|
||||||
|
listing := netip.MustParsePrefix("127.0.0.0/8")
|
||||||
|
refusal := netip.MustParsePrefix("127.255.255.0/24")
|
||||||
|
|
||||||
|
for _, addr := range answer {
|
||||||
|
switch {
|
||||||
|
case refusal.Contains(addr):
|
||||||
|
return false, fmt.Errorf("%w: %s", errRefused, addr)
|
||||||
|
case !listing.Contains(addr):
|
||||||
|
return false, fmt.Errorf("%w: %s", errNotListing, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(answer) > 0, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,737 @@
|
|||||||
|
package reputation_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The tests of the DNSBL zones run in synctest bubbles, as those of the
|
||||||
|
// lists do, and the resolver the zones are asked through is a stand-in
|
||||||
|
// reached through an in-memory connection, net.Pipe's, for the same
|
||||||
|
// reason. They run one at a time, none in parallel with another test of
|
||||||
|
// this package: Go's resolver counts the queries under way in one
|
||||||
|
// sync.WaitGroup for the whole process, and the process fails when
|
||||||
|
// queries from two bubbles, or from a bubble and from outside one, are
|
||||||
|
// under way at once. TestMain has the resolver make its configuration,
|
||||||
|
// which it makes on its first query, outside every bubble, since the
|
||||||
|
// configuration holds a channel, which the bubble it was made in would
|
||||||
|
// keep to itself.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// zone and otherZone are the DNSBL zones the tests name.
|
||||||
|
zone = "dnsbl.example"
|
||||||
|
otherZone = "other.example"
|
||||||
|
// cacheTTL is the tests' SWWAF_REPUTATION_CACHE_TTL, and timeout their
|
||||||
|
// SWWAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
|
||||||
|
// can have Go's resolver wait for one server, so that it is the
|
||||||
|
// DNSBL's own timeout that ends a query, whatever that file says.
|
||||||
|
cacheTTL = 24 * time.Hour
|
||||||
|
timeout = time.Second
|
||||||
|
// listed and unlisted are clients zone is asked about by the names
|
||||||
|
// listedName and unlistedName, and most tests have zone list the first
|
||||||
|
// alone, by answering with listing.
|
||||||
|
listed = "192.0.2.99"
|
||||||
|
unlisted = "192.0.2.100"
|
||||||
|
listedName = "99.2.0.192." + zone + "."
|
||||||
|
unlistedName = "100.2.0.192." + zone + "."
|
||||||
|
listing = "127.0.0.2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The DNS response codes the stand-in answers with, besides no error.
|
||||||
|
const (
|
||||||
|
serverFailure = 2
|
||||||
|
noSuchName = 3
|
||||||
|
refused = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNoNetwork = errors.New("the test dials nothing")
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
// A query that fails at once, as nothing is dialled for it.
|
||||||
|
resolver := &net.Resolver{
|
||||||
|
PreferGo: true,
|
||||||
|
Dial: func(context.Context, string, string) (net.Conn, error) {
|
||||||
|
return nil, errNoNetwork
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, _ = resolver.LookupNetIP(context.Background(), "ip4", "warm-up.invalid.")
|
||||||
|
|
||||||
|
m.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||||
|
func TestZonesListOrNotClientsByTheirIPv4AndIPv6Addresses(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
// The addresses of the examples of RFC 5782, and the names it
|
||||||
|
// gives for them.
|
||||||
|
const (
|
||||||
|
v4 = "192.0.2.99"
|
||||||
|
v6 = "2001:db8:1:2:3:4:567:89ab"
|
||||||
|
// v6Name is the hex digits of v6, in reverse order.
|
||||||
|
v6Name = "b.a.9.8.7.6.5.0.4.0.0.0.3.0.0.0.2.0.0.0.1.0.0.0.8.b.d.0.1.0.0.2."
|
||||||
|
)
|
||||||
|
|
||||||
|
resolver := &resolverStandIn{answers: map[string]answer{
|
||||||
|
"99.2.0.192." + zone + ".": {addrs: []string{listing}},
|
||||||
|
v6Name + otherZone + ".": {addrs: []string{"127.0.0.4", "127.0.0.10"}},
|
||||||
|
"99.2.0.192." + otherZone + ".": {},
|
||||||
|
}}
|
||||||
|
dnsbl := newDNSBL(resolver, dnsblParams(zone, otherZone))
|
||||||
|
|
||||||
|
// Neither client has a verdict yet, so neither is listed, and each
|
||||||
|
// zone is asked about each.
|
||||||
|
wantZones(t, dnsbl, v4)
|
||||||
|
wantZones(t, dnsbl, v6)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
wantZones(t, dnsbl, v4, zone)
|
||||||
|
wantZones(t, dnsbl, v6, otherZone)
|
||||||
|
wantAsked(t, resolver,
|
||||||
|
"99.2.0.192."+zone+".", "99.2.0.192."+otherZone+".",
|
||||||
|
v6Name+zone+".", v6Name+otherZone+".")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||||
|
func TestListedByNeverWaitsForAQuery(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
dnsbl := newDNSBL(&resolverStandIn{hanging: true}, dnsblParams(zone))
|
||||||
|
began := time.Now()
|
||||||
|
|
||||||
|
// The second, while the first's query is under way, starts none.
|
||||||
|
wantZones(t, dnsbl, listed)
|
||||||
|
wantZones(t, dnsbl, listed)
|
||||||
|
|
||||||
|
if waited := time.Since(began); waited != 0 {
|
||||||
|
t.Errorf("waited %s for the query, want no wait", waited)
|
||||||
|
}
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
wantQueries(t, dnsbl, 1, 0)
|
||||||
|
waitForTheResolver()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||||
|
func TestVerdictUsedUntilTheCacheTTLHasPassedSinceItWasFetched(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
resolver := &resolverStandIn{answers: map[string]answer{
|
||||||
|
listedName: {addrs: []string{listing}},
|
||||||
|
}}
|
||||||
|
dnsbl := newDNSBL(resolver, dnsblParams(zone))
|
||||||
|
|
||||||
|
wantZones(t, dnsbl, listed)
|
||||||
|
wantZones(t, dnsbl, unlisted)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
// The zone lists the other client from now on, but the verdicts
|
||||||
|
// kept are used, and the zone is not asked again, until the TTL
|
||||||
|
// has passed.
|
||||||
|
resolver.set(listedName, answer{rcode: noSuchName})
|
||||||
|
resolver.set(unlistedName, answer{addrs: []string{listing}})
|
||||||
|
time.Sleep(cacheTTL - time.Nanosecond)
|
||||||
|
|
||||||
|
wantZones(t, dnsbl, listed, zone)
|
||||||
|
wantZones(t, dnsbl, unlisted)
|
||||||
|
synctest.Wait()
|
||||||
|
wantQueries(t, dnsbl, 2, 0)
|
||||||
|
|
||||||
|
// Then neither verdict is used, and both clients are asked about
|
||||||
|
// again.
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
wantZones(t, dnsbl, listed)
|
||||||
|
wantZones(t, dnsbl, unlisted)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
wantQueries(t, dnsbl, 4, 0)
|
||||||
|
wantZones(t, dnsbl, listed)
|
||||||
|
wantZones(t, dnsbl, unlisted, zone)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||||
|
func TestQueryNotAnsweredWithinTheTimeoutFails(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
queue := newQueue()
|
||||||
|
p := dnsblParams(zone)
|
||||||
|
p.Alerts = queue
|
||||||
|
dnsbl := newDNSBL(&resolverStandIn{hanging: true}, p)
|
||||||
|
|
||||||
|
wantZones(t, dnsbl, listed)
|
||||||
|
time.Sleep(timeout - time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantQueries(t, dnsbl, 1, 0)
|
||||||
|
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantQueries(t, dnsbl, 1, 1)
|
||||||
|
|
||||||
|
if got := waiting(queue); len(got) != 1 ||
|
||||||
|
got[0].Detail["error"] != "ask the zone: i/o timeout" {
|
||||||
|
t.Errorf("alerts waiting %+v, want the timeout's", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if verdicts := dnsbl.Snapshot(); len(verdicts) != 0 {
|
||||||
|
t.Errorf("verdicts %+v, want none", verdicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForTheResolver()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||||
|
func TestZoneThatFailsOrRefusesGivesNoVerdictAndIsLeftAloneForAMinute(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
answer answer
|
||||||
|
error string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"a server failure", answer{rcode: serverFailure},
|
||||||
|
"ask the zone: server misbehaving",
|
||||||
|
},
|
||||||
|
{"a refusal", answer{rcode: refused}, "ask the zone: server misbehaving"},
|
||||||
|
{
|
||||||
|
"an answer in 127.255.255.0/24, with which Spamhaus refuses a query",
|
||||||
|
answer{addrs: []string{"127.255.255.254"}},
|
||||||
|
"the zone refused the query: 127.255.255.254",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"an answer outside 127.0.0.0/8, as for a name that does not exist",
|
||||||
|
answer{addrs: []string{"192.0.2.1"}},
|
||||||
|
"the answer is outside 127.0.0.0/8: 192.0.2.1",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
var log bytes.Buffer
|
||||||
|
|
||||||
|
queue := newQueue()
|
||||||
|
p := dnsblParams(zone)
|
||||||
|
p.Alerts = queue
|
||||||
|
p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil))
|
||||||
|
dnsbl := newDNSBL(&resolverStandIn{answers: map[string]answer{
|
||||||
|
listedName: tc.answer,
|
||||||
|
}}, p)
|
||||||
|
|
||||||
|
// The failure gives no verdict, and the zone is not asked
|
||||||
|
// again within a minute of it.
|
||||||
|
wantZones(t, dnsbl, listed)
|
||||||
|
synctest.Wait()
|
||||||
|
time.Sleep(time.Minute - time.Nanosecond)
|
||||||
|
wantZones(t, dnsbl, listed)
|
||||||
|
synctest.Wait()
|
||||||
|
wantQueries(t, dnsbl, 1, 1)
|
||||||
|
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
wantZones(t, dnsbl, listed)
|
||||||
|
synctest.Wait()
|
||||||
|
wantQueries(t, dnsbl, 2, 2)
|
||||||
|
|
||||||
|
if verdicts := dnsbl.Snapshot(); len(verdicts) != 0 {
|
||||||
|
t.Errorf("verdicts %+v, want none", verdicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One alert for the first failure; the cooldown holds back
|
||||||
|
// the second.
|
||||||
|
wantAlert(t, queue, alerts.Alert{
|
||||||
|
Time: time.Now().Add(-time.Minute),
|
||||||
|
Event: alerts.EventSourceFailure,
|
||||||
|
Reason: "asking a DNSBL zone failed",
|
||||||
|
Detail: map[string]any{"source": zone, "error": tc.error},
|
||||||
|
})
|
||||||
|
|
||||||
|
if !strings.Contains(log.String(), `"msg":"asking a DNSBL zone failed",`+
|
||||||
|
`"zone":"`+zone+`","error":"`+tc.error) {
|
||||||
|
t.Errorf("logged\n%s\nwant the failures", log.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||||
|
func TestAtMost1000QueriesUnderWay(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
dnsbl := newDNSBL(&resolverStandIn{hanging: true}, dnsblParams(zone))
|
||||||
|
|
||||||
|
client := netip.MustParseAddr("198.18.0.0")
|
||||||
|
for range 1001 {
|
||||||
|
dnsbl.ListedBy(t.Context(), client)
|
||||||
|
client = client.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
wantQueries(t, dnsbl, 1000, 0)
|
||||||
|
waitForTheResolver()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||||
|
func TestMetricsCountEachZonesQueriesAndThoseThatFailed(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
resolver := &resolverStandIn{answers: map[string]answer{
|
||||||
|
listedName: {addrs: []string{listing}},
|
||||||
|
"99.2.0.192." + otherZone + ".": {rcode: serverFailure},
|
||||||
|
}}
|
||||||
|
dnsbl := newDNSBL(resolver, dnsblParams(zone, otherZone))
|
||||||
|
m := metrics.New(1, "app")
|
||||||
|
m.AddReputation(reputation.New(params()), dnsbl)
|
||||||
|
|
||||||
|
wantZones(t, dnsbl, listed)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
scraped := httptest.NewRecorder()
|
||||||
|
m.ServeHTTP(scraped, httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/", http.NoBody))
|
||||||
|
|
||||||
|
for series, want := range map[string]string{
|
||||||
|
"queries_total" + `{instance="app",source="` + zone + `"}`: "1",
|
||||||
|
"failures_total" + `{instance="app",source="` + zone + `"}`: "0",
|
||||||
|
"queries_total" + `{instance="app",source="` + otherZone + `"}`: "1",
|
||||||
|
"failures_total" + `{instance="app",source="` + otherZone + `"}`: "1",
|
||||||
|
} {
|
||||||
|
line := "\nsmallwebwaf_reputation_" + series + " " + want + "\n"
|
||||||
|
if !strings.Contains(scraped.Body.String(), line) {
|
||||||
|
t.Errorf("metrics\n%s\nwant%s", scraped.Body.String(), line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||||
|
func TestZoneKeyIsMaskedInTheVerdictsTheFailuresAndTheMetrics(t *testing.T) {
|
||||||
|
const (
|
||||||
|
key = "abcdefghijklmnopqrstuvwxyz"
|
||||||
|
keyed = key + ".xbl.dq.spamhaus.net"
|
||||||
|
masked = "********.xbl.dq.spamhaus.net"
|
||||||
|
)
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
var log bytes.Buffer
|
||||||
|
|
||||||
|
queue := newQueue()
|
||||||
|
p := dnsblParams(keyed)
|
||||||
|
p.Alerts = queue
|
||||||
|
p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil))
|
||||||
|
dnsbl := newDNSBL(&resolverStandIn{answers: map[string]answer{
|
||||||
|
"99.2.0.192." + keyed + ".": {addrs: []string{listing}},
|
||||||
|
"100.2.0.192." + keyed + ".": {rcode: serverFailure},
|
||||||
|
}}, p)
|
||||||
|
m := metrics.New(1, "app")
|
||||||
|
m.AddReputation(reputation.New(params()), dnsbl)
|
||||||
|
|
||||||
|
// Both clients are asked about before either answer comes, so that
|
||||||
|
// the failure does not keep the zone from the other query.
|
||||||
|
wantZones(t, dnsbl, listed)
|
||||||
|
wantZones(t, dnsbl, unlisted)
|
||||||
|
synctest.Wait()
|
||||||
|
wantZones(t, dnsbl, listed, masked)
|
||||||
|
|
||||||
|
if got := waiting(queue); len(got) != 1 || got[0].Detail["source"] != masked {
|
||||||
|
t.Errorf("alerts waiting %+v, want the failure's, from %s", got, masked)
|
||||||
|
}
|
||||||
|
|
||||||
|
scraped := httptest.NewRecorder()
|
||||||
|
m.ServeHTTP(scraped, httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/", http.NoBody))
|
||||||
|
|
||||||
|
for name, shown := range map[string]string{
|
||||||
|
"the log": log.String(), "the metrics": scraped.Body.String(),
|
||||||
|
} {
|
||||||
|
if strings.Contains(shown, key) || !strings.Contains(shown, masked) {
|
||||||
|
t.Errorf("%s shows the key, or does not name the zone:\n%s", name, shown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||||
|
func TestVerdictsKeptAcrossARestart(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
resolver := &resolverStandIn{answers: map[string]answer{
|
||||||
|
listedName: {addrs: []string{listing}},
|
||||||
|
}}
|
||||||
|
dnsbl := newDNSBL(resolver, dnsblParams(zone))
|
||||||
|
fetched := time.Now()
|
||||||
|
|
||||||
|
wantZones(t, dnsbl, unlisted)
|
||||||
|
wantZones(t, dnsbl, listed)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
kept := dnsbl.Snapshot()
|
||||||
|
|
||||||
|
want := []reputation.Verdict{
|
||||||
|
{Zone: zone, Client: netip.MustParseAddr(listed), Listed: true, Fetched: fetched},
|
||||||
|
{Zone: zone, Client: netip.MustParseAddr(unlisted), Fetched: fetched},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(kept, want) {
|
||||||
|
t.Errorf("verdicts %+v, want %+v", kept, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restarted an hour later with what reputation.json keeps, it uses
|
||||||
|
// the verdicts, and asks the zone nothing, until the TTL has passed
|
||||||
|
// since they were fetched.
|
||||||
|
time.Sleep(time.Hour)
|
||||||
|
|
||||||
|
restarted := &resolverStandIn{}
|
||||||
|
again := newDNSBL(restarted, dnsblParams(zone))
|
||||||
|
again.Load(kept)
|
||||||
|
|
||||||
|
wantZones(t, again, listed, zone)
|
||||||
|
wantZones(t, again, unlisted)
|
||||||
|
synctest.Wait()
|
||||||
|
wantAsked(t, restarted)
|
||||||
|
|
||||||
|
time.Sleep(cacheTTL - time.Hour)
|
||||||
|
wantZones(t, again, listed)
|
||||||
|
synctest.Wait()
|
||||||
|
wantAsked(t, restarted, listedName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNeitherAVerdictOfAZoneNotNamedNorOnePastItsTTLIsKept(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
|
||||||
|
p := dnsblParams(zone)
|
||||||
|
p.Now = func() time.Time { return now }
|
||||||
|
dnsbl := reputation.NewDNSBL(p)
|
||||||
|
|
||||||
|
// The last verdict still in use, one fetched a TTL ago, and one of a
|
||||||
|
// zone SWWAF_DNSBL_ZONES does not name.
|
||||||
|
inUse := reputation.Verdict{
|
||||||
|
Zone: zone, Client: netip.MustParseAddr(listed), Listed: true,
|
||||||
|
Fetched: now.Add(-cacheTTL + time.Nanosecond),
|
||||||
|
}
|
||||||
|
stale := reputation.Verdict{
|
||||||
|
Zone: zone, Client: netip.MustParseAddr(unlisted), Fetched: now.Add(-cacheTTL),
|
||||||
|
}
|
||||||
|
notNamed := reputation.Verdict{
|
||||||
|
Zone: otherZone, Client: netip.MustParseAddr(listed), Listed: true, Fetched: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsbl.Load([]reputation.Verdict{notNamed, stale, inUse})
|
||||||
|
|
||||||
|
if got := dnsbl.Snapshot(); !reflect.DeepEqual(got, []reputation.Verdict{inUse}) {
|
||||||
|
t.Errorf("verdicts %+v, want only %+v", got, inUse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
|
||||||
|
p := dnsblParams(zone)
|
||||||
|
p.Now = func() time.Time { return now }
|
||||||
|
dnsbl := reputation.NewDNSBL(p)
|
||||||
|
|
||||||
|
// 100,001 verdicts, listed by client, as reputation.json lists them,
|
||||||
|
// each fetched a millisecond before the one before it: the last is one
|
||||||
|
// too many.
|
||||||
|
const count = 100001
|
||||||
|
|
||||||
|
verdicts := make([]reputation.Verdict, 0, count)
|
||||||
|
|
||||||
|
client := netip.MustParseAddr("198.18.0.0")
|
||||||
|
for i := range count {
|
||||||
|
verdicts = append(verdicts, reputation.Verdict{
|
||||||
|
Zone: zone, Client: client, Fetched: now.Add(-time.Duration(i) * time.Millisecond),
|
||||||
|
})
|
||||||
|
client = client.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsbl.Load(verdicts)
|
||||||
|
|
||||||
|
got := dnsbl.Snapshot()
|
||||||
|
if len(got) != count-1 || !slices.Contains(got, verdicts[0]) ||
|
||||||
|
slices.Contains(got, verdicts[count-1]) {
|
||||||
|
t.Errorf("%d verdicts kept, want all but the one fetched longest ago", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||||
|
func TestQueriesGoToTheResolverSWWAFDNSBLResolverNames(t *testing.T) {
|
||||||
|
resolver := &resolverStandIn{answers: map[string]answer{
|
||||||
|
listedName: {addrs: []string{listing}},
|
||||||
|
}}
|
||||||
|
|
||||||
|
conn, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
served := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
resolver.serveUDP(conn)
|
||||||
|
close(served)
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
<-served
|
||||||
|
})
|
||||||
|
|
||||||
|
p := dnsblParams(zone)
|
||||||
|
p.Resolver = netip.MustParseAddrPort(conn.LocalAddr().String())
|
||||||
|
// On the real clock: the stand-in answers at once, so only a test
|
||||||
|
// process held up for a whole minute would see the query fail.
|
||||||
|
p.Timeout = time.Minute
|
||||||
|
|
||||||
|
isListed, err := reputation.NewDNSBL(p).LookUp(zone, netip.MustParseAddr(listed))
|
||||||
|
if err != nil || !isListed {
|
||||||
|
t.Errorf("listed %t (%v), want true", isListed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantAsked(t, resolver, listedName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolverStandIn is a stand-in for the resolver the zones are asked
|
||||||
|
// through. It answers each query by the name asked about, as answers
|
||||||
|
// gives, with no such name for a name answers does not give, and not at
|
||||||
|
// all while hanging. It notes each name asked about.
|
||||||
|
type resolverStandIn struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
answers map[string]answer
|
||||||
|
hanging bool
|
||||||
|
names []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// answer is how the stand-in answers a name: with an A record of each of
|
||||||
|
// addrs, or with the response code rcode, unless it is 0, for no error.
|
||||||
|
type answer struct {
|
||||||
|
addrs []string
|
||||||
|
rcode uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the stand-in reads of a query, and writes in its reply.
|
||||||
|
const (
|
||||||
|
// headerLength is the length of a DNS message's header, which the
|
||||||
|
// question follows: its id, its flags, and how many questions,
|
||||||
|
// answers and other records it holds, two bytes each.
|
||||||
|
headerLength = 12
|
||||||
|
// typeAndClass is the length of the type and the class that end a
|
||||||
|
// question, after its name.
|
||||||
|
typeAndClass = 4
|
||||||
|
// replyFlags mark a reply to a query that asked for recursion, which
|
||||||
|
// is available, with no error. The response code goes in their last
|
||||||
|
// four bits.
|
||||||
|
replyFlags = 0x8180
|
||||||
|
// maxMessage is the longest query read over UDP.
|
||||||
|
maxMessage = 1232
|
||||||
|
)
|
||||||
|
|
||||||
|
// set has the stand-in answer name with given.
|
||||||
|
func (s *resolverStandIn) set(name string, given answer) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.answers[name] = given
|
||||||
|
}
|
||||||
|
|
||||||
|
// dial connects Go's resolver to the stand-in through an in-memory
|
||||||
|
// connection, on which it sends each query, and reads each reply, after
|
||||||
|
// its length, as over TCP.
|
||||||
|
func (s *resolverStandIn) dial(context.Context, string, string) (net.Conn, error) {
|
||||||
|
client, server := net.Pipe()
|
||||||
|
|
||||||
|
go s.serve(server)
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve answers the queries that come on conn until the resolver closes
|
||||||
|
// it.
|
||||||
|
func (s *resolverStandIn) serve(conn net.Conn) {
|
||||||
|
defer func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
var length [2]byte
|
||||||
|
|
||||||
|
_, err := io.ReadFull(conn, length[:])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message := make([]byte, binary.BigEndian.Uint16(length[:]))
|
||||||
|
|
||||||
|
_, err = io.ReadFull(conn, message)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, answered := s.reply(message)
|
||||||
|
if !answered {
|
||||||
|
continue // the resolver gives up, and closes conn
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // a reply of a few dozen bytes
|
||||||
|
_, err = conn.Write(append(binary.BigEndian.AppendUint16(nil, uint16(len(reply))),
|
||||||
|
reply...))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveUDP answers the queries that come on conn, each in a datagram, as
|
||||||
|
// a resolver does, until conn is closed.
|
||||||
|
func (s *resolverStandIn) serveUDP(conn net.PacketConn) {
|
||||||
|
message := make([]byte, maxMessage)
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, from, err := conn.ReadFrom(message)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, answered := s.reply(message[:n])
|
||||||
|
if answered {
|
||||||
|
_, _ = conn.WriteTo(reply, from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reply returns the stand-in's reply to message, a query, and false for
|
||||||
|
// none, while it hangs. It notes the name asked about.
|
||||||
|
func (s *resolverStandIn) reply(message []byte) ([]byte, bool) {
|
||||||
|
// The name is labels, each after its length, ended by a length of 0.
|
||||||
|
var labels []string
|
||||||
|
|
||||||
|
end := headerLength
|
||||||
|
for message[end] != 0 {
|
||||||
|
length := int(message[end])
|
||||||
|
labels = append(labels, string(message[end+1:end+1+length]))
|
||||||
|
end += 1 + length
|
||||||
|
}
|
||||||
|
|
||||||
|
end += 1 + typeAndClass
|
||||||
|
name := strings.Join(labels, ".") + "."
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.names = append(s.names, name)
|
||||||
|
given, found := s.answers[name]
|
||||||
|
hanging := s.hanging
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if hanging {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
given = answer{rcode: noSuchName}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The query's id, the flags, one question, the answers, and no other
|
||||||
|
// records, then the question, as asked.
|
||||||
|
reply := slices.Clone(message[:2])
|
||||||
|
reply = binary.BigEndian.AppendUint16(reply, replyFlags|given.rcode)
|
||||||
|
reply = binary.BigEndian.AppendUint16(reply, 1)
|
||||||
|
//nolint:gosec // a handful of answers
|
||||||
|
reply = binary.BigEndian.AppendUint16(reply, uint16(len(given.addrs)))
|
||||||
|
reply = append(reply, 0, 0, 0, 0)
|
||||||
|
reply = append(reply, message[headerLength:end]...)
|
||||||
|
|
||||||
|
// An A record starts with the name asked about, by a pointer to it in
|
||||||
|
// the question, then its type, A, its class, IN, how long it may be
|
||||||
|
// kept, 60 seconds, and the length of its address, 4 bytes.
|
||||||
|
record := []byte{0xc0, headerLength, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4}
|
||||||
|
|
||||||
|
for _, addr := range given.addrs {
|
||||||
|
reply = append(reply, record...)
|
||||||
|
reply = append(reply, netip.MustParseAddr(addr).AsSlice()...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// dnsblParams returns the DNSBLParams of zones, with the tests' cache TTL
|
||||||
|
// and timeout, by the bubble's clock, with alerts to a queue that sends
|
||||||
|
// none.
|
||||||
|
func dnsblParams(zones ...string) reputation.DNSBLParams {
|
||||||
|
return reputation.DNSBLParams{
|
||||||
|
Zones: zones,
|
||||||
|
CacheTTL: cacheTTL,
|
||||||
|
Timeout: timeout,
|
||||||
|
Now: time.Now,
|
||||||
|
ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
|
Alerts: newQueue(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForTheResolver waits, on the bubble's clock, an hour, until Go's
|
||||||
|
// resolver has given up on every stand-in that does not answer: it waits
|
||||||
|
// for a server as long as /etc/resolv.conf has it wait, a few seconds,
|
||||||
|
// even after the query was given up, and a bubble cannot end before it.
|
||||||
|
func waitForTheResolver() {
|
||||||
|
time.Sleep(time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDNSBL returns the DNSBL of p, asking resolver.
|
||||||
|
func newDNSBL(resolver *resolverStandIn, p reputation.DNSBLParams) *reputation.DNSBL {
|
||||||
|
dnsbl := reputation.NewDNSBL(p)
|
||||||
|
dnsbl.SetDial(resolver.dial)
|
||||||
|
|
||||||
|
return dnsbl
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantZones checks the zones whose verdict dnsbl says lists client, as a
|
||||||
|
// request from client finds them.
|
||||||
|
func wantZones(t *testing.T, dnsbl *reputation.DNSBL, client string, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got := dnsbl.ListedBy(t.Context(), netip.MustParseAddr(client))
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("%s is listed by %v, want %v", client, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantQueries checks how many queries dnsbl made to zone, and how many of
|
||||||
|
// them failed.
|
||||||
|
func wantQueries(t *testing.T, dnsbl *reputation.DNSBL, queries, failures int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if dnsbl.Queries(zone) != queries || dnsbl.Failures(zone) != failures {
|
||||||
|
t.Errorf("%d queries and %d failures, want %d and %d", dnsbl.Queries(zone),
|
||||||
|
dnsbl.Failures(zone), queries, failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantAsked checks the names the stand-in was asked about, in any order.
|
||||||
|
func wantAsked(t *testing.T, resolver *resolverStandIn, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
resolver.mu.Lock()
|
||||||
|
got := slices.Sorted(slices.Values(resolver.names))
|
||||||
|
resolver.mu.Unlock()
|
||||||
|
|
||||||
|
slices.Sort(want)
|
||||||
|
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("asked about %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package reputation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetTransport has l's fetches go through transport instead of the
|
||||||
|
// network.
|
||||||
|
func (l *Lists) SetTransport(transport http.RoundTripper) {
|
||||||
|
l.httpClient.Transport = transport
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTransport has a's checks go through transport instead of the
|
||||||
|
// network.
|
||||||
|
func (a *AbuseIPDB) SetTransport(transport http.RoundTripper) {
|
||||||
|
a.httpClient.Transport = transport
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDial has d's queries go through dial instead of the network.
|
||||||
|
func (d *DNSBL) SetDial(
|
||||||
|
dial func(ctx context.Context, network, address string) (net.Conn, error),
|
||||||
|
) {
|
||||||
|
d.resolver = &net.Resolver{PreferGo: true, Dial: dial}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookUp asks zone about addr at once, as a query in the background does,
|
||||||
|
// and returns whether zone lists addr.
|
||||||
|
func (d *DNSBL) LookUp(zone string, addr netip.Addr) (bool, error) {
|
||||||
|
return d.lookUp(context.Background(), query{zone: zone, client: addr})
|
||||||
|
}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
// Package reputation fetches the lists the settings name by URL: the
|
||||||
|
// blocklists of SWWAF_BLOCKLIST_URLS, and the file of AS:percent lines
|
||||||
|
// SWWAF_ASN_LIMIT_PERCENT_URL names. It keeps the last good copy of each,
|
||||||
|
// whole, comment lines included, which is used while a fetch fails, and
|
||||||
|
// when each was last tried. It also asks the DNSBL zones of
|
||||||
|
// SWWAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
||||||
|
// clients with AbuseIPDB, and keeps their scores and the checks spent
|
||||||
|
// today. The state package writes all of these to reputation.json and
|
||||||
|
// reads them from it, so that a restart keeps them too.
|
||||||
|
package reputation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxListBytes is the most of a list that is read. A longer one is a
|
||||||
|
// failure, so that a wrong URL cannot fill the memory.
|
||||||
|
maxListBytes = 16 << 20
|
||||||
|
// fetchTimeout bounds one fetch of a list.
|
||||||
|
fetchTimeout = time.Minute
|
||||||
|
// mappedBits is the length of ::ffff:0.0.0.0/96, the netblock of every
|
||||||
|
// IPv4-mapped address.
|
||||||
|
mappedBits = 96
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errStatus = errors.New("the server answered")
|
||||||
|
errTooLong = errors.New("the list is longer than 16 MiB")
|
||||||
|
errNotNetblock = errors.New("is not an address or a netblock, such as 192.0.2.0/24")
|
||||||
|
errNotASNPercent = errors.New(
|
||||||
|
"is not an AS number, : and a percentage, such as AS64496:50")
|
||||||
|
)
|
||||||
|
|
||||||
|
// List is a list as reputation.json holds it: the URL it is fetched from,
|
||||||
|
// when it was last tried, the fetch failed or not, and its last good copy:
|
||||||
|
// when that was fetched, and its lines, as fetched, comment lines
|
||||||
|
// included, both left out while no fetch of it has succeeded.
|
||||||
|
type List struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Tried time.Time `json:"tried"`
|
||||||
|
Fetched time.Time `json:"fetched,omitzero"`
|
||||||
|
Lines []string `json:"lines,omitzero"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params are what New needs.
|
||||||
|
type Params struct {
|
||||||
|
// BlocklistURLs are the blocklists (SWWAF_BLOCKLIST_URLS), and
|
||||||
|
// ASNLimitPercentURL the file of AS:percent lines
|
||||||
|
// (SWWAF_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
||||||
|
BlocklistURLs []string
|
||||||
|
ASNLimitPercentURL string
|
||||||
|
// Refresh is how long after a list was last fetched or tried it is
|
||||||
|
// fetched again (SWWAF_BLOCKLIST_REFRESH).
|
||||||
|
Refresh time.Duration
|
||||||
|
// Now tells the time, normally time.Now in UTC.
|
||||||
|
Now func() time.Time
|
||||||
|
// ProcessLog receives each fetch of a list, and why one failed.
|
||||||
|
ProcessLog *slog.Logger
|
||||||
|
// Alerts receive a source_failure alert for each fetch that fails.
|
||||||
|
Alerts *alerts.Queue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lists are the lists Params names, each with its last good copy. They
|
||||||
|
// are safe for concurrent use.
|
||||||
|
type Lists struct {
|
||||||
|
params Params
|
||||||
|
httpClient *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
// lists are by URL, one for each URL Params names.
|
||||||
|
lists map[string]*list
|
||||||
|
}
|
||||||
|
|
||||||
|
// list is one list: what reputation.json keeps of it, its last try, zero
|
||||||
|
// before the first, and its last good copy, what that copy says, and how
|
||||||
|
// many fetches of it failed.
|
||||||
|
type list struct {
|
||||||
|
kept List
|
||||||
|
entries entries
|
||||||
|
failures int
|
||||||
|
}
|
||||||
|
|
||||||
|
// entries are what the lines of a copy say: for a blocklist, the netblocks
|
||||||
|
// it names, with the lengths among them, and for the file of AS:percent
|
||||||
|
// lines, the percentage it gives each AS number.
|
||||||
|
type entries struct {
|
||||||
|
netblocks map[netip.Prefix]bool
|
||||||
|
lengths []int
|
||||||
|
percents map[string]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns the lists, without a copy of any yet.
|
||||||
|
func New(params Params) *Lists {
|
||||||
|
l := &Lists{params: params, httpClient: &http.Client{}, lists: map[string]*list{}}
|
||||||
|
|
||||||
|
for _, listURL := range l.URLs() {
|
||||||
|
l.lists[listURL] = &list{kept: List{URL: listURL}}
|
||||||
|
}
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// URLs returns the URL of every list: the blocklists' in the order
|
||||||
|
// SWWAF_BLOCKLIST_URLS names them, then SWWAF_ASN_LIMIT_PERCENT_URL.
|
||||||
|
func (l *Lists) URLs() []string {
|
||||||
|
urls := slices.Clone(l.params.BlocklistURLs)
|
||||||
|
if l.params.ASNLimitPercentURL != "" {
|
||||||
|
urls = append(urls, l.params.ASNLimitPercentURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListedBy returns the URLs of the blocklists whose copy lists addr, in
|
||||||
|
// the order SWWAF_BLOCKLIST_URLS names them.
|
||||||
|
func (l *Lists) ListedBy(addr netip.Addr) []string {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
var listedBy []string
|
||||||
|
|
||||||
|
for _, listURL := range l.params.BlocklistURLs {
|
||||||
|
if l.lists[listURL].entries.contain(addr) {
|
||||||
|
listedBy = append(listedBy, listURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listedBy
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASNLimitPercent returns the percentage the copy of the file of
|
||||||
|
// AS:percent lines gives asn, and whether it lists asn.
|
||||||
|
func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
|
||||||
|
if l.params.ASNLimitPercentURL == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
percent, listed := l.lists[l.params.ASNLimitPercentURL].entries.percents[asn]
|
||||||
|
|
||||||
|
return percent, listed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetched returns when the copy in use of the list at listURL was
|
||||||
|
// fetched, or zero while there is none.
|
||||||
|
func (l *Lists) Fetched(listURL string) time.Time {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
return l.lists[listURL].kept.Fetched
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failures returns how many fetches of the list at listURL failed.
|
||||||
|
func (l *Lists) Failures(listURL string) int {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
return l.lists[listURL].failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run fetches each list once Refresh has passed since it was last fetched
|
||||||
|
// or tried, the later of the two, until ctx is done. A list never tried is
|
||||||
|
// fetched at once, and so is one whose last try or copy, read from
|
||||||
|
// reputation.json, is that old.
|
||||||
|
func (l *Lists) Run(ctx context.Context) {
|
||||||
|
if len(l.lists) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
next := l.fetchDue(ctx)
|
||||||
|
timer := time.NewTimer(next.Sub(l.params.Now()))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
|
||||||
|
timer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns each list that has been tried, with its copy, if it
|
||||||
|
// has one, sorted by URL, as reputation.json lists them.
|
||||||
|
func (l *Lists) Snapshot() []List {
|
||||||
|
l.mu.Lock()
|
||||||
|
|
||||||
|
tried := make([]List, 0, len(l.lists))
|
||||||
|
|
||||||
|
for _, held := range l.lists {
|
||||||
|
if !held.kept.Tried.IsZero() {
|
||||||
|
tried = append(tried, held.kept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
l.mu.Unlock()
|
||||||
|
|
||||||
|
slices.SortFunc(tried, func(a, b List) int {
|
||||||
|
return strings.Compare(a.URL, b.URL)
|
||||||
|
})
|
||||||
|
|
||||||
|
return tried
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load puts lists, read from reputation.json, in place of the last tries
|
||||||
|
// and copies held. A list Params does not name is dropped. A copy with a
|
||||||
|
// line that parse refuses is an error, and then nothing changes.
|
||||||
|
func (l *Lists) Load(lists []List) error {
|
||||||
|
found := make(map[string]entries, len(lists))
|
||||||
|
|
||||||
|
for _, kept := range lists {
|
||||||
|
if _, named := l.lists[kept.URL]; !named {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
read, err := l.parse(kept.URL, kept.Lines)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("the copy of %s: %w", kept.URL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found[kept.URL] = read
|
||||||
|
}
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
for listURL, held := range l.lists {
|
||||||
|
held.kept, held.entries = List{URL: listURL}, entries{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, kept := range lists {
|
||||||
|
read, named := found[kept.URL]
|
||||||
|
if named {
|
||||||
|
l.lists[kept.URL].kept, l.lists[kept.URL].entries = kept, read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchDue fetches each list that is due, one after another, and returns
|
||||||
|
// when the next is due. Once ctx has ended, it starts none, since a fetch
|
||||||
|
// cut off is noted as a try.
|
||||||
|
func (l *Lists) fetchDue(ctx context.Context) time.Time {
|
||||||
|
var next time.Time
|
||||||
|
|
||||||
|
for _, listURL := range l.URLs() {
|
||||||
|
due := l.due(listURL)
|
||||||
|
if ctx.Err() == nil && !l.params.Now().Before(due) {
|
||||||
|
l.fetch(ctx, listURL)
|
||||||
|
due = l.due(listURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if next.IsZero() || due.Before(next) {
|
||||||
|
next = due
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
// due returns when the list at listURL is to be fetched: Refresh after it
|
||||||
|
// was last fetched or tried, the later of the two.
|
||||||
|
func (l *Lists) due(listURL string) time.Time {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
held := l.lists[listURL]
|
||||||
|
|
||||||
|
last := held.kept.Fetched
|
||||||
|
if held.kept.Tried.After(last) {
|
||||||
|
last = held.kept.Tried
|
||||||
|
}
|
||||||
|
|
||||||
|
return last.Add(l.params.Refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch fetches the list at listURL, and notes the try. A good copy takes
|
||||||
|
// the place of the one held. A failure leaves that in use, and is counted,
|
||||||
|
// logged and raised as a source_failure alert. A fetch cut off as ctx
|
||||||
|
// ends, as smallwebwaf stops, is no failure, but is still noted as a try,
|
||||||
|
// so that a restart waits for it: the server may have had its request.
|
||||||
|
func (l *Lists) fetch(ctx context.Context, listURL string) {
|
||||||
|
lines, err := l.get(ctx, listURL)
|
||||||
|
|
||||||
|
var found entries
|
||||||
|
if err == nil {
|
||||||
|
found, err = l.parse(listURL, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
cutOff := err != nil && ctx.Err() != nil
|
||||||
|
now := l.params.Now()
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
|
||||||
|
held := l.lists[listURL]
|
||||||
|
held.kept.Tried = now
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
held.kept.Fetched, held.kept.Lines = now, lines
|
||||||
|
held.entries = found
|
||||||
|
} else if !cutOff {
|
||||||
|
held.failures++
|
||||||
|
}
|
||||||
|
|
||||||
|
l.mu.Unlock()
|
||||||
|
|
||||||
|
if cutOff {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
const failed = "fetching a list failed"
|
||||||
|
|
||||||
|
// Raised before it is logged, so that the alert is there once the
|
||||||
|
// log line is.
|
||||||
|
raiseFailure(l.params.Alerts, failed, listURL, err)
|
||||||
|
l.params.ProcessLog.Warn(failed, "url", listURL, "error", err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.params.ProcessLog.Info("fetched a list", "url", listURL, "lines", len(lines))
|
||||||
|
}
|
||||||
|
|
||||||
|
// raiseFailure raises a source_failure alert into queue, with reason, and
|
||||||
|
// in its detail the source that failed, a list's URL, a zone with its key
|
||||||
|
// masked or abuseipdb, and err.
|
||||||
|
func raiseFailure(queue *alerts.Queue, reason, source string, err error) {
|
||||||
|
queue.Raise(alerts.Alert{
|
||||||
|
Event: alerts.EventSourceFailure,
|
||||||
|
Reason: reason,
|
||||||
|
Detail: map[string]any{"source": source, "error": err.Error()},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// get fetches the list at listURL, and returns its lines. An answer other
|
||||||
|
// than 200, or a list longer than maxListBytes, is a failure.
|
||||||
|
func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, http.NoBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("make the request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := l.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Do's error names the URL, which the log line and the alert name
|
||||||
|
// already: only what went wrong is kept.
|
||||||
|
return nil, fmt.Errorf("fetch the list: %w", errors.Unwrap(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = res.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("%w %s", errStatus, res.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(res.Body, maxListBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read the list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) > maxListBytes {
|
||||||
|
return nil, errTooLong
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{}
|
||||||
|
for line := range strings.Lines(string(body)) {
|
||||||
|
lines = append(lines, strings.TrimSuffix(line, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse reads the lines of the list at listURL: those of a blocklist, or
|
||||||
|
// of the file of AS:percent lines. Anything after a ; or a # on a line is
|
||||||
|
// left out, and so is a line left blank. Any other line that does not read
|
||||||
|
// is an error naming it by its number.
|
||||||
|
func (l *Lists) parse(listURL string, lines []string) (entries, error) {
|
||||||
|
if listURL == l.params.ASNLimitPercentURL {
|
||||||
|
return parsePercents(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseNetblocks(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNetblocks reads a blocklist's lines, each an address or a netblock
|
||||||
|
// as the settings take them.
|
||||||
|
func parseNetblocks(lines []string) (entries, error) {
|
||||||
|
found := entries{netblocks: map[netip.Prefix]bool{}}
|
||||||
|
|
||||||
|
for i, line := range lines {
|
||||||
|
text := withoutComment(line)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
netblock, ok := parseNetblock(text)
|
||||||
|
if !ok {
|
||||||
|
return entries{}, fmt.Errorf("line %d %w", i+1, errNotNetblock)
|
||||||
|
}
|
||||||
|
|
||||||
|
found.netblocks[netblock] = true
|
||||||
|
|
||||||
|
if !slices.Contains(found.lengths, netblock.Bits()) {
|
||||||
|
found.lengths = append(found.lengths, netblock.Bits())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNetblock reads text, a line of a blocklist, and reports whether it
|
||||||
|
// is an address or a netblock as the settings take them. A client's IPv4
|
||||||
|
// address is checked as IPv4, never IPv4-mapped, so an IPv4-mapped line,
|
||||||
|
// such as ::ffff:192.0.2.0/120, is read as the IPv4 address or netblock it
|
||||||
|
// stands for, 192.0.2.0/24, and a mapped netblock shorter than /96, which
|
||||||
|
// stands for none, is refused.
|
||||||
|
func parseNetblock(text string) (netip.Prefix, bool) {
|
||||||
|
netblock, err := config.ParseNetblock(text)
|
||||||
|
if err != nil {
|
||||||
|
return netip.Prefix{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The address as written: ParseNetblock's has the bits past the
|
||||||
|
// netblock's length cleared, the ::ffff among them below /96.
|
||||||
|
written, _, _ := strings.Cut(text, "/")
|
||||||
|
if addr, _ := netip.ParseAddr(written); !addr.Is4In6() {
|
||||||
|
return netblock, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if netblock.Bits() < mappedBits {
|
||||||
|
return netip.Prefix{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return netip.PrefixFrom(netblock.Addr().Unmap(), netblock.Bits()-mappedBits), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 listed more than once gets the lowest of its percentages.
|
||||||
|
func parsePercents(lines []string) (entries, error) {
|
||||||
|
found := entries{percents: map[string]int64{}}
|
||||||
|
|
||||||
|
for i, line := range lines {
|
||||||
|
text := withoutComment(line)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
asnText, percentText, _ := strings.Cut(text, ":")
|
||||||
|
asn, asnErr := config.ParseASN(asnText)
|
||||||
|
|
||||||
|
percent, percentErr := config.ParsePercent(percentText)
|
||||||
|
if asnErr != nil || percentErr != nil {
|
||||||
|
return entries{}, fmt.Errorf("line %d %w", i+1, errNotASNPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
earlier, listed := found.percents[asn]
|
||||||
|
if !listed || percent < earlier {
|
||||||
|
found.percents[asn] = percent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutComment returns line without anything after a ; or a #, and
|
||||||
|
// without the spaces around what is left.
|
||||||
|
func withoutComment(line string) string {
|
||||||
|
text, _, _ := strings.Cut(line, ";")
|
||||||
|
text, _, _ = strings.Cut(text, "#")
|
||||||
|
|
||||||
|
return strings.TrimSpace(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// contain reports whether the netblocks of a blocklist's copy hold addr:
|
||||||
|
// whether addr, cut to one of their lengths, is one of them.
|
||||||
|
func (e entries) contain(addr netip.Addr) bool {
|
||||||
|
for _, length := range e.lengths {
|
||||||
|
netblock, err := addr.Prefix(length)
|
||||||
|
if err == nil && e.netblocks[netblock] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
package reputation_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The tests run in a synctest bubble, where the time package runs on a
|
||||||
|
// clock of the test's own: time.Sleep moves it on at once, and
|
||||||
|
// synctest.Wait returns once Run waits for the next list to be due, so
|
||||||
|
// that every fetch due by then has been made. The stand-in for the
|
||||||
|
// servers the lists are fetched from answers without the network, since a
|
||||||
|
// fetch waiting on the network would keep that clock from moving on.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// dropURL and torURL are the blocklists, and asnURL the file of
|
||||||
|
// AS:percent lines.
|
||||||
|
dropURL = "https://lists.example/drop.txt"
|
||||||
|
torURL = "https://lists.example/tor.txt"
|
||||||
|
asnURL = "https://lists.example/asn.txt"
|
||||||
|
// refresh is the tests' SWWAF_BLOCKLIST_REFRESH, and cooldown their
|
||||||
|
// SWWAF_ALERT_COOLDOWN, longer than it.
|
||||||
|
refresh = 24 * time.Hour
|
||||||
|
cooldown = 48 * time.Hour
|
||||||
|
// drop is a blocklist as the Spamhaus DROP list is written, with an
|
||||||
|
// address and a netblock in each of its comments, which list nothing.
|
||||||
|
drop = "; Spamhaus DROP List 2026/10/07 - (c) 2026 The Spamhaus Project SLL\n" +
|
||||||
|
"; Last-Modified: Wed, 07 Oct 2026 00:00:00 GMT ; 192.0.2.1\n" +
|
||||||
|
"# 198.51.100.0/24\n" +
|
||||||
|
"\n" +
|
||||||
|
"203.0.113.0/24 ; SBL1\n" +
|
||||||
|
" 192.0.2.9 # one address\n" +
|
||||||
|
"2001:db8:1::/48 ; SBL2\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListedAddressesAndNetblocksWithTheCommentsLeftOut(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
servers := &standIn{lists: map[string]string{dropURL: drop}}
|
||||||
|
lists := start(t, servers, params(dropURL))
|
||||||
|
|
||||||
|
for addr, want := range map[string][]string{
|
||||||
|
"203.0.113.0": {dropURL},
|
||||||
|
"203.0.113.255": {dropURL},
|
||||||
|
"192.0.2.9": {dropURL},
|
||||||
|
"2001:db8:1::7": {dropURL},
|
||||||
|
"203.0.114.0": nil,
|
||||||
|
"192.0.2.8": nil,
|
||||||
|
"192.0.2.1": nil,
|
||||||
|
"198.51.100.7": nil,
|
||||||
|
"2001:db8:2::7": nil,
|
||||||
|
} {
|
||||||
|
wantListedBy(t, lists, addr, want...)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIPv4MappedLineListsTheIPv4AddressOrNetblockItStandsFor(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
lists := reputation.New(params(dropURL))
|
||||||
|
|
||||||
|
err := lists.Load([]reputation.List{{
|
||||||
|
URL: dropURL, Tried: now, Fetched: now,
|
||||||
|
Lines: []string{"::ffff:192.0.2.9", "::ffff:203.0.113.0/120"},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for addr, want := range map[string][]string{
|
||||||
|
"192.0.2.9": {dropURL},
|
||||||
|
"203.0.113.255": {dropURL},
|
||||||
|
"192.0.2.8": nil,
|
||||||
|
"203.0.114.0": nil,
|
||||||
|
} {
|
||||||
|
wantListedBy(t, lists, addr, want...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mapped netblock shorter than /96 stands for no IPv4 one.
|
||||||
|
err = lists.Load([]reputation.List{{
|
||||||
|
URL: dropURL, Tried: now, Fetched: now, Lines: []string{"::ffff:198.51.100.0/88"},
|
||||||
|
}})
|
||||||
|
|
||||||
|
const want = "the copy of " + dropURL +
|
||||||
|
": line 1 is not an address or a netblock, such as 192.0.2.0/24"
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("error %v, want %s", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIsListedByEachBlocklistThatListsIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
servers := &standIn{lists: map[string]string{
|
||||||
|
dropURL: "203.0.113.0/24\n", torURL: "203.0.113.9\n",
|
||||||
|
}}
|
||||||
|
lists := start(t, servers, params(torURL, dropURL))
|
||||||
|
|
||||||
|
// In the order SWWAF_BLOCKLIST_URLS names them.
|
||||||
|
wantListedBy(t, lists, "203.0.113.9", torURL, dropURL)
|
||||||
|
wantListedBy(t, lists, "203.0.113.8", dropURL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListFetchedAgainOnceRefreshHasPassed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
servers := &standIn{lists: map[string]string{dropURL: "203.0.113.9\n"}}
|
||||||
|
lists := start(t, servers, params(dropURL))
|
||||||
|
began := time.Now()
|
||||||
|
|
||||||
|
wantFetches(t, servers, 1)
|
||||||
|
|
||||||
|
servers.set(dropURL, "203.0.113.10\n")
|
||||||
|
time.Sleep(refresh - time.Nanosecond)
|
||||||
|
wantFetches(t, servers, 1)
|
||||||
|
wantListedBy(t, lists, "203.0.113.9", dropURL)
|
||||||
|
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
wantFetches(t, servers, 2)
|
||||||
|
wantListedBy(t, lists, "203.0.113.9")
|
||||||
|
wantListedBy(t, lists, "203.0.113.10", dropURL)
|
||||||
|
|
||||||
|
if fetched := lists.Fetched(dropURL); !fetched.Equal(began.Add(refresh)) {
|
||||||
|
t.Errorf("the copy in use was fetched at %s, want %s", fetched,
|
||||||
|
began.Add(refresh))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailedFetchKeepsTheLastGoodCopyAndAlertsOncePerCooldown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
// fail has the stand-in answer the fetches after the first so that
|
||||||
|
// they fail with error.
|
||||||
|
fail func(servers *standIn)
|
||||||
|
error string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"an answer other than 200",
|
||||||
|
func(servers *standIn) { servers.set(dropURL, "") },
|
||||||
|
"the server answered 503 Service Unavailable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a line that does not read",
|
||||||
|
func(servers *standIn) { servers.set(dropURL, "203.0.113.10\n<html>\n") },
|
||||||
|
"line 2 is not an address or a netblock, such as 192.0.2.0/24",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a list longer than 16 MiB",
|
||||||
|
func(servers *standIn) {
|
||||||
|
servers.set(dropURL, strings.Repeat("#\n", 8<<20+1))
|
||||||
|
},
|
||||||
|
"the list is longer than 16 MiB",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
var log bytes.Buffer
|
||||||
|
|
||||||
|
servers := &standIn{lists: map[string]string{dropURL: drop}}
|
||||||
|
queue := newQueue()
|
||||||
|
p := params(dropURL)
|
||||||
|
p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil))
|
||||||
|
p.Alerts = queue
|
||||||
|
lists := start(t, servers, p)
|
||||||
|
kept := lists.Snapshot()
|
||||||
|
|
||||||
|
tc.fail(servers)
|
||||||
|
|
||||||
|
// Each failure is tried again once refresh has passed since it.
|
||||||
|
for range 2 {
|
||||||
|
time.Sleep(refresh)
|
||||||
|
synctest.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
wantFetches(t, servers, 3)
|
||||||
|
wantListedBy(t, lists, "203.0.113.9", dropURL)
|
||||||
|
|
||||||
|
want := kept[0]
|
||||||
|
want.Tried = time.Now()
|
||||||
|
|
||||||
|
if got := lists.Snapshot(); !reflect.DeepEqual(got, []reputation.List{want}) {
|
||||||
|
t.Errorf("lists %+v, want the first copy, last tried now, %+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lists.Failures(dropURL) != 2 {
|
||||||
|
t.Errorf("%d failures, want 2", lists.Failures(dropURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
// One alert for the first failure; the cooldown holds back the
|
||||||
|
// second.
|
||||||
|
wantAlert(t, queue, alerts.Alert{
|
||||||
|
Time: time.Now().Add(-refresh),
|
||||||
|
Event: alerts.EventSourceFailure,
|
||||||
|
Reason: "fetching a list failed",
|
||||||
|
Detail: map[string]any{"source": dropURL, "error": tc.error},
|
||||||
|
})
|
||||||
|
|
||||||
|
if !strings.Contains(log.String(), `"msg":"fetching a list failed",`+
|
||||||
|
`"url":"`+dropURL+`","error":"`+tc.error) {
|
||||||
|
t.Errorf("logged\n%s\nwant the failures", log.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchNotDoneWithinAMinuteFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
servers := &standIn{lists: map[string]string{}, hanging: true}
|
||||||
|
lists := start(t, servers, params(dropURL))
|
||||||
|
|
||||||
|
time.Sleep(time.Minute - time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
if lists.Failures(dropURL) != 0 {
|
||||||
|
t.Errorf("%d failures before a minute, want none", lists.Failures(dropURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
if lists.Failures(dropURL) != 1 {
|
||||||
|
t.Errorf("%d failures after a minute, want 1", lists.Failures(dropURL))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeptCopyIsFetchedAgainOnceRefreshHasPassedSinceItWasFetched(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
servers := &standIn{lists: map[string]string{
|
||||||
|
dropURL: "203.0.113.10\n", torURL: "198.51.100.10\n",
|
||||||
|
}}
|
||||||
|
lists := reputation.New(params(dropURL, torURL))
|
||||||
|
lists.SetTransport(servers)
|
||||||
|
|
||||||
|
// drop.txt was fetched an hour ago, and tor.txt a refresh ago, as
|
||||||
|
// reputation.json says at start.
|
||||||
|
err := lists.Load([]reputation.List{
|
||||||
|
{URL: dropURL, Fetched: time.Now().Add(-time.Hour), Lines: []string{"203.0.113.9"}},
|
||||||
|
{URL: torURL, Fetched: time.Now().Add(-refresh), Lines: []string{"198.51.100.9"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run(t, lists)
|
||||||
|
|
||||||
|
wantFetches(t, servers, 1)
|
||||||
|
wantListedBy(t, lists, "203.0.113.9", dropURL)
|
||||||
|
wantListedBy(t, lists, "198.51.100.10", torURL)
|
||||||
|
|
||||||
|
time.Sleep(refresh - time.Hour - time.Nanosecond)
|
||||||
|
wantFetches(t, servers, 1)
|
||||||
|
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
wantFetches(t, servers, 2)
|
||||||
|
wantListedBy(t, lists, "203.0.113.10", dropURL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestartWaitsRefreshAfterTheLastTryEvenOneThatFailed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
// drop.txt is fetched, and a refresh later the fetch downloads it
|
||||||
|
// whole but fails on a line that does not read.
|
||||||
|
servers := &standIn{lists: map[string]string{dropURL: "198.51.100.1\n"}}
|
||||||
|
lists := start(t, servers, params(dropURL))
|
||||||
|
|
||||||
|
servers.set(dropURL, "198.51.100.2\n<html>\n")
|
||||||
|
time.Sleep(refresh)
|
||||||
|
wantFetches(t, servers, 2)
|
||||||
|
|
||||||
|
// Restarted with what reputation.json keeps, it waits a refresh
|
||||||
|
// after the failed try, as it does while it runs.
|
||||||
|
restarted := &standIn{lists: map[string]string{dropURL: "198.51.100.2\n"}}
|
||||||
|
again := reputation.New(params(dropURL))
|
||||||
|
again.SetTransport(restarted)
|
||||||
|
|
||||||
|
err := again.Load(lists.Snapshot())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run(t, again)
|
||||||
|
wantFetches(t, restarted, 0)
|
||||||
|
wantListedBy(t, again, "198.51.100.1", dropURL)
|
||||||
|
|
||||||
|
time.Sleep(refresh - time.Nanosecond)
|
||||||
|
wantFetches(t, restarted, 0)
|
||||||
|
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
wantFetches(t, restarted, 1)
|
||||||
|
wantListedBy(t, again, "198.51.100.2", dropURL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchCutOffAsItStopsIsNoFailureButARestartWaitsForIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
// Stopped 30 seconds into the fetch of drop.txt, before tor.txt's.
|
||||||
|
servers := &standIn{lists: map[string]string{}, hanging: true}
|
||||||
|
queue := newQueue()
|
||||||
|
p := params(dropURL, torURL)
|
||||||
|
p.Alerts = queue
|
||||||
|
|
||||||
|
lists := reputation.New(p)
|
||||||
|
lists.SetTransport(servers)
|
||||||
|
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
lists.Run(ctx)
|
||||||
|
close(stopped)
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
stop()
|
||||||
|
<-stopped
|
||||||
|
|
||||||
|
wantFetches(t, servers, 1)
|
||||||
|
|
||||||
|
if lists.Failures(dropURL) != 0 || len(waiting(queue)) != 0 {
|
||||||
|
t.Errorf("%d failures and alerts %+v, want none", lists.Failures(dropURL),
|
||||||
|
waiting(queue))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restarted an hour later with what reputation.json keeps, it fetches
|
||||||
|
// tor.txt, never tried, at once, and drop.txt a refresh after its
|
||||||
|
// cut-off try.
|
||||||
|
time.Sleep(time.Hour)
|
||||||
|
|
||||||
|
restarted := &standIn{lists: map[string]string{
|
||||||
|
dropURL: "203.0.113.7\n", torURL: "198.51.100.7\n",
|
||||||
|
}}
|
||||||
|
again := reputation.New(params(dropURL, torURL))
|
||||||
|
again.SetTransport(restarted)
|
||||||
|
|
||||||
|
err := again.Load(lists.Snapshot())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run(t, again)
|
||||||
|
wantFetches(t, restarted, 1)
|
||||||
|
wantListedBy(t, again, "198.51.100.7", torURL)
|
||||||
|
|
||||||
|
time.Sleep(refresh - time.Hour - time.Nanosecond)
|
||||||
|
wantFetches(t, restarted, 1)
|
||||||
|
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
wantFetches(t, restarted, 2)
|
||||||
|
wantListedBy(t, again, "203.0.113.7", dropURL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASNLimitPercentFileGivesEachASNumberItsLowestPercentage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
servers := &standIn{lists: map[string]string{
|
||||||
|
asnURL: "# hosting networks\nAS14061:50 ; DigitalOcean\nas16276:25\n\n" +
|
||||||
|
"AS14061:10\nAS14061:30\n",
|
||||||
|
}}
|
||||||
|
p := params()
|
||||||
|
p.ASNLimitPercentURL = asnURL
|
||||||
|
lists := start(t, servers, p)
|
||||||
|
|
||||||
|
for asn, want := range map[string]int64{"AS14061": 10, "AS16276": 25} {
|
||||||
|
percent, listed := lists.ASNLimitPercent(asn)
|
||||||
|
if !listed || percent != want {
|
||||||
|
t.Errorf("%s has %d (listed %t), want %d", asn, percent, listed, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, listed := lists.ASNLimitPercent("AS64496"); listed {
|
||||||
|
t.Error("AS64496 is listed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A line that does not read fails the fetch.
|
||||||
|
servers.set(asnURL, "AS14061:50\nAS16276\n")
|
||||||
|
time.Sleep(refresh)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
if lists.Failures(asnURL) != 1 {
|
||||||
|
t.Errorf("%d failures, want 1", lists.Failures(asnURL))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDropsCopiesOfListsNotNamedAndRefusesOnesThatDoNotRead(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fetched := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
|
||||||
|
kept := reputation.List{
|
||||||
|
URL: dropURL, Tried: fetched, Fetched: fetched, Lines: []string{"203.0.113.9"},
|
||||||
|
}
|
||||||
|
lists := reputation.New(params(dropURL))
|
||||||
|
|
||||||
|
err := lists.Load([]reputation.List{kept, {
|
||||||
|
URL: torURL, Tried: fetched, Fetched: fetched, Lines: []string{"198.51.100.9"},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := lists.Snapshot(); !reflect.DeepEqual(got, []reputation.List{kept}) {
|
||||||
|
t.Errorf("copies %+v, want only %+v", got, kept)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = lists.Load([]reputation.List{{URL: dropURL, Fetched: fetched, Lines: []string{
|
||||||
|
"; DROP", "203.0.113.300",
|
||||||
|
}}})
|
||||||
|
|
||||||
|
const want = "the copy of " + dropURL +
|
||||||
|
": line 2 is not an address or a netblock, such as 192.0.2.0/24"
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("error %v, want %s", err, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := lists.Snapshot(); !reflect.DeepEqual(got, []reputation.List{kept}) {
|
||||||
|
t.Errorf("copies %+v after the error, want %+v still", got, kept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// standIn is a stand-in for the servers the lists are fetched from. It
|
||||||
|
// notes the URL of each fetch.
|
||||||
|
type standIn struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
// lists are what it answers with, by URL; it answers a URL it has no
|
||||||
|
// list for with 503, and none at all while hanging.
|
||||||
|
lists map[string]string
|
||||||
|
hanging bool
|
||||||
|
fetches []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoundTrip has the stand-in answer req, in place of the network. A fetch
|
||||||
|
// abandoned before the stand-in answers fails, as over the network.
|
||||||
|
func (s *standIn) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.fetches = append(s.fetches, req.URL.String())
|
||||||
|
list, found := s.lists[req.URL.String()]
|
||||||
|
hanging := s.hanging
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if hanging {
|
||||||
|
<-req.Context().Done()
|
||||||
|
|
||||||
|
return nil, req.Context().Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
status := http.StatusOK
|
||||||
|
if !found {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
|
||||||
|
Header: http.Header{},
|
||||||
|
Body: io.NopCloser(strings.NewReader(list)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// set has the stand-in answer listURL with list, or with 503 for "".
|
||||||
|
func (s *standIn) set(listURL, list string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if list == "" {
|
||||||
|
delete(s.lists, listURL)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.lists[listURL] = list
|
||||||
|
}
|
||||||
|
|
||||||
|
// params returns the Params of the blocklists at urls, refreshed every
|
||||||
|
// refresh, by the bubble's clock, with alerts to a queue that sends none.
|
||||||
|
func params(urls ...string) reputation.Params {
|
||||||
|
return reputation.Params{
|
||||||
|
BlocklistURLs: urls,
|
||||||
|
Refresh: refresh,
|
||||||
|
Now: time.Now,
|
||||||
|
ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
|
Alerts: newQueue(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newQueue returns a queue of alerts to a webhook that is never sent
|
||||||
|
// them, with a cooldown of cooldown.
|
||||||
|
func newQueue() *alerts.Queue {
|
||||||
|
return alerts.New(alerts.Params{
|
||||||
|
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
||||||
|
Events: alerts.Events(),
|
||||||
|
Cooldown: cooldown,
|
||||||
|
Now: time.Now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// start returns the lists of p, fetched through servers by Run, which runs
|
||||||
|
// until the test ends, once Run has fetched those due at start.
|
||||||
|
func start(t *testing.T, servers *standIn, p reputation.Params) *reputation.Lists {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
lists := reputation.New(p)
|
||||||
|
lists.SetTransport(servers)
|
||||||
|
run(t, lists)
|
||||||
|
|
||||||
|
return lists
|
||||||
|
}
|
||||||
|
|
||||||
|
// run runs lists' Run until the test ends, and waits until it has fetched
|
||||||
|
// the lists due.
|
||||||
|
func run(t *testing.T, lists *reputation.Lists) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
lists.Run(ctx)
|
||||||
|
close(stopped)
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
stop()
|
||||||
|
<-stopped
|
||||||
|
})
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantFetches waits until Run has made the fetches due, and checks how
|
||||||
|
// many the servers have had.
|
||||||
|
func wantFetches(t *testing.T, servers *standIn, want int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
servers.mu.Lock()
|
||||||
|
got := len(servers.fetches)
|
||||||
|
servers.mu.Unlock()
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%d fetches, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantListedBy checks the URLs of the blocklists lists says list addr.
|
||||||
|
func wantListedBy(t *testing.T, lists *reputation.Lists, addr string, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got := lists.ListedBy(netip.MustParseAddr(addr))
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("%s is listed by %v, want %v", addr, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waiting returns the alerts waiting in queue.
|
||||||
|
func waiting(queue *alerts.Queue) []alerts.Alert {
|
||||||
|
return queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantAlert checks that want is the one alert waiting in queue, and that
|
||||||
|
// the cooldown has held back one repeat of it.
|
||||||
|
func wantAlert(t *testing.T, queue *alerts.Queue, want alerts.Alert) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got := waiting(queue)
|
||||||
|
if len(got) != 1 || !reflect.DeepEqual(got[0], want) || queue.Suppressed() != 1 {
|
||||||
|
t.Errorf("alerts waiting %+v, %d held back, want only %+v and 1", got,
|
||||||
|
queue.Suppressed(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The action a request line names: what smallwebwaf did with the
|
// The action a request line names: what smallwebwaf did with the
|
||||||
@@ -26,10 +28,16 @@ const (
|
|||||||
// 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"
|
||||||
// ActionBanned is a request refused because a ban covers its client.
|
// ActionBanned is a request refused because a ban covers its client,
|
||||||
|
// or because it matched a ban rule, which bans the client.
|
||||||
ActionBanned = "banned"
|
ActionBanned = "banned"
|
||||||
|
// ActionRuleBlocked is a request refused because it matched a block
|
||||||
|
// rule.
|
||||||
|
ActionRuleBlocked = "rule_blocked"
|
||||||
// ActionDenied is a request refused because its client is in
|
// ActionDenied is a request refused because its client is in
|
||||||
// SWWAF_DENY_NETS.
|
// SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny,
|
||||||
|
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
||||||
|
// SWWAF_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"
|
||||||
@@ -39,47 +47,119 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// OffenceLimit is the offence a request line names for a request that
|
// OffenceLimit is the offence a request line names for a request that
|
||||||
// broke a rate limit.
|
// broke a rate limit, or whose bytes broke a byte limit.
|
||||||
const OffenceLimit = "limit"
|
const OffenceLimit = "limit"
|
||||||
|
|
||||||
// timeLayout is RFC 3339 with milliseconds.
|
// timeLayout is RFC 3339 with milliseconds.
|
||||||
const timeLayout = "2006-01-02T15:04:05.000Z07:00"
|
const timeLayout = "2006-01-02T15:04:05.000Z07:00"
|
||||||
|
|
||||||
// Line is one request's line in the request log. The field names are
|
// Line is one request's line in the request log. The field names, and
|
||||||
// those of the "Request log" section of SPEC.md.
|
// their order, are those of the "Request log" section of SPEC.md. A field
|
||||||
|
// that may not apply to a request is left out of its line when it does
|
||||||
|
// not.
|
||||||
//
|
//
|
||||||
//nolint:tagliatelle // SPEC.md's request log names its fields in snake_case
|
//nolint:tagliatelle // SPEC.md's request log names its fields in snake_case
|
||||||
type Line struct {
|
type Line struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
|
||||||
|
// The standard web log fields. Scheme is how the client reached
|
||||||
|
// smallwebwaf, or the trusted proxy in front of it.
|
||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
|
Instance string `json:"instance"`
|
||||||
ClientIP string `json:"client_ip"`
|
ClientIP string `json:"client_ip"`
|
||||||
PeerIP string `json:"peer_ip"`
|
|
||||||
Country string `json:"country"`
|
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
|
Scheme string `json:"scheme"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
UpstreamStatus int `json:"upstream_status,omitempty"`
|
|
||||||
RequestBytes int64 `json:"request_bytes"`
|
RequestBytes int64 `json:"request_bytes"`
|
||||||
ResponseBytes int64 `json:"response_bytes"`
|
ResponseBytes int64 `json:"response_bytes"`
|
||||||
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,
|
||||||
|
// or a new one, and is sent on to the app. ForwardedFor is the
|
||||||
|
// X-Forwarded-For header as received. ClientGroup is the netblock the
|
||||||
|
// client is counted as. ASN, ASName and Country are the client's AS
|
||||||
|
// number, AS name and country, as looked up.
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
PeerIP string `json:"peer_ip"`
|
||||||
|
ForwardedFor string `json:"forwarded_for,omitempty"`
|
||||||
|
ClientGroup string `json:"client_group"`
|
||||||
|
ASN string `json:"asn"`
|
||||||
|
ASName string `json:"as_name"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
ContentType string `json:"content_type,omitempty"`
|
||||||
|
// ContentLength is the length of its body the request announced.
|
||||||
|
ContentLength int64 `json:"content_length,omitempty"`
|
||||||
|
// RequestHeaders are the headers SWWAF_LOG_REQUEST_HEADERS names that
|
||||||
|
// the request carried, by name in lower case.
|
||||||
|
RequestHeaders map[string]string `json:"request_headers,omitempty"`
|
||||||
|
HasAuthorization bool `json:"has_authorization,omitempty"`
|
||||||
|
HasCookie bool `json:"has_cookie,omitempty"`
|
||||||
|
// 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
|
||||||
|
// passed on, or those of smallwebwaf's own. Aborted is true when the
|
||||||
|
// client went away early.
|
||||||
|
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"`
|
||||||
|
|
||||||
|
// The decision.
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
// LimitHit is the window whose rate limit the request went over:
|
// WouldAction is, in observe mode, the action enforce mode would have
|
||||||
// minute, hour or day.
|
// taken with a request it would have refused: ActionDenied,
|
||||||
|
// ActionBanned, ActionCountryDenied, ActionRateLimited or
|
||||||
|
// ActionRuleBlocked.
|
||||||
|
WouldAction string `json:"would_action,omitempty"`
|
||||||
|
// LimitPercent and LimitPercentSetting are, for a request the rate
|
||||||
|
// limits counted whose client a biased threshold gives a percentage of
|
||||||
|
// the rate limits below 100, that percentage and the setting that gave
|
||||||
|
// it. BytesPercent and BytesPercentSetting are the same for the byte
|
||||||
|
// limits.
|
||||||
|
LimitPercent *int64 `json:"limit_percent,omitempty"`
|
||||||
|
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
||||||
|
BytesPercent *int64 `json:"bytes_percent,omitempty"`
|
||||||
|
BytesPercentSetting string `json:"bytes_percent_setting,omitempty"`
|
||||||
|
// Counts are, for a request the rate limits counted, the client's
|
||||||
|
// requests as they counted them with this one, and its bytes as the
|
||||||
|
// byte limits counted them, with this request's once it has ended if
|
||||||
|
// they count them.
|
||||||
|
Counts ratelimit.Counts `json:"counts,omitzero"`
|
||||||
|
// RuleIDs are the ids of the rule file rules the request matched.
|
||||||
|
RuleIDs []string `json:"rule_ids,omitempty"`
|
||||||
|
// LimitHit is the window whose limit the request went over, named as
|
||||||
|
// Counts names its count: minute, hour or day for a rate limit, and
|
||||||
|
// minute_bytes, hour_bytes or day_bytes for a byte limit.
|
||||||
LimitHit string `json:"limit_hit,omitempty"`
|
LimitHit string `json:"limit_hit,omitempty"`
|
||||||
|
// Reputation are the URLs of the blocklists that list the client, then
|
||||||
|
// the DNSBL zones whose verdict lists it, their keys masked, then
|
||||||
|
// abuseipdb when its score is a hit.
|
||||||
|
Reputation []string `json:"reputation,omitempty"`
|
||||||
// Offence is the offence the request was held as, OffenceLimit.
|
// Offence is the offence the request was held as, OffenceLimit.
|
||||||
Offence string `json:"offence,omitempty"`
|
Offence string `json:"offence,omitempty"`
|
||||||
// BanExpires is when the ban the request made, or was refused under,
|
// BanExpires is when the ban the request made, or was refused under,
|
||||||
// ends: a time, or "permanent".
|
// ends: a time, or "permanent".
|
||||||
BanExpires string `json:"ban_expires,omitempty"`
|
BanExpires string `json:"ban_expires,omitempty"`
|
||||||
// Aborted is true when the client went away early.
|
|
||||||
Aborted bool `json:"aborted,omitempty"`
|
// The timings, in milliseconds. DurationChecks is the time until the
|
||||||
// DurationTotal and DurationUpstreamTotal are in milliseconds.
|
// checks were done. DurationUpstreamConnect, DurationUpstreamFirstByte
|
||||||
|
// and DurationUpstreamTotal run from when the request was handed to the
|
||||||
|
// app: until there was a connection to it, until the first byte of its
|
||||||
|
// 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"`
|
||||||
DurationUpstreamTotal float64 `json:"duration_upstream_total,omitempty"`
|
DurationChecks *float64 `json:"duration_checks,omitempty"`
|
||||||
|
DurationUpstreamConnect *float64 `json:"duration_upstream_connect,omitempty"`
|
||||||
|
DurationUpstreamFirstByte *float64 `json:"duration_upstream_first_byte,omitempty"`
|
||||||
|
DurationUpstreamTotal *float64 `json:"duration_upstream_total,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".
|
||||||
@@ -112,8 +192,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.
|
// as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance.
|
||||||
func NewProcessLogger(w io.Writer) *slog.Logger {
|
func NewProcessLogger(w io.Writer, instanceName string) *slog.Logger {
|
||||||
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
||||||
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
||||||
if attr.Key == slog.TimeKey && len(groups) == 0 {
|
if attr.Key == slog.TimeKey && len(groups) == 0 {
|
||||||
@@ -124,5 +204,5 @@ func NewProcessLogger(w io.Writer) *slog.Logger {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return slog.New(handler).With("type", "process")
|
return slog.New(handler).With("type", "process", "instance", instanceName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unset := []string{
|
unset := []string{
|
||||||
"upstream_status", "limit_hit", "offence", "ban_expires", "aborted",
|
"forwarded_for", "content_type", "content_length", "request_headers",
|
||||||
|
"has_authorization", "has_cookie", "websocket", "response_content_type",
|
||||||
|
"upstream_status", "cache_control", "location", "aborted", "counts",
|
||||||
|
"limit_hit", "offence", "ban_expires", "duration_checks",
|
||||||
|
"duration_upstream_connect", "duration_upstream_first_byte",
|
||||||
"duration_upstream_total",
|
"duration_upstream_total",
|
||||||
}
|
}
|
||||||
for _, name := range unset {
|
for _, name := range unset {
|
||||||
@@ -61,12 +65,12 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessLinesAreMarkedProcess(t *testing.T) {
|
func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
|
|
||||||
requestlog.NewProcessLogger(&out).Info("starting", "version", "v1")
|
requestlog.NewProcessLogger(&out, "fsn1app1/gitea").Info("starting", "version", "v1")
|
||||||
|
|
||||||
var fields map[string]any
|
var fields map[string]any
|
||||||
|
|
||||||
@@ -75,8 +79,9 @@ func TestProcessLinesAreMarkedProcess(t *testing.T) {
|
|||||||
t.Fatalf("decode %q: %v", out.String(), err)
|
t.Fatalf("decode %q: %v", out.String(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if fields["type"] != "process" || fields["msg"] != "starting" ||
|
if fields["type"] != "process" || fields["instance"] != "fsn1app1/gitea" ||
|
||||||
fields["level"] != "INFO" || fields["version"] != "v1" {
|
fields["msg"] != "starting" || fields["level"] != "INFO" ||
|
||||||
|
fields["version"] != "v1" {
|
||||||
t.Errorf("process line %v", fields)
|
t.Errorf("process line %v", fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
// Package rules reads the rule files: the plain text files in
|
||||||
|
// SWWAF_RULES_DIR, one rule to a line, that each request is checked
|
||||||
|
// against, as the "Rule files" section of SPEC.md describes. They are read
|
||||||
|
// at start, and again once the directory has had no change for a short
|
||||||
|
// time after one is edited, added or removed.
|
||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The actions a rule takes when it matches.
|
||||||
|
const (
|
||||||
|
// ActionLog notes the match in the request log, and does nothing else.
|
||||||
|
ActionLog = "log"
|
||||||
|
// ActionBlock refuses the request with 403.
|
||||||
|
ActionBlock = "block"
|
||||||
|
// ActionBan refuses the request and bans the client's netblock: the
|
||||||
|
// request is a clear sign of attack.
|
||||||
|
ActionBan = "ban"
|
||||||
|
)
|
||||||
|
|
||||||
|
// extension ends the name of every rule file.
|
||||||
|
const extension = ".rules"
|
||||||
|
|
||||||
|
// quietTime is how long SWWAF_RULES_DIR must go without a change before
|
||||||
|
// the rule files are read again, so that a file still being written, such
|
||||||
|
// as one saved in place, appended to or copied in with scp, is read only
|
||||||
|
// once whole.
|
||||||
|
const quietTime = 2 * time.Second
|
||||||
|
|
||||||
|
// headerTarget starts the target that is one request header,
|
||||||
|
// header:<Name>.
|
||||||
|
const headerTarget = "header:"
|
||||||
|
|
||||||
|
// escapeLength is the length of a percent escape, such as %2e.
|
||||||
|
const escapeLength = 3
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ruleLine is a rule: four fields separated by spaces or tabs, of
|
||||||
|
// which the fourth, the regex, runs to the end of the line.
|
||||||
|
ruleLine = regexp.MustCompile(`^([^ \t]+)[ \t]+([^ \t]+)[ \t]+([^ \t]+)[ \t]+(.+)$`)
|
||||||
|
// idChars are the characters of a rule's id.
|
||||||
|
idChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errNotRule = errors.New(
|
||||||
|
"is not a rule: an id, a target, an action and a regex, " +
|
||||||
|
"separated by spaces or tabs")
|
||||||
|
errNotID = errors.New("is not an id of letters, digits, - and _")
|
||||||
|
errNotTarget = errors.New(
|
||||||
|
"is not path, query, uri, method, host, user_agent, referer or header:<Name>")
|
||||||
|
errNotHeaderName = errors.New(
|
||||||
|
"has a character after header: that no header name can have")
|
||||||
|
errHeaderTakenOut = errors.New(
|
||||||
|
"names a header that Go's HTTP server takes out of every request, " +
|
||||||
|
"so a rule never sees it")
|
||||||
|
errNotAction = errors.New("is not log, block or ban")
|
||||||
|
errNotRegex = errors.New("does not compile")
|
||||||
|
errUsedTwice = errors.New("is already the id of the rule at")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rule is one rule of a rule file.
|
||||||
|
type Rule struct {
|
||||||
|
// ID names the rule in the request log, the metrics and ban notes.
|
||||||
|
ID string
|
||||||
|
// Target is what the regex is matched against, such as path or
|
||||||
|
// header:Accept.
|
||||||
|
Target string
|
||||||
|
// Action is ActionLog, ActionBlock or ActionBan.
|
||||||
|
Action string
|
||||||
|
|
||||||
|
regex *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params are what Load needs.
|
||||||
|
type Params struct {
|
||||||
|
// Dir is the directory of the rule files (SWWAF_RULES_DIR).
|
||||||
|
Dir string
|
||||||
|
// Enabled is SWWAF_RULES_ENABLED: while it is false, no file is read
|
||||||
|
// and no rule loaded.
|
||||||
|
Enabled bool
|
||||||
|
// ProcessLog receives how many rules were read, and the error in a
|
||||||
|
// rule file edited while smallwebwaf runs.
|
||||||
|
ProcessLog *slog.Logger
|
||||||
|
// Alerts receive a file_error alert for that error.
|
||||||
|
Alerts *alerts.Queue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files are the rule files of a running smallwebwaf, and the rules read
|
||||||
|
// from them. They are safe for concurrent use.
|
||||||
|
type Files struct {
|
||||||
|
params Params
|
||||||
|
// rules are the rules loaded, in the order of their files' names, and
|
||||||
|
// then of their lines.
|
||||||
|
rules atomic.Pointer[[]Rule]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the rules of every *.rules file in Dir, in the order of the
|
||||||
|
// files' names, unless Enabled is false. A Dir that cannot be read is an
|
||||||
|
// error, and so is a line that is not a rule, a header name with a
|
||||||
|
// character no header name can have, a rule for the Host or the
|
||||||
|
// Transfer-Encoding header, which Go's HTTP server takes out of every
|
||||||
|
// request, a regex that does not compile and an id used twice, each named
|
||||||
|
// with its file and line.
|
||||||
|
func Load(params Params) (*Files, error) {
|
||||||
|
f := &Files{params: params}
|
||||||
|
f.rules.Store(&[]Rule{})
|
||||||
|
|
||||||
|
if !params.Enabled {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, _, err := read(params.Dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.rules.Store(&rules)
|
||||||
|
f.logRead(len(rules))
|
||||||
|
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match checks r against the rules, in order, and returns those it
|
||||||
|
// matches, up to the first whose action refuses it, block or ban, which
|
||||||
|
// is then the last one returned.
|
||||||
|
func (f *Files) Match(r *http.Request) []Rule {
|
||||||
|
var matched []Rule
|
||||||
|
|
||||||
|
for _, rule := range *f.rules.Load() {
|
||||||
|
if !rule.matches(r) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
matched = append(matched, rule)
|
||||||
|
if rule.Action != ActionLog {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns how many rules are loaded.
|
||||||
|
func (f *Files) Len() int {
|
||||||
|
return len(*f.rules.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch watches Dir until ctx is done, and reads the rule files again
|
||||||
|
// once Dir has had no change for quietTime, after one is edited, added or
|
||||||
|
// removed, and after Watch starts watching. If they then hold an error,
|
||||||
|
// the rules stay as they were, the error is logged with its file and
|
||||||
|
// line, and the files are read again after the next change. If Dir cannot
|
||||||
|
// be watched, that is logged, and the rules stay as they were loaded.
|
||||||
|
// While Enabled is false, Watch returns at once.
|
||||||
|
func (f *Files) Watch(ctx context.Context) {
|
||||||
|
if !f.params.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err == nil {
|
||||||
|
defer func() {
|
||||||
|
_ = watcher.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
err = watcher.Add(f.params.Dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
f.params.ProcessLog.Error("cannot watch the rule files for edits",
|
||||||
|
"error", err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f.params.ProcessLog.Info("watching the rule files for edits",
|
||||||
|
"directory", f.params.Dir)
|
||||||
|
|
||||||
|
f.readAfterChanges(ctx, watcher.Events, watcher.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAfterChanges reads the rule files again once quietTime has passed
|
||||||
|
// without a change from events, until ctx is done, and logs the errors
|
||||||
|
// from errs. The wait starts at once, as if for a change, so that an edit
|
||||||
|
// saved after Load read the files, and before Dir was watched, is taken
|
||||||
|
// in too.
|
||||||
|
func (f *Files) readAfterChanges(
|
||||||
|
ctx context.Context, events <-chan fsnotify.Event, errs <-chan error,
|
||||||
|
) {
|
||||||
|
quiet := time.NewTimer(quietTime)
|
||||||
|
defer quiet.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-events:
|
||||||
|
quiet.Reset(quietTime)
|
||||||
|
case <-quiet.C:
|
||||||
|
f.readAgain()
|
||||||
|
case err := <-errs:
|
||||||
|
f.params.ProcessLog.Warn("watching the rule files failed",
|
||||||
|
"error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAgain reads the rule files again, in place of the rules loaded, or
|
||||||
|
// logs the error that keeps the rules as they were, and raises a
|
||||||
|
// file_error alert for it, for the file it is in.
|
||||||
|
func (f *Files) readAgain() {
|
||||||
|
rules, path, err := read(f.params.Dir)
|
||||||
|
if err != nil {
|
||||||
|
const kept = "a rule file has an error, and the rules stay as they were"
|
||||||
|
|
||||||
|
// Raised before it is logged, so that the alert is there once the
|
||||||
|
// log line is.
|
||||||
|
f.params.Alerts.Raise(alerts.Alert{
|
||||||
|
Event: alerts.EventFileError,
|
||||||
|
Reason: kept,
|
||||||
|
Detail: map[string]any{"file": path, "error": err.Error()},
|
||||||
|
})
|
||||||
|
f.params.ProcessLog.Error(kept, "error", err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f.rules.Store(&rules)
|
||||||
|
f.logRead(len(rules))
|
||||||
|
}
|
||||||
|
|
||||||
|
// logRead logs that the rule files were read, and how many rules they
|
||||||
|
// hold, which can be none.
|
||||||
|
func (f *Files) logRead(count int) {
|
||||||
|
f.params.ProcessLog.Info("read the rule files",
|
||||||
|
"directory", f.params.Dir, "rules", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// read returns the rules of every rule file in dir, in the order of the
|
||||||
|
// files' names, and then of their lines, or an error, with the path of the
|
||||||
|
// rule file it is in, or dir. A file whose name starts with a dot, such as
|
||||||
|
// an editor's lock file .#50-app.rules, is not a rule file, as a shell's
|
||||||
|
// *.rules would not match it.
|
||||||
|
func read(dir string) ([]Rule, string, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dir, fmt.Errorf("SWWAF_RULES_DIR cannot be read: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules []Rule
|
||||||
|
|
||||||
|
// places are where each id is, as "<file>, line <n>".
|
||||||
|
places := map[string]string{}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if entry.IsDir() || strings.HasPrefix(name, ".") || filepath.Ext(name) != extension {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
|
||||||
|
rules, err = readFile(path, rules, places)
|
||||||
|
if err != nil {
|
||||||
|
return nil, path, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFile appends the rules of the rule file at path to rules. places
|
||||||
|
// are where each id read so far is, and gain those of the file.
|
||||||
|
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWWAF_RULES_DIR
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
number := 0
|
||||||
|
|
||||||
|
for line := range strings.Lines(string(data)) {
|
||||||
|
number++
|
||||||
|
place := fmt.Sprintf("%s, line %d", path, number)
|
||||||
|
|
||||||
|
text := strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
|
||||||
|
|
||||||
|
rule, isRule, err := parse(text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", place, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isRule {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
first, used := places[rule.ID]
|
||||||
|
if used {
|
||||||
|
return nil, fmt.Errorf("%s: the id %q %w %s", place, rule.ID, errUsedTwice, first)
|
||||||
|
}
|
||||||
|
|
||||||
|
places[rule.ID] = place
|
||||||
|
rules = append(rules, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse reads a line of a rule file. It returns false for a blank line
|
||||||
|
// and for a comment, a line that starts with #. Spaces and tabs at the
|
||||||
|
// end of the line are not part of its regex, so a line with only those
|
||||||
|
// after its action has no regex, and is not a rule.
|
||||||
|
func parse(line string) (Rule, bool, error) {
|
||||||
|
line = strings.Trim(line, " \t")
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
return Rule{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := ruleLine.FindStringSubmatch(line)
|
||||||
|
if fields == nil {
|
||||||
|
return Rule{}, false, errNotRule
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := Rule{ID: fields[1], Target: fields[2], Action: fields[3]}
|
||||||
|
headerName, isHeader := strings.CutPrefix(rule.Target, headerTarget)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !idChars.MatchString(rule.ID):
|
||||||
|
return Rule{}, false, fmt.Errorf("the id %q %w", rule.ID, errNotID)
|
||||||
|
case !isTarget(rule.Target):
|
||||||
|
return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotTarget)
|
||||||
|
case isHeader && !config.IsHeaderName(headerName):
|
||||||
|
return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotHeaderName)
|
||||||
|
case strings.EqualFold(rule.Target, headerTarget+"Host"):
|
||||||
|
return Rule{}, false, fmt.Errorf(
|
||||||
|
"the target %q %w; the request's host is the target host",
|
||||||
|
rule.Target, errHeaderTakenOut)
|
||||||
|
case strings.EqualFold(rule.Target, headerTarget+"Transfer-Encoding"):
|
||||||
|
return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errHeaderTakenOut)
|
||||||
|
case !slices.Contains([]string{ActionLog, ActionBlock, ActionBan}, rule.Action):
|
||||||
|
return Rule{}, false, fmt.Errorf("the action %q %w", rule.Action, errNotAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
regex, err := regexp.Compile(fields[4])
|
||||||
|
if err != nil {
|
||||||
|
return Rule{}, false, fmt.Errorf("the regex %w: %w", errNotRegex, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.regex = regex
|
||||||
|
|
||||||
|
return rule, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTarget reports whether target is one a rule may have.
|
||||||
|
func isTarget(target string) bool {
|
||||||
|
switch target {
|
||||||
|
case "path", "query", "uri", "method", "host", "user_agent", "referer":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
name, isHeader := strings.CutPrefix(target, headerTarget)
|
||||||
|
|
||||||
|
return isHeader && name != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// matches reports whether the rule's regex matches its target in r. For
|
||||||
|
// uri it is matched against the path and query as received, and against
|
||||||
|
// them once percent-decoded, so that an encoded probe cannot slip past.
|
||||||
|
func (rule Rule) matches(r *http.Request) bool {
|
||||||
|
if rule.Target == "uri" {
|
||||||
|
uri := pathAndQuery(r)
|
||||||
|
|
||||||
|
return rule.regex.MatchString(uri) || rule.regex.MatchString(decodeOnce(uri))
|
||||||
|
}
|
||||||
|
|
||||||
|
return rule.regex.MatchString(value(rule.Target, r))
|
||||||
|
}
|
||||||
|
|
||||||
|
// value returns what a rule with target, other than uri, is matched
|
||||||
|
// against in r: the path and the query as the client sent them, before
|
||||||
|
// any decoding or re-encoding, split at the first ?, and a header's values
|
||||||
|
// joined by ", ", as HTTP joins those of a header sent more than once.
|
||||||
|
func value(target string, r *http.Request) string {
|
||||||
|
switch target {
|
||||||
|
case "path":
|
||||||
|
path, _, _ := strings.Cut(pathAndQuery(r), "?")
|
||||||
|
|
||||||
|
return path
|
||||||
|
case "query":
|
||||||
|
_, query, _ := strings.Cut(pathAndQuery(r), "?")
|
||||||
|
|
||||||
|
return query
|
||||||
|
case "method":
|
||||||
|
return r.Method
|
||||||
|
case "host":
|
||||||
|
return r.Host
|
||||||
|
case "user_agent":
|
||||||
|
return header(r, "User-Agent")
|
||||||
|
case "referer":
|
||||||
|
return header(r, "Referer")
|
||||||
|
default:
|
||||||
|
return header(r, strings.TrimPrefix(target, headerTarget))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathAndQuery returns the target of r's request line, r.RequestURI, as
|
||||||
|
// the client sent it, less any scheme and host: a target with a scheme
|
||||||
|
// gives what follows the scheme and its :, and the host when // follows.
|
||||||
|
// So http://host/path, as a client sends it to a proxy, gives /path, and
|
||||||
|
// so does http:/path, which Go reads as a target with a scheme and no
|
||||||
|
// host. r.URL is not used: when the path holds a character it escapes,
|
||||||
|
// such as \ or a non-ASCII byte, it decodes the whole path and escapes it
|
||||||
|
// again, so that \ becomes %5C and %2e a dot.
|
||||||
|
func pathAndQuery(r *http.Request) string {
|
||||||
|
if !r.URL.IsAbs() {
|
||||||
|
return r.RequestURI
|
||||||
|
}
|
||||||
|
|
||||||
|
_, afterScheme, _ := strings.Cut(r.RequestURI, ":")
|
||||||
|
|
||||||
|
hostAndRest, hasHost := strings.CutPrefix(afterScheme, "//")
|
||||||
|
if !hasHost {
|
||||||
|
return afterScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
start := strings.IndexAny(hostAndRest, "/?")
|
||||||
|
if start < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return hostAndRest[start:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// header returns the values of r's header name joined by ", ", or "" if
|
||||||
|
// r has no such header.
|
||||||
|
func header(r *http.Request, name string) string {
|
||||||
|
return strings.Join(r.Header.Values(name), ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeOnce returns s with each percent escape, such as %2e, replaced by
|
||||||
|
// the byte it stands for. A % that is not followed by two hex digits is
|
||||||
|
// left as it is, so that a malformed escape cannot keep the rest of s
|
||||||
|
// from being decoded.
|
||||||
|
func decodeOnce(s string) string {
|
||||||
|
var decoded strings.Builder
|
||||||
|
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '%' && i+escapeLength <= len(s) {
|
||||||
|
b, err := hex.DecodeString(s[i+1 : i+escapeLength])
|
||||||
|
if err == nil {
|
||||||
|
decoded.Write(b)
|
||||||
|
|
||||||
|
i += escapeLength - 1
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded.WriteByte(s[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,687 @@
|
|||||||
|
package rules_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// What the process log says once Watch watches the directory, after
|
||||||
|
// each reading of the rule files, and for one that has an error.
|
||||||
|
watching = "watching the rule files for edits"
|
||||||
|
read = "read the rule files"
|
||||||
|
hasError = "a rule file has an error, and the rules stay as they were"
|
||||||
|
// maxLogLines is how many lines of the process log wait for a test to
|
||||||
|
// read them.
|
||||||
|
maxLogLines = 64
|
||||||
|
// browser is the user agent of an ordinary visitor.
|
||||||
|
browser = "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
|
||||||
|
// testFile is the rule file of a test that needs only one, and
|
||||||
|
// firstFile the first of a test's rule files.
|
||||||
|
testFile = "test.rules"
|
||||||
|
firstFile = "00-a.rules"
|
||||||
|
// userAgent is the header that carries the user agent.
|
||||||
|
userAgent = "User-Agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEachTargetMatchesWhatItNames(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
rule string // its target, action and regex
|
||||||
|
uri string // the request's path and query
|
||||||
|
header http.Header
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"path as received", `path log ^/%2eenv$`, "/%2eenv", nil, true},
|
||||||
|
{"path not decoded", `path log ^/\.env$`, "/%2eenv", nil, false},
|
||||||
|
{"path without the query", `path log ^/a$`, "/a?b=c", nil, true},
|
||||||
|
{"query as received", `query log ^b=%2e$`, "/a?b=%2e", nil, true},
|
||||||
|
{"uri as received", `uri log ^/a\?b=%2e$`, "/a?b=%2e", nil, true},
|
||||||
|
{"uri decoded", `uri log (\.\./){2}`, "/a?f=%2e%2e%2f%2e%2e%2f", nil, true},
|
||||||
|
{
|
||||||
|
"uri decoded past malformed escapes", `uri log (\.\./){2}&h=%$`,
|
||||||
|
"/a?g=%zz&f=%2e%2e%2f%2e%2e%2f&h=%", nil, true,
|
||||||
|
},
|
||||||
|
{"uri decoded only once", `uri log ^/a\.b$`, "/a%252eb", nil, false},
|
||||||
|
{"method", `method log ^PUT$`, "/", nil, true},
|
||||||
|
{"host", `host log ^app\.example$`, "/", nil, true},
|
||||||
|
{
|
||||||
|
"user_agent", `user_agent log ^sqlmap/`, "/",
|
||||||
|
http.Header{userAgent: {"sqlmap/1.8"}}, true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_agent sent twice", `user_agent log ^curl/8, sqlmap/`, "/",
|
||||||
|
http.Header{userAgent: {"curl/8", "sqlmap/1.8"}}, true,
|
||||||
|
},
|
||||||
|
{"user_agent missing", `user_agent log ^$`, "/", nil, true},
|
||||||
|
{
|
||||||
|
"referer", `referer log ^https://spam\.example/`, "/",
|
||||||
|
http.Header{"Referer": {"https://spam.example/buy"}}, true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a header sent twice", `header:x-api-version log ^2, 3$`, "/",
|
||||||
|
http.Header{"X-Api-Version": {"2", "3"}}, true,
|
||||||
|
},
|
||||||
|
{"a header missing", `header:X-Api-Version log ^$`, "/", nil, true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
files := load(t, ruleFiles{testFile: "a-rule " + tc.rule + "\n"})
|
||||||
|
|
||||||
|
// Every request is a PUT, which the method rule looks for.
|
||||||
|
r := httptest.NewRequestWithContext(t.Context(), http.MethodPut,
|
||||||
|
"http://app.example"+tc.uri, nil)
|
||||||
|
maps.Copy(r.Header, tc.header)
|
||||||
|
|
||||||
|
got := len(files.Match(r)) == 1
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("%s matches %s: %t, want %t", tc.rule, tc.uri, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathMatchedAsTheClientSentIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Each path holds a character Go's URL type would escape again, \ or
|
||||||
|
// a non-ASCII byte, and each rule is written for the path as sent.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
rule string // its target, action and regex
|
||||||
|
sent string // the path and query the client sent
|
||||||
|
}{
|
||||||
|
{`path log ^/\.\.\\\.\.\\windows\\win\.ini$`, `/..\..\windows\win.ini`},
|
||||||
|
{`path log ^/%2e%2e\\%2e%2e\\windows\\win\.ini$`, `/%2e%2e\%2e%2e\windows\win.ini`},
|
||||||
|
{`path log ^/café$`, "/café?x=1"},
|
||||||
|
{`uri log ^/%2e%2e\\%2e%2e\\boot\.ini\?x=1$`, `/%2e%2e\%2e%2e\boot.ini?x=1`},
|
||||||
|
} {
|
||||||
|
files := load(t, ruleFiles{testFile: "as-sent " + tc.rule + "\n"})
|
||||||
|
|
||||||
|
// The target in origin form, as traefik sends it, in absolute form,
|
||||||
|
// as a client sends it to a proxy, and with a scheme but no host,
|
||||||
|
// which Go reads as absolute form with no host, sending the app
|
||||||
|
// the path.
|
||||||
|
for _, target := range []string{
|
||||||
|
tc.sent, "http://app.example" + tc.sent, "http:" + tc.sent, "foo:" + tc.sent,
|
||||||
|
} {
|
||||||
|
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
||||||
|
wantMatched(t, files, r, "as-sent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchingStopsAtTheFirstRuleThatRefuses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
files := load(t, ruleFiles{testFile: `
|
||||||
|
every-path path log ^/
|
||||||
|
no-path path log ^$
|
||||||
|
first-refusal path block ^/probe
|
||||||
|
later-ban path ban ^/probe
|
||||||
|
after path log ^/
|
||||||
|
`})
|
||||||
|
|
||||||
|
// Every log rule that matches is noted, and the block rule ends the
|
||||||
|
// matching.
|
||||||
|
wantMatched(t, files, get(t, "/probe"), "every-path", "first-refusal")
|
||||||
|
wantMatched(t, files, get(t, "/page"), "every-path", "after")
|
||||||
|
|
||||||
|
// A ban rule ends it too.
|
||||||
|
files = load(t, ruleFiles{testFile: "ban path ban ^/\nlater path block ^/\n"})
|
||||||
|
wantMatched(t, files, get(t, "/"), "ban")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpacesAndTabsEndingALineAreNotPartOfItsRegex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
files := load(t, ruleFiles{testFile: "env-file path block ^/\\.env$ \t \n"})
|
||||||
|
|
||||||
|
wantMatched(t, files, get(t, "/.env"), "env-file")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesReadInNameOrderThenLineOrder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
files := load(t, ruleFiles{
|
||||||
|
"50-b.rules": "b1 path log ^/\n\n# a comment\n # an indented one\nb2 path log ^/\n",
|
||||||
|
firstFile: "a1 path log ^/\r\n",
|
||||||
|
// None is a rule file.
|
||||||
|
"notes.txt": "notes, not rules\n",
|
||||||
|
"10-c.rules.bak": "an old copy\n",
|
||||||
|
"20-d.rules/keep": "a file in a directory\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
wantMatched(t, files, get(t, "/"), "a1", "b1", "b2")
|
||||||
|
|
||||||
|
if files.Len() != 3 {
|
||||||
|
t.Errorf("%d rules loaded, want 3", files.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileWhoseNameStartsWithADotIsNotARuleFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := writeFiles(t, ruleFiles{firstFile: "probe path block ^/probe\n"})
|
||||||
|
|
||||||
|
// The lock file Emacs makes beside a file while it is edited: a link to
|
||||||
|
// nothing, which cannot be read.
|
||||||
|
err := os.Symlink("user@host.1234:1700000000", filepath.Join(dir, ".#"+firstFile))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
params, _ := newParams(dir)
|
||||||
|
|
||||||
|
files, err := rules.Load(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantMatched(t, files, get(t, "/probe"), "probe")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
line int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"too few fields", "env-file path ban\n", 1,
|
||||||
|
"is not a rule: an id, a target, an action and a regex, " +
|
||||||
|
"separated by spaces or tabs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Else its regex would be a space, found in nearly every user agent.
|
||||||
|
"a regex of only spaces and tabs", "scanner user_agent ban\t \n", 1,
|
||||||
|
"is not a rule: an id, a target, an action and a regex, " +
|
||||||
|
"separated by spaces or tabs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"an id of other characters", "# ids\n\nenv.file path ban ^/\n", 3,
|
||||||
|
`the id "env.file" is not an id of letters, digits, - and _`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"an unknown target", "env-file paths ban ^/\n", 1,
|
||||||
|
`the target "paths" is not path, query, uri, method, host, ` +
|
||||||
|
"user_agent, referer or header:<Name>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a header without a name", "env-file header: ban ^/\n", 1,
|
||||||
|
`the target "header:" is not path, query, uri, method, host, ` +
|
||||||
|
"user_agent, referer or header:<Name>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a header name written with its colon", "sqlmap header:User-Agent: ban sqlmap\n", 1,
|
||||||
|
`the target "header:User-Agent:" has a character after header: ` +
|
||||||
|
"that no header name can have",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a header name with a semicolon", "accept header:Accept;q log ^$\n", 1,
|
||||||
|
`the target "header:Accept;q" has a character after header: ` +
|
||||||
|
"that no header name can have",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a header name with brackets", "x-header header:X(y) log ^$\n", 1,
|
||||||
|
`the target "header:X(y)" has a character after header: ` +
|
||||||
|
"that no header name can have",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"the Host header", "host-header header:host block ^$\n", 1,
|
||||||
|
`the target "header:host" names a header that Go's HTTP server ` +
|
||||||
|
"takes out of every request, so a rule never sees it; " +
|
||||||
|
"the request's host is the target host",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"the Transfer-Encoding header",
|
||||||
|
"# bodies sent in chunks\nchunked header:Transfer-Encoding block ^chunked$\n", 2,
|
||||||
|
`the target "header:Transfer-Encoding" names a header that Go's ` +
|
||||||
|
"HTTP server takes out of every request, so a rule never sees it",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"an unknown action", "env-file path deny ^/\n", 1,
|
||||||
|
`the action "deny" is not log, block or ban`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a regex that does not compile", "env-file path ban ^/(\n", 1,
|
||||||
|
"the regex does not compile: error parsing regexp: " +
|
||||||
|
"missing closing ): `^/(`",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := writeFiles(t, ruleFiles{"00-default.rules": tc.content})
|
||||||
|
path := filepath.Join(dir, "00-default.rules")
|
||||||
|
|
||||||
|
wantRefused(t, dir, path+", line "+strconv.Itoa(tc.line)+": "+tc.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIDUsedTwiceStopsTheStartNamingBothPlaces(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := writeFiles(t, ruleFiles{
|
||||||
|
"00-a.rules": "probe path log ^/a\n",
|
||||||
|
"50-b.rules": "other path log ^/b\nprobe path ban ^/c\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
wantRefused(t, dir, filepath.Join(dir, "50-b.rules")+`, line 2: the id "probe" `+
|
||||||
|
"is already the id of the rule at "+filepath.Join(dir, "00-a.rules")+", line 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||||
|
|
||||||
|
wantRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+
|
||||||
|
": no such file or directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
params, lines := newParams(writeFiles(t, ruleFiles{"00-default.rules": "# none\n"}))
|
||||||
|
|
||||||
|
files, err := rules.Load(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := lines.waitFor(t, read)
|
||||||
|
if files.Len() != 0 || line["rules"] != 0.0 {
|
||||||
|
t.Errorf("%d rules loaded, and the log says %v, want none", files.Len(), line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleFilesOffReadNothing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// SWWAF_RULES_DIR does not exist, which would stop the start.
|
||||||
|
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
||||||
|
params.Enabled = false
|
||||||
|
|
||||||
|
files, err := rules.Load(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if files.Len() != 0 || files.Match(get(t, "/")) != nil {
|
||||||
|
t.Errorf("%d rules loaded with the rule files off", files.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// It would watch until the test ends.
|
||||||
|
files.Watch(t.Context())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditsTakenInWhileRunning(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
|
||||||
|
files, lines, _ := watch(t, dir)
|
||||||
|
|
||||||
|
// matches reports whether path matches a rule.
|
||||||
|
matches := func(path string) bool { return len(files.Match(get(t, path))) == 1 }
|
||||||
|
|
||||||
|
// A file added.
|
||||||
|
save(t, dir, "50-b.rules", "second path block ^/second\n")
|
||||||
|
lines.waitUntil(t, func() bool { return matches("/second") })
|
||||||
|
wantMatched(t, files, get(t, "/first"), "first")
|
||||||
|
|
||||||
|
// A file edited.
|
||||||
|
save(t, dir, firstFile, "first path block ^/edited\n")
|
||||||
|
lines.waitUntil(t, func() bool { return !matches("/first") })
|
||||||
|
wantMatched(t, files, get(t, "/edited"), "first")
|
||||||
|
|
||||||
|
// A file removed.
|
||||||
|
err := os.Remove(filepath.Join(dir, "50-b.rules"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("remove: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.waitUntil(t, func() bool { return !matches("/second") })
|
||||||
|
wantMatched(t, files, get(t, "/edited"), "first")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
|
||||||
|
files, lines, queue := watch(t, dir)
|
||||||
|
|
||||||
|
// The edit's second line has an unknown action, so the rules stay as
|
||||||
|
// they were, the first line's earlier version included.
|
||||||
|
save(t, dir, firstFile, "first path block ^/edited\nsecond path bann ^/second\n")
|
||||||
|
|
||||||
|
line := lines.waitFor(t, hasError)
|
||||||
|
want := filepath.Join(dir, firstFile) +
|
||||||
|
`, line 2: the action "bann" is not log, block or ban`
|
||||||
|
|
||||||
|
if line["error"] != want || line["level"] != "ERROR" {
|
||||||
|
t.Errorf("logged %v, want an error %q", line, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The error is raised as a file_error alert too, for the file.
|
||||||
|
wantFileError := func() {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 1 || waiting[0].Event != alerts.EventFileError ||
|
||||||
|
waiting[0].Reason != hasError || waiting[0].Detail["error"] != want ||
|
||||||
|
waiting[0].Detail["file"] != filepath.Join(dir, firstFile) {
|
||||||
|
t.Errorf("alerts waiting %+v, want one file_error alert for %q", waiting, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wantFileError()
|
||||||
|
|
||||||
|
wantMatched(t, files, get(t, "/first"), "first")
|
||||||
|
wantMatched(t, files, get(t, "/second"))
|
||||||
|
|
||||||
|
// Once mended, the file is read again, and raises no alert.
|
||||||
|
save(t, dir, firstFile, "first path block ^/edited\nsecond path ban ^/second\n")
|
||||||
|
lines.waitUntil(t, func() bool { return len(files.Match(get(t, "/second"))) == 1 })
|
||||||
|
wantMatched(t, files, get(t, "/edited"), "first")
|
||||||
|
wantFileError()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultFileBansProbesAtTheSiteRootAlone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
params, _ := newParams(filepath.Join("..", "..", "share", "rules.d"))
|
||||||
|
|
||||||
|
files, err := rules.Load(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load the default file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probes sent by a browser, by the rule that refuses them.
|
||||||
|
for rule, targets := range map[string][]string{
|
||||||
|
"env-file": {"/.env", "/.env.production", "/.ENV"},
|
||||||
|
"vcs-dir": {"/.git/config", "/.git", "/.svn/entries"},
|
||||||
|
"secrets-dir": {"/.aws/credentials", "/.ssh/id_rsa"},
|
||||||
|
"secret-file": {"/.htpasswd", "/.DS_Store", "/.git-credentials"},
|
||||||
|
"editor-dir": {"/.vscode/sftp.json"},
|
||||||
|
"backup-file": {
|
||||||
|
"/wp-config.php.bak", "/index.php~", "/dump.sql", "/backup.sql.gz",
|
||||||
|
},
|
||||||
|
"log-file": {"/debug.log"},
|
||||||
|
"compose-file": {"/docker-compose.yml", "/compose.yaml"},
|
||||||
|
"php-shell": {"/shell.php"},
|
||||||
|
"path-traversal": {
|
||||||
|
"/static/../../etc/passwd", "/f?f=%2e%2e%2f%2e%2e%2fetc%2fpasswd",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
for _, target := range targets {
|
||||||
|
wantRefusedBy(t, files, target, browser, rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scanners, by their user agents.
|
||||||
|
for _, scanner := range []string{
|
||||||
|
"sqlmap/1.8.4#stable (https://sqlmap.org)",
|
||||||
|
"Mozilla/5.0 (compatible; Nuclei - Open-source project)",
|
||||||
|
} {
|
||||||
|
wantRefusedBy(t, files, "/", scanner, "scanner-agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordinary requests to a code forge for files of those names deeper
|
||||||
|
// in its paths, and for other files at its root.
|
||||||
|
for _, target := range []string{
|
||||||
|
"/owner/repo/src/branch/main/.env.example",
|
||||||
|
"/owner/repo/src/branch/main/.env",
|
||||||
|
"/owner/repo/src/branch/main/.github/workflows/ci.yml",
|
||||||
|
"/owner/repo/src/branch/main/.vscode/settings.json",
|
||||||
|
"/owner/repo/src/branch/main/.htaccess",
|
||||||
|
"/owner/repo/src/branch/main/docker-compose.yml",
|
||||||
|
"/owner/repo/src/branch/main/db/schema.sql",
|
||||||
|
"/owner/repo/raw/branch/main/debug.log",
|
||||||
|
"/owner/repo.git/info/refs?service=git-upload-pack",
|
||||||
|
"/owner/repo/src/branch/main/docs/../README.md",
|
||||||
|
"/user/login?redirect_to=%2fowner%2frepo",
|
||||||
|
"/index.php",
|
||||||
|
"/.well-known/security.txt",
|
||||||
|
} {
|
||||||
|
r := get(t, target)
|
||||||
|
r.Header.Set(userAgent, browser)
|
||||||
|
|
||||||
|
matched := files.Match(r)
|
||||||
|
if len(matched) != 0 {
|
||||||
|
t.Errorf("%s matched %v, want no rule", target, ids(matched))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request without a user agent is only noted.
|
||||||
|
wantMatched(t, files, get(t, "/"), "empty-agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruleFiles are files to write into a directory of rule files, by name.
|
||||||
|
type ruleFiles map[string]string
|
||||||
|
|
||||||
|
// writeFiles writes files into a new directory, and returns it.
|
||||||
|
func writeFiles(t *testing.T, files ruleFiles) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
for name, content := range files {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
|
||||||
|
err := os.MkdirAll(filepath.Dir(path), 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(path, []byte(content), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// save writes content to the rule file name in dir as an editor that
|
||||||
|
// saves by renaming does, so that the file is never seen half written.
|
||||||
|
func save(t *testing.T, dir, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
|
||||||
|
err := os.WriteFile(path+".tmp", []byte(content), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Rename(path+".tmp", path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rename: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newParams returns Params for the rule files in dir, switched on, with
|
||||||
|
// the process log in the processLog returned, and the alerts waiting in a
|
||||||
|
// queue for a webhook that is never sent them.
|
||||||
|
func newParams(dir string) (rules.Params, processLog) {
|
||||||
|
lines := make(processLog, maxLogLines)
|
||||||
|
|
||||||
|
return rules.Params{
|
||||||
|
Dir: dir,
|
||||||
|
Enabled: true,
|
||||||
|
ProcessLog: slog.New(slog.NewJSONHandler(lines, nil)),
|
||||||
|
Alerts: alerts.New(alerts.Params{
|
||||||
|
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
||||||
|
Events: alerts.Events(),
|
||||||
|
Cooldown: 15 * time.Minute,
|
||||||
|
Now: time.Now,
|
||||||
|
}),
|
||||||
|
}, lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// load writes files into a new directory and loads the rules in it.
|
||||||
|
func load(t *testing.T, files ruleFiles) *rules.Files {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
params, _ := newParams(writeFiles(t, files))
|
||||||
|
params.ProcessLog = slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
loaded, err := rules.Load(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
// watch loads the rules in dir, runs their Watch until the test ends, and
|
||||||
|
// waits until it watches the directory. It returns the alerts' queue as
|
||||||
|
// well.
|
||||||
|
func watch(t *testing.T, dir string) (*rules.Files, processLog, *alerts.Queue) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
params, lines := newParams(dir)
|
||||||
|
|
||||||
|
files, err := rules.Load(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
files.Watch(ctx)
|
||||||
|
close(stopped)
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
stop()
|
||||||
|
<-stopped
|
||||||
|
})
|
||||||
|
|
||||||
|
lines.waitFor(t, watching)
|
||||||
|
|
||||||
|
return files, lines, params.Alerts
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantRefused checks that loading the rule files in dir fails with the
|
||||||
|
// error want.
|
||||||
|
func wantRefused(t *testing.T, dir, want string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
params, _ := newParams(dir)
|
||||||
|
|
||||||
|
_, err := rules.Load(params)
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("error %v, want %s", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get returns a GET request for target, a path and an optional query, as
|
||||||
|
// smallwebwaf's server reads it, without a user agent.
|
||||||
|
func get(t *testing.T, target string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"http://app.example"+target, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantRefusedBy checks that a GET request for target with the user agent
|
||||||
|
// sent matches rule alone, and that rule refuses it.
|
||||||
|
func wantRefusedBy(t *testing.T, files *rules.Files, target, sent, rule string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
r := get(t, target)
|
||||||
|
r.Header.Set(userAgent, sent)
|
||||||
|
|
||||||
|
matched := files.Match(r)
|
||||||
|
if len(matched) != 1 || matched[0].ID != rule || matched[0].Action == rules.ActionLog {
|
||||||
|
t.Errorf("%s from %q matched %v, want %s alone, refusing it", target,
|
||||||
|
sent, ids(matched), rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantMatched checks the ids of the rules r matches, in order.
|
||||||
|
func wantMatched(t *testing.T, files *rules.Files, r *http.Request, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got := ids(files.Match(r))
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("%s matched %v, want %v", r.URL, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ids returns the ids of matched.
|
||||||
|
func ids(matched []rules.Rule) []string {
|
||||||
|
got := make([]string, 0, len(matched))
|
||||||
|
for _, rule := range matched {
|
||||||
|
got = append(got, rule.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// processLog receives the lines of a process log, each a JSON object, for
|
||||||
|
// a test to wait for.
|
||||||
|
type processLog chan string
|
||||||
|
|
||||||
|
// Write receives a line of the process log.
|
||||||
|
func (l processLog) Write(line []byte) (int, error) {
|
||||||
|
l <- string(line)
|
||||||
|
|
||||||
|
return len(line), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitFor returns the next line of the process log whose message is msg,
|
||||||
|
// passing over the lines before it. It waits as long as that takes, so
|
||||||
|
// that a slow test process cannot fail the test.
|
||||||
|
func (l processLog) waitFor(t *testing.T, msg string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for line := range l {
|
||||||
|
var fields map[string]any
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(line), &fields)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("process log line %q is not JSON: %v", line, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields["msg"] == msg {
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitUntil waits for the rule files to be read until done reports true,
|
||||||
|
// as it does once they have been read after the test's last change. They
|
||||||
|
// can be read before then too, as they are once Watch starts watching.
|
||||||
|
func (l processLog) waitUntil(t *testing.T, done func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for !done() {
|
||||||
|
l.waitFor(t, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The tests below run readAfterChanges in a synctest bubble, where time is
|
||||||
|
// a clock of the test's own: time.Sleep moves it on at once, and
|
||||||
|
// synctest.Wait returns once readAfterChanges waits again, so that every
|
||||||
|
// reading due by then is done. The test sends the changes itself, as the
|
||||||
|
// watch of a directory cannot run in a bubble.
|
||||||
|
|
||||||
|
func TestFileWrittenInTwoPartsTakenInOnlyWhole(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "50-app.rules")
|
||||||
|
writeFile(t, path, "first path block ^/first\n")
|
||||||
|
files := load(t, dir)
|
||||||
|
changes := run(t, files)
|
||||||
|
|
||||||
|
file, err := os.Create(path) //nolint:gosec // a file the test wrote
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = file.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// The first part ends in the middle of a ban rule's regex, which,
|
||||||
|
// read then, would ban every request.
|
||||||
|
write(t, file, "first path block ^/first\nprobe path ban ^/")
|
||||||
|
|
||||||
|
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
||||||
|
|
||||||
|
time.Sleep(quietTime - time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantMatched(t, files, "/anything")
|
||||||
|
|
||||||
|
// The second part starts the wait again.
|
||||||
|
write(t, file, `\.env$`+"\n")
|
||||||
|
|
||||||
|
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
||||||
|
|
||||||
|
time.Sleep(quietTime - time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantMatched(t, files, "/.env")
|
||||||
|
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantMatched(t, files, "/.env", "probe")
|
||||||
|
wantMatched(t, files, "/anything")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditSavedBeforeTheWatchStartsTakenIn(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "50-app.rules")
|
||||||
|
writeFile(t, path, "first path block ^/first\n")
|
||||||
|
files := load(t, dir)
|
||||||
|
|
||||||
|
// Saved after Load read the files, and before the directory was
|
||||||
|
// watched, so that no change is seen for it.
|
||||||
|
writeFile(t, path, "first path block ^/edited\n")
|
||||||
|
run(t, files)
|
||||||
|
time.Sleep(quietTime)
|
||||||
|
synctest.Wait()
|
||||||
|
wantMatched(t, files, "/edited", "first")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// load loads the rules in dir.
|
||||||
|
func load(t *testing.T, dir string) *Files {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
files, err := Load(Params{
|
||||||
|
Dir: dir, Enabled: true, ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
|
Alerts: alerts.New(alerts.Params{}),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// run runs files' readAfterChanges until the test ends, and returns the
|
||||||
|
// channel that sends it changes.
|
||||||
|
func run(t *testing.T, files *Files) chan<- fsnotify.Event {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
changes := make(chan fsnotify.Event)
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
files.readAfterChanges(ctx, changes, nil)
|
||||||
|
close(stopped)
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
stop()
|
||||||
|
<-stopped
|
||||||
|
})
|
||||||
|
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFile writes content to the file at path.
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(content), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write writes text to the end of file.
|
||||||
|
func write(t *testing.T, file *os.File, text string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := file.WriteString(text)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantMatched checks the ids of the rules that a GET request for path
|
||||||
|
// matches, in order.
|
||||||
|
func wantMatched(t *testing.T, files *Files, path string, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"http://app.example"+path, nil)
|
||||||
|
|
||||||
|
matched := files.Match(r)
|
||||||
|
|
||||||
|
got := make([]string, 0, len(matched))
|
||||||
|
for _, rule := range matched {
|
||||||
|
got = append(got, rule.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("%s matched %v, want %v", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ var errHealthEndpoint = errors.New("smallwebwaf's health endpoint answered")
|
|||||||
// smallwebwaf answers its health endpoint on 127.0.0.1, at the port in
|
// smallwebwaf answers its health endpoint on 127.0.0.1, at the port in
|
||||||
// SWWAF_LISTEN_ADDR, and the app accepts connections at the address in
|
// SWWAF_LISTEN_ADDR, and the app accepts connections at the address in
|
||||||
// SWWAF_UPSTREAM_URL. Otherwise it writes why to stderr and returns 1.
|
// SWWAF_UPSTREAM_URL. Otherwise it writes why to stderr and returns 1.
|
||||||
|
// It reads no other setting, nor a file that another names, so neither
|
||||||
|
// can fail it.
|
||||||
// args are the arguments after `healthcheck`; it takes none, and given
|
// args are the arguments after `healthcheck`; it takes none, and given
|
||||||
// one it names it on stderr and returns 1 without checking anything.
|
// one it names it on stderr and returns 1 without checking anything.
|
||||||
func HealthCheck(
|
func HealthCheck(
|
||||||
@@ -50,13 +52,13 @@ func healthCheck(ctx context.Context, lookupEnv func(string) (string, bool)) err
|
|||||||
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
|
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(lookupEnv)
|
listenAddr, upstreamURL, err := config.ListenAddrAndUpstreamURL(lookupEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid setting: %w", err)
|
return fmt.Errorf("invalid setting: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The settings have checked that the address has a port.
|
// The settings have checked that the address has a port.
|
||||||
_, port, _ := net.SplitHostPort(cfg.ListenAddr)
|
_, port, _ := net.SplitHostPort(listenAddr)
|
||||||
health := "http://" + net.JoinHostPort("127.0.0.1", port) + proxy.HealthPath
|
health := "http://" + net.JoinHostPort("127.0.0.1", port) + proxy.HealthPath
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, health, http.NoBody)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, health, http.NoBody)
|
||||||
@@ -75,7 +77,7 @@ func healthCheck(ctx context.Context, lookupEnv func(string) (string, bool)) err
|
|||||||
return fmt.Errorf("%w %s", errHealthEndpoint, res.Status)
|
return fmt.Errorf("%w %s", errHealthEndpoint, res.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", appAddress(cfg.UpstreamURL))
|
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", appAddress(upstreamURL))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to the app: %w", err)
|
return fmt.Errorf("connect to the app: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -28,6 +30,7 @@ func TestHealthCheck(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
upstreamURL: app.URL,
|
upstreamURL: app.URL,
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
|
rulesDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -42,6 +45,21 @@ func TestHealthCheck(t *testing.T) {
|
|||||||
|
|
||||||
wantHealthCheck(t, env, 0, "")
|
wantHealthCheck(t, env, 0, "")
|
||||||
|
|
||||||
|
// The health check reads those two settings alone, here given as
|
||||||
|
// files: a removed or invalid token file, or an invalid value of
|
||||||
|
// another setting, does not fail it.
|
||||||
|
for _, other := range []struct{ name, value string }{
|
||||||
|
{"SWWAF_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")},
|
||||||
|
{"SWWAF_METRICS_TOKEN_FILE", writeFile(t, "too short\n")},
|
||||||
|
{"SWWAF_MODE", "neither"},
|
||||||
|
} {
|
||||||
|
wantHealthCheck(t, map[string]string{
|
||||||
|
listenAddr + "_FILE": writeFile(t, ":"+port+"\n"),
|
||||||
|
upstreamURL + "_FILE": writeFile(t, app.URL+"\n"),
|
||||||
|
other.name: other.value,
|
||||||
|
}, 0, "")
|
||||||
|
}
|
||||||
|
|
||||||
app.Close()
|
app.Close()
|
||||||
wantHealthCheck(t, env, 1, "unhealthy: connect to the app: ")
|
wantHealthCheck(t, env, 1, "unhealthy: connect to the app: ")
|
||||||
|
|
||||||
@@ -97,3 +115,18 @@ func wantHealthCheck(t *testing.T, env map[string]string, status int, message st
|
|||||||
got, wrote, status, message)
|
got, wrote, status, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeFile writes contents to a file in a directory of its own, removed
|
||||||
|
// when the test ends, and returns the file's path.
|
||||||
|
func writeFile(t *testing.T, contents string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "setting")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(contents), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Package smallwebwaf runs the smallwebwaf process: it reads the settings
|
// Package smallwebwaf runs the smallwebwaf process: it reads the settings,
|
||||||
// and the state files, serves requests until it is told to stop, and then
|
// the rule files, the lookup database and the state files, serves requests
|
||||||
// stops in an orderly way, writing the state files.
|
// until it is told to stop, and then stops in an orderly way, writing the
|
||||||
|
// state files.
|
||||||
package smallwebwaf
|
package smallwebwaf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,10 +16,14 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,6 +32,11 @@ import (
|
|||||||
// runit and docker wait a little longer before they kill the process.
|
// runit and docker wait a little longer before they kill the process.
|
||||||
const shutdownTimeout = 5 * time.Second
|
const shutdownTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// remoteLogStopTimeout is how long, as smallwebwaf stops, the log lines
|
||||||
|
// still waiting are sent to SWWAF_LOG_REMOTE_URL before they are given
|
||||||
|
// up. stdout has carried them.
|
||||||
|
const remoteLogStopTimeout = 2 * time.Second
|
||||||
|
|
||||||
// Params are what Run needs from the process.
|
// Params are what Run needs from the process.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// Version is the version of the binary, set when it is built.
|
// Version is the version of the binary, set when it is built.
|
||||||
@@ -56,11 +66,12 @@ func Main(version string) int {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run reads the settings and the state files, then serves requests until
|
// Run reads the settings, the rule files, the lookup database and the
|
||||||
// ctx is done. It returns the process's exit status, 1 when smallwebwaf
|
// state files, then serves requests until ctx is done. It returns the
|
||||||
// cannot start.
|
// process's exit status, 1 when smallwebwaf cannot start.
|
||||||
func Run(ctx context.Context, params Params) int {
|
func Run(ctx context.Context, params Params) int {
|
||||||
processLog := requestlog.NewProcessLogger(params.Stdout)
|
processLog := requestlog.NewProcessLogger(params.Stdout,
|
||||||
|
config.InstanceName(params.LookupEnv))
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(params.LookupEnv)
|
cfg, err := config.FromEnvironment(params.LookupEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,28 +80,50 @@ func Run(ctx context.Context, params Params) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// The state files give times in UTC.
|
// While SWWAF_LOG_REMOTE_URL is set, every line on stdout from here on
|
||||||
|
// is sent there too.
|
||||||
|
stdout := params.Stdout
|
||||||
|
|
||||||
|
var remote *remotelog.Sender
|
||||||
|
|
||||||
|
if cfg.LogRemoteURL != nil {
|
||||||
|
remote = newRemoteLogSender(cfg)
|
||||||
|
stdout = io.MultiWriter(params.Stdout, remote)
|
||||||
|
processLog = requestlog.NewProcessLogger(stdout, cfg.InstanceName)
|
||||||
|
|
||||||
|
stopSending := startSending(ctx, remote, processLog)
|
||||||
|
defer stopSending()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The state files and the alerts give times in UTC.
|
||||||
now := func() time.Time { return time.Now().UTC() }
|
now := func() time.Time { return time.Now().UTC() }
|
||||||
|
|
||||||
server := proxy.New(proxy.Params{
|
alertQueue := newAlertQueue(cfg, now, processLog)
|
||||||
Config: cfg,
|
|
||||||
RequestLog: params.Stdout,
|
|
||||||
ProcessLog: processLog,
|
|
||||||
GeoJSURL: lookup.URL,
|
|
||||||
Now: now,
|
|
||||||
})
|
|
||||||
|
|
||||||
files, err := state.Load(state.Params{
|
ruleFiles, err := rules.Load(rules.Params{
|
||||||
Dir: cfg.StateDir,
|
Dir: cfg.RulesDir,
|
||||||
WriteDelay: cfg.StateWriteDelay,
|
Enabled: cfg.RulesEnabled,
|
||||||
CounterInterval: cfg.StateCounterInterval,
|
|
||||||
Ledger: server.Ledger,
|
|
||||||
Limiter: server.Limiter,
|
|
||||||
GeoJS: server.GeoJS,
|
|
||||||
Now: now,
|
|
||||||
ProcessLog: processLog,
|
ProcessLog: processLog,
|
||||||
Metrics: server.Metrics,
|
Alerts: alertQueue,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
processLog.Error("cannot use the rule files", "error", err.Error())
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := newServer(cfg, stdout, processLog, now, ruleFiles, alertQueue)
|
||||||
|
if err != nil {
|
||||||
|
processLog.Error("cannot use the lookup database", "error", err.Error())
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if remote != nil {
|
||||||
|
server.Metrics.AddRemoteLog(remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := loadStateFiles(cfg, server, alertQueue, now, processLog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
processLog.Error("cannot use the state files", "error", err.Error())
|
processLog.Error("cannot use the state files", "error", err.Error())
|
||||||
|
|
||||||
@@ -110,15 +143,144 @@ func Run(ctx context.Context, params Params) int {
|
|||||||
"address", listener.Addr().String(),
|
"address", listener.Addr().String(),
|
||||||
"settings", cfg)
|
"settings", cfg)
|
||||||
|
|
||||||
return serve(ctx, server.Server, listener, files, processLog)
|
return serve(ctx, server, listener, files, ruleFiles, alertQueue, processLog)
|
||||||
}
|
}
|
||||||
|
|
||||||
// serve serves requests on listener, and writes the state files as they
|
// newServer returns the server smallwebwaf runs, with the metrics of the
|
||||||
// are due, until ctx is done. Then it gives the requests in progress
|
// alerts, after reading the lookup database while SWWAF_LOOKUP_SOURCE is
|
||||||
// shutdownTimeout to finish, and writes every state file.
|
// file. A lookup database that cannot be read is an error.
|
||||||
|
func newServer(
|
||||||
|
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
|
||||||
|
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
||||||
|
) (*proxy.Server, error) {
|
||||||
|
var lookupFile *lookup.File
|
||||||
|
|
||||||
|
if cfg.LookupSource == "file" {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
lookupFile, err = lookup.OpenFile(lookup.FileParams{
|
||||||
|
Path: cfg.LookupDBPath,
|
||||||
|
Now: now,
|
||||||
|
ProcessLog: processLog,
|
||||||
|
Alerts: alertQueue,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server := proxy.New(proxy.Params{
|
||||||
|
Config: cfg,
|
||||||
|
RequestLog: stdout,
|
||||||
|
ProcessLog: processLog,
|
||||||
|
GeoJSURL: lookup.URL,
|
||||||
|
AbuseIPDBURL: reputation.AbuseIPDBURL,
|
||||||
|
LookupFile: lookupFile,
|
||||||
|
Now: now,
|
||||||
|
Rules: ruleFiles,
|
||||||
|
Alerts: alertQueue,
|
||||||
|
})
|
||||||
|
server.Metrics.AddAlerts(alertQueue)
|
||||||
|
|
||||||
|
if lookupFile != nil {
|
||||||
|
server.Metrics.AddLookupFile(lookupFile.LastRead, lookupFile.ReadFailures)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadStateFiles reads the state files into the parts of server and into
|
||||||
|
// alertQueue, as state.Load does.
|
||||||
|
func loadStateFiles(
|
||||||
|
cfg *config.Config, server *proxy.Server, alertQueue *alerts.Queue,
|
||||||
|
now func() time.Time, processLog *slog.Logger,
|
||||||
|
) (*state.Files, error) {
|
||||||
|
return state.Load(state.Params{
|
||||||
|
Dir: cfg.StateDir,
|
||||||
|
WriteDelay: cfg.StateWriteDelay,
|
||||||
|
CounterInterval: cfg.StateCounterInterval,
|
||||||
|
Ledger: server.Ledger,
|
||||||
|
Limiter: server.Limiter,
|
||||||
|
GeoJS: server.GeoJS,
|
||||||
|
Lists: server.Lists,
|
||||||
|
DNSBL: server.DNSBL,
|
||||||
|
AbuseIPDB: server.AbuseIPDB,
|
||||||
|
Alerts: alertQueue,
|
||||||
|
Anomalies: server.Anomalies,
|
||||||
|
Now: now,
|
||||||
|
ProcessLog: processLog,
|
||||||
|
Metrics: server.Metrics,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAlertQueue returns the queue of the alerts to the webhook, Slack and
|
||||||
|
// ntfy, with the settings for them.
|
||||||
|
func newAlertQueue(
|
||||||
|
cfg *config.Config, now func() time.Time, processLog *slog.Logger,
|
||||||
|
) *alerts.Queue {
|
||||||
|
return alerts.New(alerts.Params{
|
||||||
|
WebhookURL: cfg.AlertWebhookURL,
|
||||||
|
WebhookHeaders: cfg.AlertWebhookHeaders,
|
||||||
|
SlackURL: cfg.AlertSlackWebhookURL,
|
||||||
|
NtfyURL: cfg.AlertNtfyURL,
|
||||||
|
NtfyToken: cfg.AlertNtfyToken,
|
||||||
|
Events: cfg.AlertEvents,
|
||||||
|
Cooldown: cfg.AlertCooldown,
|
||||||
|
MaxPerHour: cfg.AlertMaxPerHour,
|
||||||
|
Instance: cfg.InstanceName,
|
||||||
|
Now: now,
|
||||||
|
ProcessLog: processLog,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRemoteLogSender returns a sender of the log lines to
|
||||||
|
// SWWAF_LOG_REMOTE_URL, with the settings for it.
|
||||||
|
func newRemoteLogSender(cfg *config.Config) *remotelog.Sender {
|
||||||
|
return remotelog.New(remotelog.Params{
|
||||||
|
URL: cfg.LogRemoteURL,
|
||||||
|
RootCAs: cfg.LogRemoteTLSCAs,
|
||||||
|
Buffer: cfg.LogRemoteBuffer,
|
||||||
|
Facility: cfg.LogRemoteFacility,
|
||||||
|
AppName: cfg.LogRemoteAppName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSending runs remote until the function it returns is called, which
|
||||||
|
// then waits at most remoteLogStopTimeout for the lines still waiting to
|
||||||
|
// be sent. Sending goes on after ctx is done, so that the lines written
|
||||||
|
// while smallwebwaf stops are sent too.
|
||||||
|
func startSending(
|
||||||
|
ctx context.Context, remote *remotelog.Sender, processLog *slog.Logger,
|
||||||
|
) func() {
|
||||||
|
sending, stop := context.WithCancel(context.WithoutCancel(ctx))
|
||||||
|
sent := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
remote.Run(sending, processLog)
|
||||||
|
close(sent)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-sent:
|
||||||
|
case <-time.After(remoteLogStopTimeout):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve serves requests on listener, writes the state files as they are
|
||||||
|
// due, takes in an admin's edits of them, reads the rule files again as
|
||||||
|
// they change, and the lookup database when it is replaced, fetches the
|
||||||
|
// lists the settings name by URL as they are due, and sends the alerts,
|
||||||
|
// until ctx is done. Then it gives the requests in progress
|
||||||
|
// shutdownTimeout to finish, and writes every state file, alerts.json with
|
||||||
|
// the alerts still waiting.
|
||||||
func serve(
|
func serve(
|
||||||
ctx context.Context, server *http.Server, listener net.Listener,
|
ctx context.Context, server *proxy.Server, listener net.Listener,
|
||||||
files *state.Files, processLog *slog.Logger,
|
files *state.Files, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
||||||
|
processLog *slog.Logger,
|
||||||
) int {
|
) int {
|
||||||
served := make(chan error, 1)
|
served := make(chan error, 1)
|
||||||
|
|
||||||
@@ -129,12 +291,16 @@ func serve(
|
|||||||
writing, stopWriting := context.WithCancel(ctx)
|
writing, stopWriting := context.WithCancel(ctx)
|
||||||
defer stopWriting()
|
defer stopWriting()
|
||||||
|
|
||||||
written := make(chan struct{})
|
written := inBackground(func() { files.Run(writing) })
|
||||||
|
watched := inBackground(func() { files.Watch(writing) })
|
||||||
go func() {
|
rulesWatched := inBackground(func() { ruleFiles.Watch(writing) })
|
||||||
files.Run(writing)
|
lookupFileWatched := inBackground(func() {
|
||||||
close(written)
|
if server.LookupFile != nil {
|
||||||
}()
|
server.LookupFile.Watch(writing)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
listsFetched := inBackground(func() { server.Lists.Run(writing) })
|
||||||
|
alertsSent := inBackground(func() { alertQueue.Run(writing) })
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case err := <-served:
|
case err := <-served:
|
||||||
@@ -165,13 +331,20 @@ func serve(
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run's last write has ended, so nothing else writes the files. Every
|
// Run and Watch have ended, so nothing else reads or writes the
|
||||||
// request has ended too, but for two kinds that Go's server does not
|
// files, and no alert is being sent, so that alerts.json keeps every
|
||||||
// wait for: one cut off because Shutdown timed out, and one whose
|
// alert not yet sent. Every request has ended too, but for two kinds
|
||||||
// connection switched protocols, such as a WebSocket. Such a request
|
// that Go's server does not wait for: one cut off because Shutdown
|
||||||
// adds to its client's history only as it ends, which can be after
|
// timed out, and one whose connection switched protocols, such as a
|
||||||
// this write, and then that request is missing from clients.json.
|
// WebSocket. Such a request adds to its client's history only as it
|
||||||
|
// ends, which can be after this write, and then that request is
|
||||||
|
// missing from clients.json.
|
||||||
<-written
|
<-written
|
||||||
|
<-watched
|
||||||
|
<-rulesWatched
|
||||||
|
<-lookupFileWatched
|
||||||
|
<-listsFetched
|
||||||
|
<-alertsSent
|
||||||
|
|
||||||
err = files.WriteAll()
|
err = files.WriteAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -184,3 +357,16 @@ func serve(
|
|||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inBackground runs task on a goroutine of its own, and returns a channel
|
||||||
|
// that is closed once task has returned.
|
||||||
|
func inBackground(task func()) <-chan struct{} {
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
task()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package smallwebwaf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The stop's tests run in a synctest bubble, where the time package runs
|
||||||
|
// on a clock of the test's own, so that how long the stop takes can be
|
||||||
|
// told exactly. The sender is held up by its process log, not by the
|
||||||
|
// network: a goroutine of the bubble that waits on the network keeps that
|
||||||
|
// clock from moving on.
|
||||||
|
|
||||||
|
func TestStopWaitsForTheSenderToFinish(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
took := stopHeldSender(t, time.Second)
|
||||||
|
if took != time.Second {
|
||||||
|
t.Errorf("the stop took %s, want the second the sender took", took)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopWaitsForTheSenderAtMostTwoSeconds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
took := stopHeldSender(t, time.Minute)
|
||||||
|
if took != 2*time.Second {
|
||||||
|
t.Errorf("the stop took %s, want 2s", took)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldLog holds each line written to it until it is closed.
|
||||||
|
type heldLog chan struct{}
|
||||||
|
|
||||||
|
// Write waits until the log is closed.
|
||||||
|
func (l heldLog) Write(p []byte) (int, error) {
|
||||||
|
<-l
|
||||||
|
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopHeldSender starts sending to an endpoint the sender cannot connect
|
||||||
|
// to, holds the sender as it logs that failure until release has passed,
|
||||||
|
// stops the sending, and returns how long the stop took. It returns once
|
||||||
|
// the sender has ended, as a bubble must.
|
||||||
|
func stopHeldSender(t *testing.T, release time.Duration) time.Duration {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
log := make(heldLog)
|
||||||
|
sender := remotelog.New(remotelog.Params{
|
||||||
|
// No port is 65536, so each attempt to connect fails at once,
|
||||||
|
// before it reaches the network.
|
||||||
|
URL: &url.URL{Scheme: remotelog.SchemeTCP, Host: "127.0.0.1:65536"},
|
||||||
|
Buffer: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
stopSending := startSending(t.Context(), sender,
|
||||||
|
slog.New(slog.NewJSONHandler(log, nil)))
|
||||||
|
|
||||||
|
synctest.Wait()
|
||||||
|
time.AfterFunc(release, func() { close(log) })
|
||||||
|
|
||||||
|
stopped := time.Now()
|
||||||
|
|
||||||
|
stopSending()
|
||||||
|
|
||||||
|
took := time.Since(stopped)
|
||||||
|
|
||||||
|
time.Sleep(release)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
return took
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+642
-95
@@ -1,28 +1,43 @@
|
|||||||
// Package state keeps smallwebwaf's state in JSON files in
|
// Package state keeps smallwebwaf's state in JSON files in
|
||||||
// SWWAF_STATE_DIR, as the "Persistent state" section of SPEC.md describes:
|
// SWWAF_STATE_DIR, as the "Persistent state" section of SPEC.md describes:
|
||||||
// bans.json holds the bans, clients.json each client's counters and
|
// bans.json holds the bans, clients.json each client's counters and
|
||||||
// history, and lookups.json GeoJS's answers. Load reads them at start, and
|
// history, lookups.json GeoJS's answers, reputation.json the last try and
|
||||||
// Run and WriteAll write them, each from a snapshot its part takes under
|
// last good copy of each list fetched from a URL, the DNSBL zones'
|
||||||
// its own lock, so that no request waits on the disk.
|
// verdicts, and AbuseIPDB's scores and checks spent, and alerts.json the
|
||||||
|
// cooldowns, the hour under way, the alerts waiting for each destination
|
||||||
|
// and the anomaly counters. Load
|
||||||
|
// reads them at start, Watch takes in an admin's edit of one while
|
||||||
|
// smallwebwaf runs, and Run and WriteAll write them. The disk is read and
|
||||||
|
// written outside the parts' locks, which are held only to take a
|
||||||
|
// snapshot or to put in what a file holds, so that no request waits on
|
||||||
|
// the disk.
|
||||||
package state
|
package state
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
)
|
)
|
||||||
|
|
||||||
// version is the version of the files' format, the only one read.
|
// version is the version of the files' format, the only one read.
|
||||||
@@ -37,12 +52,20 @@ const (
|
|||||||
bansJSON = "bans.json"
|
bansJSON = "bans.json"
|
||||||
clientsJSON = "clients.json"
|
clientsJSON = "clients.json"
|
||||||
lookupsJSON = "lookups.json"
|
lookupsJSON = "lookups.json"
|
||||||
|
reputationJSON = "reputation.json"
|
||||||
|
alertsJSON = "alerts.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errVersion = errors.New("unknown version")
|
errVersion = errors.New("unknown version")
|
||||||
// errMissing is for an entry without a field it needs.
|
// errMissing is for an entry without a field it needs.
|
||||||
errMissing = errors.New("has no")
|
errMissing = errors.New("has no")
|
||||||
|
errCause = errors.New("is not limit, attack or admin")
|
||||||
|
errDestination = errors.New("is not webhook, slack or ntfy")
|
||||||
|
errScope = errors.New("is not client, net, asn, total or watch")
|
||||||
|
errWaitingList = errors.New(`waiting is a list, but now lists the alerts by ` +
|
||||||
|
`destination: put the list under "webhook", as "waiting": {"webhook": [...]}, ` +
|
||||||
|
`or remove the file`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params are what Load needs.
|
// Params are what Load needs.
|
||||||
@@ -54,36 +77,59 @@ type Params struct {
|
|||||||
// is (SWWAF_STATE_COUNTER_INTERVAL).
|
// is (SWWAF_STATE_COUNTER_INTERVAL).
|
||||||
WriteDelay time.Duration
|
WriteDelay time.Duration
|
||||||
CounterInterval time.Duration
|
CounterInterval time.Duration
|
||||||
// Ledger, Limiter and GeoJS hold the state.
|
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies
|
||||||
|
// hold the state. Alerts also receive a file_error alert for an edit set
|
||||||
|
// aside, and for a write that fails while smallwebwaf runs.
|
||||||
Ledger *bans.Ledger
|
Ledger *bans.Ledger
|
||||||
Limiter *ratelimit.Limiter
|
Limiter *ratelimit.Limiter
|
||||||
GeoJS *lookup.GeoJS
|
GeoJS *lookup.GeoJS
|
||||||
|
Lists *reputation.Lists
|
||||||
|
DNSBL *reputation.DNSBL
|
||||||
|
AbuseIPDB *reputation.AbuseIPDB
|
||||||
|
Alerts *alerts.Queue
|
||||||
|
Anomalies *anomaly.Counters
|
||||||
// Now tells the time by which the counters' buckets run out, normally
|
// Now tells the time by which the counters' buckets run out, normally
|
||||||
// time.Now in UTC.
|
// time.Now in UTC.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
// ProcessLog receives what was read, and the writes that fail.
|
// ProcessLog receives what was read and taken in, the edits set aside,
|
||||||
|
// and the writes that fail.
|
||||||
ProcessLog *slog.Logger
|
ProcessLog *slog.Logger
|
||||||
// Metrics count each file's writes.
|
// Metrics count each file's writes, and the edits taken in and set
|
||||||
|
// aside.
|
||||||
Metrics *metrics.Metrics
|
Metrics *metrics.Metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files are the state files of a running smallwebwaf.
|
// Files are the state files of a running smallwebwaf.
|
||||||
type Files struct {
|
type Files struct {
|
||||||
params Params
|
params Params
|
||||||
|
|
||||||
|
// mu is held while a file is read for an edit, and while it is
|
||||||
|
// written, so that Watch and the writes take turns. No request takes
|
||||||
|
// it.
|
||||||
|
mu sync.Mutex
|
||||||
|
// sums are the SHA-256 sums of what each file held, by name, when
|
||||||
|
// smallwebwaf last read or wrote it. A file that holds anything else
|
||||||
|
// has been edited since.
|
||||||
|
sums map[string][sha256.Size]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// bansFile is bans.json, indented for an admin to read and edit.
|
// bansFile is bans.json, indented for an admin to read and edit.
|
||||||
type bansFile struct {
|
type bansFile struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Bans []banEntry `json:"bans"`
|
Bans []BanEntry `json:"bans"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// banEntry is a ban as bans.json holds it: a permanent ban's expires is
|
// BanEntry is a ban as bans.json holds it: a permanent ban's expires is
|
||||||
// null.
|
// null, a ban an admin added may have no cause, which makes it an
|
||||||
type banEntry struct {
|
// admin's, and lifted is left out until an admin lifts the ban. The ban
|
||||||
|
// endpoints answer with bans in this form too.
|
||||||
|
type BanEntry struct {
|
||||||
Netblock netip.Prefix `json:"netblock"`
|
Netblock netip.Prefix `json:"netblock"`
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
Expires *time.Time `json:"expires"`
|
Expires *time.Time `json:"expires"`
|
||||||
|
Cause string `json:"cause"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Lifted *time.Time `json:"lifted,omitempty"`
|
||||||
Notes bans.Notes `json:"notes"`
|
Notes bans.Notes `json:"notes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +145,26 @@ type lookupsFile struct {
|
|||||||
Lookups []lookup.Answer `json:"lookups"`
|
Lookups []lookup.Answer `json:"lookups"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reputationFile is reputation.json, indented for an admin to read and
|
||||||
|
// edit, so that each line of a list's copy is on a line of its own.
|
||||||
|
type reputationFile struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Lists []reputation.List `json:"lists"`
|
||||||
|
Verdicts []reputation.Verdict `json:"verdicts"`
|
||||||
|
AbuseIPDB reputation.Checks `json:"abuseipdb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// alertsFile is alerts.json, indented for an admin to read and edit.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||||
|
type alertsFile struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Cooldowns []alerts.Cooldown `json:"cooldowns"`
|
||||||
|
Hour alerts.Hour `json:"hour"`
|
||||||
|
Waiting map[string][]alerts.Alert `json:"waiting"`
|
||||||
|
AnomalyCounters []anomaly.Counter `json:"anomaly_counters"`
|
||||||
|
}
|
||||||
|
|
||||||
// stateFile is the struct of a state file. Once the file is decoded, its
|
// stateFile is the struct of a state file. Once the file is decoded, its
|
||||||
// check refuses the first entry without a field it needs, which would
|
// check refuses the first entry without a field it needs, which would
|
||||||
// otherwise be read as something the entry does not say. data is the
|
// otherwise be read as something the entry does not say. data is the
|
||||||
@@ -109,10 +175,10 @@ type stateFile interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load checks that files can be written in Dir, and reads the state files
|
// Load checks that files can be written in Dir, and reads the state files
|
||||||
// in it into the ledger, the limiter and GeoJS. A missing file is empty
|
// in it into the parts of Params that hold the state. A missing file is
|
||||||
// state, as on a first start. A file that does not parse, has an unknown
|
// empty state, as on a first start. A file that does not parse, has an
|
||||||
// version, or has an entry without a field it needs, is an error that
|
// unknown version, or has an entry without a field it needs, is an error
|
||||||
// names the file and, where the JSON decoder tells it, the line and
|
// that names the file and, where the JSON decoder tells it, the line and
|
||||||
// column, or else the entry.
|
// column, or else the entry.
|
||||||
func Load(params Params) (*Files, error) {
|
func Load(params Params) (*Files, error) {
|
||||||
err := checkWritable(params.Dir)
|
err := checkWritable(params.Dir)
|
||||||
@@ -120,41 +186,31 @@ func Load(params Params) (*Files, error) {
|
|||||||
return nil, fmt.Errorf("SWWAF_STATE_DIR cannot be written: %w", err)
|
return nil, fmt.Errorf("SWWAF_STATE_DIR cannot be written: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
f := &Files{params: params, sums: map[string][sha256.Size]byte{}}
|
||||||
bansIn bansFile
|
|
||||||
clientsIn clientsFile
|
|
||||||
lookupsIn lookupsFile
|
|
||||||
)
|
|
||||||
|
|
||||||
err = errors.Join(
|
bansRead, bansErr := f.read(bansJSON)
|
||||||
read(params.Dir, bansJSON, &bansIn),
|
clientsRead, clientsErr := f.read(clientsJSON)
|
||||||
read(params.Dir, clientsJSON, &clientsIn),
|
lookupsRead, lookupsErr := f.read(lookupsJSON)
|
||||||
read(params.Dir, lookupsJSON, &lookupsIn),
|
reputationRead, reputationErr := f.read(reputationJSON)
|
||||||
)
|
alertsRead, alertsErr := f.read(alertsJSON)
|
||||||
|
|
||||||
|
err = errors.Join(bansErr, clientsErr, lookupsErr, reputationErr, alertsErr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
held := make([]bans.Ban, 0, len(bansIn.Bans))
|
|
||||||
for _, entry := range bansIn.Bans {
|
|
||||||
held = append(held, entry.ban())
|
|
||||||
}
|
|
||||||
|
|
||||||
params.Ledger.Load(held)
|
|
||||||
params.Limiter.Load(clientsIn.Clients, params.Now())
|
|
||||||
params.GeoJS.Load(lookupsIn.Lookups)
|
|
||||||
|
|
||||||
params.ProcessLog.Info("read the state files", "directory", params.Dir,
|
params.ProcessLog.Info("read the state files", "directory", params.Dir,
|
||||||
"bans", len(bansIn.Bans), "clients", len(clientsIn.Clients),
|
"bans", bansRead, "clients", clientsRead, "lookups", lookupsRead,
|
||||||
"lookups", len(lookupsIn.Lookups))
|
"lists", reputationRead, "alerts_waiting", alertsRead)
|
||||||
|
|
||||||
return &Files{params: params}, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run writes bans.json WriteDelay after a ban is made, with every ban
|
// Run writes bans.json WriteDelay after a ban is made, with every ban
|
||||||
// made in between, and every file every CounterInterval, until ctx is
|
// made in between, and every file every CounterInterval, until ctx is
|
||||||
// done. A write that fails is logged, and the file is written again at
|
// done. A write that fails is logged, raised as a file_error alert, and
|
||||||
// its next write.
|
// the file is written again at its next write. Each write takes in an
|
||||||
|
// admin's edit of its file first, as writeFile describes.
|
||||||
func (f *Files) Run(ctx context.Context) {
|
func (f *Files) Run(ctx context.Context) {
|
||||||
interval := time.NewTicker(f.params.CounterInterval)
|
interval := time.NewTicker(f.params.CounterInterval)
|
||||||
defer interval.Stop()
|
defer interval.Stop()
|
||||||
@@ -172,9 +228,13 @@ func (f *Files) Run(ctx context.Context) {
|
|||||||
case <-bansDue:
|
case <-bansDue:
|
||||||
bansDue = nil
|
bansDue = nil
|
||||||
|
|
||||||
f.logFailure(f.writeBans())
|
f.logFailure(bansJSON, f.writeFile(bansJSON))
|
||||||
case <-interval.C:
|
case <-interval.C:
|
||||||
f.logFailure(f.WriteAll())
|
for _, name := range []string{
|
||||||
|
bansJSON, clientsJSON, lookupsJSON, reputationJSON, alertsJSON,
|
||||||
|
} {
|
||||||
|
f.logFailure(name, f.writeFile(name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,80 +242,403 @@ func (f *Files) Run(ctx context.Context) {
|
|||||||
// WriteAll writes every state file, as smallwebwaf stops. A file that
|
// WriteAll writes every state file, as smallwebwaf stops. A file that
|
||||||
// fails does not keep the others from being written.
|
// fails does not keep the others from being written.
|
||||||
func (f *Files) WriteAll() error {
|
func (f *Files) WriteAll() error {
|
||||||
return errors.Join(f.writeBans(), f.writeClients(), f.writeLookups())
|
return errors.Join(f.writeFile(bansJSON), f.writeFile(clientsJSON),
|
||||||
|
f.writeFile(lookupsJSON), f.writeFile(reputationJSON), f.writeFile(alertsJSON))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch watches Dir until ctx is done, and takes in an admin's edit of a
|
||||||
|
// state file as soon as it is saved: what the file holds replaces what
|
||||||
|
// smallwebwaf held for it. An edit that does not parse is left for the
|
||||||
|
// file's next write, which sets it aside, since a file can be read while
|
||||||
|
// an editor is still writing it. If Dir cannot be watched, that is
|
||||||
|
// logged, and an edit is taken in only before its file is written.
|
||||||
|
func (f *Files) Watch(ctx context.Context) {
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err == nil {
|
||||||
|
defer func() {
|
||||||
|
_ = watcher.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
err = watcher.Add(f.params.Dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// logFailure logs a write that failed.
|
|
||||||
func (f *Files) logFailure(err error) {
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.params.ProcessLog.Error("writing the state files failed",
|
f.params.ProcessLog.Error("cannot watch the state files for edits",
|
||||||
|
"error", err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f.params.ProcessLog.Info("watching the state files for edits",
|
||||||
|
"directory", f.params.Dir)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case event := <-watcher.Events:
|
||||||
|
switch name := filepath.Base(event.Name); name {
|
||||||
|
case bansJSON, clientsJSON, lookupsJSON, reputationJSON, alertsJSON:
|
||||||
|
f.fileChanged(name)
|
||||||
|
}
|
||||||
|
case err = <-watcher.Errors:
|
||||||
|
f.params.ProcessLog.Warn("watching the state files failed",
|
||||||
"error", err.Error())
|
"error", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeBans writes bans.json.
|
|
||||||
func (f *Files) writeBans() error {
|
|
||||||
held := f.params.Ledger.Snapshot()
|
|
||||||
|
|
||||||
file := bansFile{Version: version, Bans: make([]banEntry, 0, len(held))}
|
|
||||||
for _, ban := range held {
|
|
||||||
file.Bans = append(file.Bans, newBanEntry(ban))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.MarshalIndent(file, "", " ")
|
// logFailure logs a write of the state file name that failed, and raises
|
||||||
|
// a file_error alert for it.
|
||||||
|
func (f *Files) logFailure(name string, err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encode %s: %w", bansJSON, err)
|
const failed = "writing the state files failed"
|
||||||
|
|
||||||
|
// Raised before it is logged, so that the alert is there once the
|
||||||
|
// log line is.
|
||||||
|
f.params.Alerts.Raise(alerts.Alert{
|
||||||
|
Event: alerts.EventFileError,
|
||||||
|
Reason: failed,
|
||||||
|
Detail: map[string]any{
|
||||||
|
"file": filepath.Join(f.params.Dir, name), "error": err.Error(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
f.params.ProcessLog.Error(failed, "error", err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.writeCounted(bansJSON, append(data, '\n'))
|
// fileChanged takes in what the state file name holds, as Watch sees it
|
||||||
|
// change, if that is an edit made since smallwebwaf last read or wrote
|
||||||
|
// the file. A file that cannot be read or does not parse is left for its
|
||||||
|
// next write.
|
||||||
|
func (f *Files) fileChanged(name string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
data, changed, err := f.readChanged(name)
|
||||||
|
if err != nil || !changed {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeClients writes clients.json.
|
_ = f.takeInEdit(name, data)
|
||||||
func (f *Files) writeClients() error {
|
}
|
||||||
data, err := encodeOnePerLine("clients", f.params.Limiter.Snapshot())
|
|
||||||
|
// takeInEdit takes in data, an edit of the state file name, as takeIn
|
||||||
|
// does, and counts and logs it. Every edit taken in while smallwebwaf
|
||||||
|
// runs, by Watch or by a write, is taken in here. An edit that does not
|
||||||
|
// parse is neither counted nor logged, and takeIn's error returned.
|
||||||
|
func (f *Files) takeInEdit(name string, data []byte) error {
|
||||||
|
_, err := f.takeIn(name, data, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encode %s: %w", clientsJSON, err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.writeCounted(clientsJSON, data)
|
// Counted before it is logged, so that the count is there once the
|
||||||
|
// log line is.
|
||||||
|
f.params.Metrics.StateFileEditTakenIn(name)
|
||||||
|
f.params.ProcessLog.Info("took in an edit of a state file",
|
||||||
|
"file", filepath.Join(f.params.Dir, name))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// read takes in the state file name at start, and returns how many
|
||||||
|
// entries it holds. A missing file holds none.
|
||||||
|
func (f *Files) read(name string) (int, error) {
|
||||||
|
data, changed, err := f.readChanged(name)
|
||||||
|
if err != nil || !changed {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.takeIn(name, data, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readChanged returns what the state file name holds, and whether that
|
||||||
|
// has changed since smallwebwaf last read or wrote the file, as it has
|
||||||
|
// for a file smallwebwaf never read or wrote. A missing file has not
|
||||||
|
// changed: it is written again at its next write.
|
||||||
|
func (f *Files) readChanged(name string) ([]byte, bool, error) {
|
||||||
|
path := filepath.Join(f.params.Dir, name)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWWAF_STATE_DIR
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeLookups writes lookups.json.
|
|
||||||
func (f *Files) writeLookups() error {
|
|
||||||
data, err := encodeOnePerLine("lookups", f.params.GeoJS.Snapshot())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encode %s: %w", lookupsJSON, err)
|
return nil, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.writeCounted(lookupsJSON, data)
|
return data, sha256.Sum256(data) != f.sums[name], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeIn parses data, what the state file name holds, puts it into the
|
||||||
|
// part that keeps that state, in place of what the part held, and returns
|
||||||
|
// how many entries the file holds. edit is whether data is an admin's
|
||||||
|
// edit taken in while smallwebwaf runs, rather than the file read at the
|
||||||
|
// start. An error names the file and, where the JSON decoder tells it,
|
||||||
|
// the line and column, or else the entry.
|
||||||
|
func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) {
|
||||||
|
path := filepath.Join(f.params.Dir, name)
|
||||||
|
|
||||||
|
var entries int
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case bansJSON:
|
||||||
|
var file bansFile
|
||||||
|
|
||||||
|
err := parse(path, data, &file)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
held := make([]bans.Ban, 0, len(file.Bans))
|
||||||
|
for _, entry := range file.Bans {
|
||||||
|
held = append(held, entry.ban())
|
||||||
|
}
|
||||||
|
|
||||||
|
if edit {
|
||||||
|
f.params.Ledger.LoadEdit(held)
|
||||||
|
} else {
|
||||||
|
f.params.Ledger.Load(held)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = len(held)
|
||||||
|
case clientsJSON:
|
||||||
|
var file clientsFile
|
||||||
|
|
||||||
|
err := parse(path, data, &file)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.params.Limiter.Load(file.Clients, f.params.Now())
|
||||||
|
entries = len(file.Clients)
|
||||||
|
case lookupsJSON:
|
||||||
|
var file lookupsFile
|
||||||
|
|
||||||
|
err := parse(path, data, &file)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.params.GeoJS.Load(file.Lookups)
|
||||||
|
entries = len(file.Lookups)
|
||||||
|
case reputationJSON:
|
||||||
|
var file reputationFile
|
||||||
|
|
||||||
|
err := parse(path, data, &file)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = f.params.Lists.Load(file.Lists)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.params.DNSBL.Load(file.Verdicts)
|
||||||
|
f.params.AbuseIPDB.Load(file.AbuseIPDB)
|
||||||
|
entries = len(file.Lists)
|
||||||
|
case alertsJSON:
|
||||||
|
waiting, err := f.takeInAlerts(path, data)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = waiting
|
||||||
|
}
|
||||||
|
|
||||||
|
f.sums[name] = sha256.Sum256(data)
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeInAlerts parses data, what alerts.json, at path, holds, puts it
|
||||||
|
// into the alerts and the anomaly counters, in place of what they held,
|
||||||
|
// and returns how many alerts wait in it, as takeIn describes.
|
||||||
|
func (f *Files) takeInAlerts(path string, data []byte) (int, error) {
|
||||||
|
// waiting was a list, of the alerts waiting for the webhook, before
|
||||||
|
// alerts went to Slack and ntfy too.
|
||||||
|
var written struct {
|
||||||
|
Waiting json.RawMessage `json:"waiting"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if json.Unmarshal(data, &written) == nil &&
|
||||||
|
bytes.HasPrefix(written.Waiting, []byte("[")) {
|
||||||
|
return 0, fmt.Errorf("%s: %w", path, errWaitingList)
|
||||||
|
}
|
||||||
|
|
||||||
|
var file alertsFile
|
||||||
|
|
||||||
|
err := parse(path, data, &file)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.params.Alerts.Load(alerts.State{
|
||||||
|
Cooldowns: file.Cooldowns, Hour: file.Hour, Waiting: file.Waiting,
|
||||||
|
})
|
||||||
|
f.params.Anomalies.Load(file.AnomalyCounters, f.params.Now())
|
||||||
|
|
||||||
|
entries := 0
|
||||||
|
for _, waiting := range file.Waiting {
|
||||||
|
entries += len(waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFile writes the state file name from what smallwebwaf holds. An
|
||||||
|
// edit made since smallwebwaf last read or wrote the file is taken in
|
||||||
|
// first, so that it is not overwritten, or set aside if it does not
|
||||||
|
// parse. A file that cannot be read, or an edit that cannot be set
|
||||||
|
// aside, is left as it is, and the write given up. Every write is counted
|
||||||
|
// in the metrics, and one that fails or is given up as a failure.
|
||||||
|
func (f *Files) writeFile(name string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
data, changed, err := f.readChanged(name)
|
||||||
|
if err == nil && changed {
|
||||||
|
err = f.takeInEdit(name, data)
|
||||||
|
if err != nil {
|
||||||
|
err = f.setAside(name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
data, err = f.encode(name)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("encode %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
err = write(f.params.Dir, name, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// The file holds data from here on, even if the directory sync
|
||||||
|
// fails, so that its next read does not take it for an admin's
|
||||||
|
// edit.
|
||||||
|
f.sums[name] = sha256.Sum256(data)
|
||||||
|
err = syncDirectory(f.params.Dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeCounted writes data to the state file name, as write does, and
|
|
||||||
// counts the write in the metrics.
|
|
||||||
func (f *Files) writeCounted(name string, data []byte) error {
|
|
||||||
err := write(f.params.Dir, name, data)
|
|
||||||
f.params.Metrics.StateFileWritten(name, len(data), err)
|
f.params.Metrics.StateFileWritten(name, len(data), err)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setAside renames the state file name, an edit that does not parse with
|
||||||
|
// parseErr, to name.bad, for the admin to mend, and logs it with where in
|
||||||
|
// the file the error is, and raises a file_error alert for it. If the
|
||||||
|
// rename fails, the edit is left as it is, and the error returned is
|
||||||
|
// parseErr joined with the rename's.
|
||||||
|
func (f *Files) setAside(name string, parseErr error) error {
|
||||||
|
path := filepath.Join(f.params.Dir, name)
|
||||||
|
|
||||||
|
err := os.Rename(path, path+".bad")
|
||||||
|
if err != nil {
|
||||||
|
return errors.Join(parseErr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAside = "set aside an edit of a state file that does not parse"
|
||||||
|
|
||||||
|
// Raised before it is logged, so that the alert is there once the log
|
||||||
|
// line is.
|
||||||
|
f.params.Alerts.Raise(alerts.Alert{
|
||||||
|
Event: alerts.EventFileError,
|
||||||
|
Reason: setAside,
|
||||||
|
Detail: map[string]any{"file": path + ".bad", "error": parseErr.Error()},
|
||||||
|
})
|
||||||
|
f.params.ProcessLog.Error(setAside, "file", path+".bad", "error", parseErr.Error())
|
||||||
|
f.params.Metrics.StateFileEditSetAside(name)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encode returns the state file name as smallwebwaf writes it, from a
|
||||||
|
// snapshot of the part that keeps that state.
|
||||||
|
func (f *Files) encode(name string) ([]byte, error) {
|
||||||
|
switch name {
|
||||||
|
case bansJSON:
|
||||||
|
return encodeIndented(bansFile{
|
||||||
|
Version: version, Bans: BanEntries(f.params.Ledger.Snapshot()),
|
||||||
|
})
|
||||||
|
case clientsJSON:
|
||||||
|
return encodeOnePerLine("clients", f.params.Limiter.Snapshot())
|
||||||
|
case lookupsJSON:
|
||||||
|
return encodeOnePerLine("lookups", f.params.GeoJS.Snapshot())
|
||||||
|
case reputationJSON:
|
||||||
|
return encodeIndented(reputationFile{
|
||||||
|
Version: version, Lists: f.params.Lists.Snapshot(),
|
||||||
|
Verdicts: f.params.DNSBL.Snapshot(), AbuseIPDB: f.params.AbuseIPDB.Snapshot(),
|
||||||
|
})
|
||||||
|
default: // alerts.json
|
||||||
|
held := f.params.Alerts.Snapshot()
|
||||||
|
|
||||||
|
return encodeIndented(alertsFile{
|
||||||
|
Version: version, Cooldowns: held.Cooldowns, Hour: held.Hour,
|
||||||
|
Waiting: held.Waiting, AnomalyCounters: f.params.Anomalies.Snapshot(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeIndented encodes file, a state file's struct, indented for an
|
||||||
|
// admin to read and edit.
|
||||||
|
func encodeIndented(file any) ([]byte, error) {
|
||||||
|
data, err := json.MarshalIndent(file, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(data, '\n'), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BanEntries returns held as bans.json lists them, an empty list for
|
||||||
|
// none.
|
||||||
|
func BanEntries(held []bans.Ban) []BanEntry {
|
||||||
|
entries := make([]BanEntry, 0, len(held))
|
||||||
|
for _, ban := range held {
|
||||||
|
entries = append(entries, newBanEntry(ban))
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
// newBanEntry returns ban as bans.json holds it.
|
// newBanEntry returns ban as bans.json holds it.
|
||||||
func newBanEntry(ban bans.Ban) banEntry {
|
func newBanEntry(ban bans.Ban) BanEntry {
|
||||||
entry := banEntry{Netblock: ban.Netblock, Start: ban.Start, Notes: ban.Notes}
|
entry := BanEntry{
|
||||||
|
Netblock: ban.Netblock, Start: ban.Start, Cause: ban.Cause, Reason: ban.Reason,
|
||||||
|
Notes: ban.Notes,
|
||||||
|
}
|
||||||
if !ban.Permanent() {
|
if !ban.Permanent() {
|
||||||
entry.Expires = &ban.Expires
|
entry.Expires = &ban.Expires
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !ban.Lifted.IsZero() {
|
||||||
|
entry.Lifted = &ban.Lifted
|
||||||
|
}
|
||||||
|
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
// ban returns the ban an entry of bans.json holds.
|
// ban returns the ban an entry of bans.json holds.
|
||||||
func (e banEntry) ban() bans.Ban {
|
func (e BanEntry) ban() bans.Ban {
|
||||||
ban := bans.Ban{Netblock: e.Netblock, Start: e.Start, Notes: e.Notes}
|
ban := bans.Ban{
|
||||||
|
Netblock: e.Netblock, Start: e.Start, Cause: e.Cause, Reason: e.Reason,
|
||||||
|
Notes: e.Notes,
|
||||||
|
}
|
||||||
if e.Expires != nil {
|
if e.Expires != nil {
|
||||||
ban.Expires = *e.Expires
|
ban.Expires = *e.Expires
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if e.Lifted != nil {
|
||||||
|
ban.Lifted = *e.Lifted
|
||||||
|
}
|
||||||
|
|
||||||
return ban
|
return ban
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +646,8 @@ func (e banEntry) ban() bans.Ban {
|
|||||||
// client, a start, from which the length of the netblock's next ban is
|
// client, a start, from which the length of the netblock's next ban is
|
||||||
// worked out, or an expires, which would make it permanent. A permanent
|
// worked out, or an expires, which would make it permanent. A permanent
|
||||||
// ban's expires is null, which Bans cannot tell from a missing one, so
|
// ban's expires is null, which Bans cannot tell from a missing one, so
|
||||||
// each expires is read again as written.
|
// each expires is read again as written. A cause other than limit,
|
||||||
|
// attack or admin, most likely misspelt, is refused too.
|
||||||
func (f *bansFile) check(data []byte) error {
|
func (f *bansFile) check(data []byte) error {
|
||||||
var written struct {
|
var written struct {
|
||||||
Bans []struct {
|
Bans []struct {
|
||||||
@@ -284,6 +668,9 @@ func (f *bansFile) check(data []byte) error {
|
|||||||
return missing(i, "start")
|
return missing(i, "start")
|
||||||
case written.Bans[i].Expires == nil:
|
case written.Bans[i].Expires == nil:
|
||||||
return missing(i, "expires")
|
return missing(i, "expires")
|
||||||
|
case entry.Cause != "" && entry.Cause != bans.CauseLimit &&
|
||||||
|
entry.Cause != bans.CauseAttack && entry.Cause != bans.CauseAdmin:
|
||||||
|
return fmt.Errorf("entry %d's cause %q %w", i+1, entry.Cause, errCause)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,8 +678,8 @@ func (f *bansFile) check(data []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// check refuses a client without its address, which would count nobody's
|
// check refuses a client without its address, which would count nobody's
|
||||||
// requests, or with requests in a window but no start, which would drop
|
// requests, or with requests or bytes in a window but no start, which
|
||||||
// them and give the client a fresh allowance.
|
// would drop them and give the client a fresh allowance.
|
||||||
func (f *clientsFile) check([]byte) error {
|
func (f *clientsFile) check([]byte) error {
|
||||||
for i, client := range f.Clients {
|
for i, client := range f.Clients {
|
||||||
switch {
|
switch {
|
||||||
@@ -304,6 +691,12 @@ func (f *clientsFile) check([]byte) error {
|
|||||||
return missing(i, "hour.start")
|
return missing(i, "hour.start")
|
||||||
case countsWithoutStart(client.Day):
|
case countsWithoutStart(client.Day):
|
||||||
return missing(i, "day.start")
|
return missing(i, "day.start")
|
||||||
|
case countsWithoutStart(client.MinuteBytes):
|
||||||
|
return missing(i, "minute_bytes.start")
|
||||||
|
case countsWithoutStart(client.HourBytes):
|
||||||
|
return missing(i, "hour_bytes.start")
|
||||||
|
case countsWithoutStart(client.DayBytes):
|
||||||
|
return missing(i, "day_bytes.start")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,8 +734,171 @@ func (f *lookupsFile) check(data []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// countsWithoutStart reports whether b holds requests but no start, which
|
// check refuses a list without its URL, which would name no list, or the
|
||||||
// places them in time.
|
// time it was last tried, which would have it fetched at once, and a copy
|
||||||
|
// of it without the time it was fetched, or without its lines, which hold
|
||||||
|
// the list. It refuses a verdict without its zone or its client, which
|
||||||
|
// would be about no one, whether the zone lists the client, or the time
|
||||||
|
// it was fetched, which would drop it, and so an AbuseIPDB score without
|
||||||
|
// its client, the score, or the time it was fetched. A verdict's listed is
|
||||||
|
// false for a client the zone does not list, and a score can be 0, which
|
||||||
|
// the structs cannot tell from a missing one, so each is read again as
|
||||||
|
// written.
|
||||||
|
func (f *reputationFile) check(data []byte) error {
|
||||||
|
for i, kept := range f.Lists {
|
||||||
|
switch {
|
||||||
|
case kept.URL == "":
|
||||||
|
return missing(i, "url")
|
||||||
|
case kept.Tried.IsZero():
|
||||||
|
return missing(i, "tried")
|
||||||
|
case kept.Fetched.IsZero() && kept.Lines != nil:
|
||||||
|
return missing(i, "fetched")
|
||||||
|
case kept.Lines == nil && !kept.Fetched.IsZero():
|
||||||
|
return missing(i, "lines")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var written struct {
|
||||||
|
Verdicts []struct {
|
||||||
|
Listed *bool `json:"listed"`
|
||||||
|
} `json:"verdicts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.Unmarshal(data, &written)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, verdict := range f.Verdicts {
|
||||||
|
switch {
|
||||||
|
case verdict.Zone == "":
|
||||||
|
return fmt.Errorf("verdicts %w", missing(i, "zone"))
|
||||||
|
case !verdict.Client.IsValid():
|
||||||
|
return fmt.Errorf("verdicts %w", missing(i, "client"))
|
||||||
|
case written.Verdicts[i].Listed == nil:
|
||||||
|
return fmt.Errorf("verdicts %w", missing(i, "listed"))
|
||||||
|
case verdict.Fetched.IsZero():
|
||||||
|
return fmt.Errorf("verdicts %w", missing(i, "fetched"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkScores(f.AbuseIPDB.Scores, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkScores refuses an AbuseIPDB score, of scores, read from data, as
|
||||||
|
// reputationFile's check describes.
|
||||||
|
func checkScores(scores []reputation.Score, data []byte) error {
|
||||||
|
var written struct {
|
||||||
|
AbuseIPDB struct {
|
||||||
|
Scores []struct {
|
||||||
|
Score *int64 `json:"score"`
|
||||||
|
} `json:"scores"`
|
||||||
|
} `json:"abuseipdb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.Unmarshal(data, &written)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, kept := range scores {
|
||||||
|
switch {
|
||||||
|
case !kept.Client.IsValid():
|
||||||
|
return fmt.Errorf("abuseipdb scores %w", missing(i, "client"))
|
||||||
|
case written.AbuseIPDB.Scores[i].Score == nil:
|
||||||
|
return fmt.Errorf("abuseipdb scores %w", missing(i, "score"))
|
||||||
|
case kept.Fetched.IsZero():
|
||||||
|
return fmt.Errorf("abuseipdb scores %w", missing(i, "fetched"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// check refuses a cooldown without its event or when its alert was sent,
|
||||||
|
// which would hold back no repeat, alerts waiting for a destination with
|
||||||
|
// another name than webhook, slack or ntfy, most likely misspelt, an
|
||||||
|
// alert waiting without its event or its time, and an anomaly counter as
|
||||||
|
// checkAnomalyCounters does.
|
||||||
|
func (f *alertsFile) check([]byte) error {
|
||||||
|
for i, cooldown := range f.Cooldowns {
|
||||||
|
switch {
|
||||||
|
case cooldown.Event == "":
|
||||||
|
return fmt.Errorf("cooldowns %w", missing(i, "event"))
|
||||||
|
case cooldown.Sent.IsZero():
|
||||||
|
return fmt.Errorf("cooldowns %w", missing(i, "sent"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, destination := range slices.Sorted(maps.Keys(f.Waiting)) {
|
||||||
|
if !slices.Contains(alerts.Destinations(), destination) {
|
||||||
|
return fmt.Errorf("waiting %q %w", destination, errDestination)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, alert := range f.Waiting[destination] {
|
||||||
|
switch {
|
||||||
|
case alert.Event == "":
|
||||||
|
return fmt.Errorf("waiting %s %w", destination, missing(i, "event"))
|
||||||
|
case alert.Time.IsZero():
|
||||||
|
return fmt.Errorf("waiting %s %w", destination, missing(i, "time"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkAnomalyCounters(f.AnomalyCounters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAnomalyCounters refuses an anomaly counter whose scope is not
|
||||||
|
// client, net, asn, total or watch, most likely misspelt, and one without
|
||||||
|
// a field it needs, as missingFromCounter tells.
|
||||||
|
func checkAnomalyCounters(counters []anomaly.Counter) error {
|
||||||
|
for i, counter := range counters {
|
||||||
|
if !slices.Contains(anomaly.Scopes(), counter.Scope) {
|
||||||
|
return fmt.Errorf("anomaly_counters entry %d's scope %q %w", i+1,
|
||||||
|
counter.Scope, errScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
field := missingFromCounter(counter)
|
||||||
|
if field != "" {
|
||||||
|
return fmt.Errorf("anomaly_counters %w", missing(i, field))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// missingFromCounter returns the first field counter, an anomaly counter,
|
||||||
|
// needs and has not, or "" when it has them all: what tells it from the
|
||||||
|
// others in its scope, without which it would never be counted again, the
|
||||||
|
// netblock of a client, net or watch counter, the AS number of an asn one
|
||||||
|
// and the name of a watch one; and the start of a window in which it has
|
||||||
|
// requests or bytes, without which they would be dropped.
|
||||||
|
func missingFromCounter(counter anomaly.Counter) string {
|
||||||
|
scope := counter.Scope
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case scope != anomaly.ScopeASN && scope != anomaly.ScopeTotal &&
|
||||||
|
!counter.Netblock.IsValid():
|
||||||
|
return "netblock"
|
||||||
|
case scope == anomaly.ScopeASN && counter.ASN == "":
|
||||||
|
return "asn"
|
||||||
|
case scope == anomaly.ScopeWatch && counter.Name == "":
|
||||||
|
return "name"
|
||||||
|
case countsWithoutStart(counter.Minute):
|
||||||
|
return "minute.start"
|
||||||
|
case countsWithoutStart(counter.Hour):
|
||||||
|
return "hour.start"
|
||||||
|
case countsWithoutStart(counter.MinuteBytes):
|
||||||
|
return "minute_bytes.start"
|
||||||
|
case countsWithoutStart(counter.HourBytes):
|
||||||
|
return "hour_bytes.start"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countsWithoutStart reports whether b holds requests, or bytes, but no
|
||||||
|
// start, which places them in time.
|
||||||
func countsWithoutStart(b ratelimit.Buckets) bool {
|
func countsWithoutStart(b ratelimit.Buckets) bool {
|
||||||
return b.Start.IsZero() && (b.Current != 0 || b.Previous != 0)
|
return b.Start.IsZero() && (b.Current != 0 || b.Previous != 0)
|
||||||
}
|
}
|
||||||
@@ -389,28 +945,16 @@ func checkWritable(dir string) error {
|
|||||||
return errors.Join(file.Close(), os.Remove(file.Name()))
|
return errors.Join(file.Close(), os.Remove(file.Name()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// read reads the state file name in dir into file, a pointer to that
|
// parse reads data, what the state file at path holds, into file, a
|
||||||
// file's struct, and checks its entries. A missing file leaves file as it
|
// pointer to that file's struct, and checks its entries.
|
||||||
// is.
|
func parse(path string, data []byte, file stateFile) error {
|
||||||
func read(dir, name string, file stateFile) error {
|
|
||||||
path := filepath.Join(dir, name)
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWWAF_STATE_DIR
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// The version is read first, so that a file of another version is
|
// The version is read first, so that a file of another version is
|
||||||
// refused for that, and not for an entry this version cannot read.
|
// refused for that, and not for an entry this version cannot read.
|
||||||
var header struct {
|
var header struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(data, &header)
|
err := json.Unmarshal(data, &header)
|
||||||
if err == nil && header.Version != version {
|
if err == nil && header.Version != version {
|
||||||
err = fmt.Errorf("%w %d, where this smallwebwaf reads version %d",
|
err = fmt.Errorf("%w %d, where this smallwebwaf reads version %d",
|
||||||
errVersion, header.Version, version)
|
errVersion, header.Version, version)
|
||||||
@@ -463,7 +1007,7 @@ func position(data []byte, err error) string {
|
|||||||
// write writes data to the file name in dir so that a crash at any
|
// write writes data to the file name in dir so that a crash at any
|
||||||
// moment leaves either the old file or the new one, whole: data goes to a
|
// moment leaves either the old file or the new one, whole: data goes to a
|
||||||
// temporary file in the same directory, which is synced and renamed over
|
// temporary file in the same directory, which is synced and renamed over
|
||||||
// name, and then the directory is synced, so that the rename lasts.
|
// name. syncDirectory must follow, so that the rename lasts.
|
||||||
func write(dir, name string, data []byte) error {
|
func write(dir, name string, data []byte) error {
|
||||||
path := filepath.Join(dir, name)
|
path := filepath.Join(dir, name)
|
||||||
temporary := path + ".tmp"
|
temporary := path + ".tmp"
|
||||||
@@ -475,10 +1019,13 @@ func write(dir, name string, data []byte) error {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(temporary)
|
_ = os.Remove(temporary)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncDirectory syncs dir to the disk, so that a rename in it lasts.
|
||||||
|
func syncDirectory(dir string) error {
|
||||||
directory, err := os.Open(dir) //nolint:gosec // SWWAF_STATE_DIR itself
|
directory, err := os.Open(dir) //nolint:gosec // SWWAF_STATE_DIR itself
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+1449
-35
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
package state
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The test is on write itself: a state file is read before it is
|
||||||
|
// written, and a directory in its place fails that read first.
|
||||||
|
func TestFailedRenameLeavesNoTemporaryFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// A directory named bans.json cannot be renamed over.
|
||||||
|
err := os.Mkdir(filepath.Join(dir, bansJSON), 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = write(dir, bansJSON, []byte("{}\n"))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("writing over a directory did not fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) != 1 || entries[0].Name() != bansJSON {
|
||||||
|
t.Errorf("%s holds %v, want only bans.json", dir, entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-10
@@ -3,12 +3,14 @@
|
|||||||
# deploy/example-app, then run the app's container with a volume for the
|
# deploy/example-app, then run the app's container with a volume for the
|
||||||
# state files and check that the health check passes, that a request is
|
# state files and check that the health check passes, that a request is
|
||||||
# served through smallwebwaf, that a second one in a minute bans the
|
# served through smallwebwaf, that a second one in a minute bans the
|
||||||
# client, that `sv stop` stops smallwebwaf in order, that `docker stop`
|
# client, that a probe for /.env bans another client, which its next
|
||||||
# stops the container without having to kill it, and that a new
|
# request bans for good, that `sv stop` stops smallwebwaf in order, that
|
||||||
# container on the same volume still refuses the banned client. The
|
# `docker stop` stops the container without having to kill it, and that
|
||||||
# containers, the volume and both images are removed however the script
|
# a new container on the same volume still refuses the banned client. The
|
||||||
# ends. Building the app needs network access, for nixpkgs' binary cache.
|
# containers run with SWWAF_LOOKUP_SOURCE=off, so that no address is sent
|
||||||
# script/check does not run this.
|
# to GeoJS. The containers, the volume and both images are removed however
|
||||||
|
# the script ends. Building the app needs network access, for nixpkgs'
|
||||||
|
# binary cache. script/check does not run this.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -52,18 +54,23 @@ healthy() {
|
|||||||
[ "$status" = healthy ]
|
[ "$status" = healthy ]
|
||||||
}
|
}
|
||||||
|
|
||||||
# logged <text>: the container's output holds text.
|
# logged <text>...: a line of the container's output holds every text,
|
||||||
|
# in any order.
|
||||||
logged() {
|
logged() {
|
||||||
docker logs "$CONTAINER" 2>&1 | grep -qF "$1"
|
lines="$(docker logs "$CONTAINER" 2>&1)"
|
||||||
|
for text in "$@"; do
|
||||||
|
lines="$(printf '%s\n' "$lines" | grep -F "$text")" || return 1
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# start_container: run the app's container, with the state files on the
|
# start_container: run the app's container, with the state files on the
|
||||||
# volume and a rate limit of one request a minute, and wait until it is
|
# volume, a rate limit of one request a minute and no client looked up,
|
||||||
# healthy.
|
# and wait until it is healthy.
|
||||||
start_container() {
|
start_container() {
|
||||||
docker run --detach --name "$CONTAINER" --publish 127.0.0.1::8080 \
|
docker run --detach --name "$CONTAINER" --publish 127.0.0.1::8080 \
|
||||||
--volume "$VOLUME:/var/lib/smallwebwaf" \
|
--volume "$VOLUME:/var/lib/smallwebwaf" \
|
||||||
--env SWWAF_RATE_LIMIT_PER_MINUTE=1 \
|
--env SWWAF_RATE_LIMIT_PER_MINUTE=1 \
|
||||||
|
--env SWWAF_LOOKUP_SOURCE=off \
|
||||||
"$APP_IMAGE" >/dev/null
|
"$APP_IMAGE" >/dev/null
|
||||||
wait_for "the health check did not pass" healthy
|
wait_for "the health check did not pass" healthy
|
||||||
address="$(docker port "$CONTAINER" 8080/tcp)"
|
address="$(docker port "$CONTAINER" 8080/tcp)"
|
||||||
@@ -77,6 +84,15 @@ refused() {
|
|||||||
[ "$code" = 403 ]
|
[ "$code" = 403 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# refused_from <client> <path>: a request for path from client, as
|
||||||
|
# X-Forwarded-For names it, gets 403. smallwebwaf believes the header
|
||||||
|
# from docker's gateway, a private address.
|
||||||
|
refused_from() {
|
||||||
|
code="$(curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||||
|
--max-time 10 --header "X-Forwarded-For: $1" "http://$address$2")" || true
|
||||||
|
[ "$code" = 403 ]
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
@@ -100,6 +116,14 @@ main() {
|
|||||||
wait_for "smallwebwaf logged no ban" logged '"action":"rate_limited"'
|
wait_for "smallwebwaf logged no ban" logged '"action":"rate_limited"'
|
||||||
echo "example-app: a second request in a minute bans the client"
|
echo "example-app: a second request in a minute bans the client"
|
||||||
|
|
||||||
|
refused_from 203.0.113.9 /.env || fail "a probe for /.env was not refused"
|
||||||
|
wait_for "smallwebwaf logged no ban for the probe" \
|
||||||
|
logged '"action":"banned"' '"rule_ids":["env-file"]'
|
||||||
|
refused_from 203.0.113.9 / || fail "the client of the probe was let through"
|
||||||
|
wait_for "the client's next request did not make its ban permanent" \
|
||||||
|
logged '"ban_expires":"permanent"'
|
||||||
|
echo "example-app: a probe for /.env bans the client, its next request for good"
|
||||||
|
|
||||||
docker exec "$CONTAINER" sv stop smallwebwaf >/dev/null ||
|
docker exec "$CONTAINER" sv stop smallwebwaf >/dev/null ||
|
||||||
fail "sv stop smallwebwaf failed"
|
fail "sv stop smallwebwaf failed"
|
||||||
wait_for "smallwebwaf did not stop in order" logged '"msg":"stopped"'
|
wait_for "smallwebwaf did not stop in order" logged '"msg":"stopped"'
|
||||||
|
|||||||
+7
-1
@@ -1,7 +1,9 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/run: build bin/smallwebwaf with script/build and run it, with
|
# script/run: build bin/smallwebwaf with script/build and run it, with
|
||||||
# the settings in the environment. Unless SWWAF_STATE_DIR is set, the
|
# the settings in the environment. Unless SWWAF_STATE_DIR is set, the
|
||||||
# state files go in bin/state, beside the binary.
|
# state files go in bin/state, beside the binary, and unless
|
||||||
|
# SWWAF_RULES_DIR is set, the rule files are those of share/rules.d,
|
||||||
|
# which the image ships.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -14,6 +16,10 @@ main() {
|
|||||||
export SWWAF_STATE_DIR
|
export SWWAF_STATE_DIR
|
||||||
mkdir -p "$SWWAF_STATE_DIR"
|
mkdir -p "$SWWAF_STATE_DIR"
|
||||||
fi
|
fi
|
||||||
|
if [ -z "${SWWAF_RULES_DIR+set}" ]; then
|
||||||
|
SWWAF_RULES_DIR="$ROOT/share/rules.d"
|
||||||
|
export SWWAF_RULES_DIR
|
||||||
|
fi
|
||||||
exec "$ROOT/bin/smallwebwaf"
|
exec "$ROOT/bin/smallwebwaf"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/tidy: write go.mod and go.sum as `go mod tidy` writes them, which
|
||||||
|
# the test phase of the Dockerfile checks. This builds the Dockerfile's
|
||||||
|
# tidy-files stage, which holds the two files alone, and --output writes
|
||||||
|
# them into the working tree. The build makes no image, so it has no tag.
|
||||||
|
# --no-cache because `go mod tidy` asks the module proxy, whose answers a
|
||||||
|
# cached layer would repeat.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
docker build --no-cache \
|
||||||
|
--target tidy-files \
|
||||||
|
--output "type=local,dest=$ROOT" .
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 00-default.rules: probes no real visitor sends, anchored at the site root
|
||||||
|
|
||||||
|
# id target action regex
|
||||||
|
env-file path ban (?i)^/\.env(\.[a-z]+)?$
|
||||||
|
vcs-dir path ban (?i)^/\.(git|svn|hg|bzr)(/|$)
|
||||||
|
secrets-dir path ban (?i)^/\.(aws|ssh|docker|kube)/
|
||||||
|
secret-file path ban (?i)^/\.(htpasswd|htaccess|npmrc|netrc|pgpass|git-credentials|bash_history|DS_Store)$
|
||||||
|
editor-dir path ban (?i)^/\.(vscode|idea)/
|
||||||
|
backup-file path ban (?i)^/[^/]+\.(php(\.[a-z0-9]+|~)|sql(\.[a-z0-9]+)?)$
|
||||||
|
log-file path ban (?i)^/(debug|error|access)\.log$
|
||||||
|
compose-file path ban (?i)^/(docker-)?compose\.ya?ml$
|
||||||
|
php-shell path ban (?i)^/(shell|c99|r57|wso|alfa)\.php$
|
||||||
|
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|masscan|zgrab|wpscan)\b
|
||||||
|
path-traversal uri block (\.\./){2,}
|
||||||
|
empty-agent user_agent log ^$
|
||||||
Reference in New Issue
Block a user