Author SHA1 Message Date
sneak b5364bf282 SWW_APPS: an app's Core Rule Set fixes apply only to the hostnames that serve it (closes #220)
check / check (push) Failing after 3m37s
gitea's fixes (its parameters, its cookies and gitea.conf) now apply
only to a hostname SWW_APPS lists gitea for, or to every hostname while
it is unset. Each app's fixes are one .conf file, whose first rule skips
the rest unless Inspect sets tx.app_<name>, and one entry in apps.

A hostname in SWW_APPS that is not a DNS name, such as *.example.com,
stops the start. Unset, SWW_APPS is logged at start as null; set
empty, as "".

Model: opus-5-5
2026-11-09 02:32:38 +00:00
clawbot c66277b111 Request tags, with refusing and limits per tag (closes #175)
check / check (push) Failing after 5m8s
Each request gets tags, logged as `tags`: `asn=`, `country=`, one per `SWW_TAG_LISTS` list holding the client, `crowdsec`, `dnsbl=<zone>`, `abuseipdb` and `exploitsignature`. `SWW_TAG_DENY` refuses by tag and `SWW_TAG_LIMIT_PERCENT` lowers limits by tag. They replace `SWW_ASN_LIMIT_PERCENT`, `SWW_COUNTRY_LIMIT_PERCENT` and `SWW_DENIED_COUNTRIES`, with no aliases. A tag in those settings that nothing configured can give stops the start.

Judgement call: `SWW_ASN_LIMIT_PERCENT_URL` stays, its entries counting like `asn=` tags.
Judgement call: `SWW_TAG_LIMIT_PERCENT` also lowers byte limits, as the settings it replaces did.
Trips a reader: `sww_country_list_refusals_total` now counts only `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` refusals.

Model: opus-5-5
2026-11-09 03:30:49 +01:00
sneak f6435b1a73 Add make publish: build the image on the Docker host and push it to the registry
check / check (push) Successful in 3m23s
`script/publish` runs `script/docker` against the Docker host and pushes the image to the registry under four tags: the UTC date, the short commit hash, the branch name and `latest`. It refuses a working tree with uncommitted changes, since the hash tag names a commit.

Also carries sneak's `golines -m 77` line wrapping in three files, with no code change.

Unverified: a real build on the Docker host and a real push from this commit.
Deviation: sneak's FIXME comments are out, because the linter refuses them; they are #227, #228 and #229.

Model: opus-5-5
Co-authored-by: sneak <sneak@sneak.berlin>
2026-11-09 02:03:39 +01:00
clawbot 2349b2a081 discriminator: each decision heuristic is its own module (closes #195)
check / check (push) Failing after 1m53s
Each discriminator check is now a type in its own file behind one small interface, listed in `heuristics.go`. Adding a heuristic is one file and one line. Behaviour does not change.

Refusal and ban log lines gain `heuristic` and `heuristic_figures`. A ban after the answer, by `refusal_limit` or `byte_limits`, logs `after_answer_heuristic` and its figures, so one line can name both.

Deviation: four lists, not the planned three, so `refusal_limit` still bans at the decision.
Rule suppressed: `//nolint:lll` on one struct tag.

Model: opus-5-5
2026-11-09 01:14:24 +01:00
clawbot 42e06bf298 Quiet the update-alternatives man page warnings in the image build (closes #222) (#226)
check / check (push) Successful in 5m24s
xz-utils, which nix-bin depends on, links its man pages with update-alternatives when it installs, and the image's dpkg config drops man pages, so each link printed a warning. A config file re-includes only the man1 pages for this install; the same step deletes them, their dangling links in /etc/alternatives and the config file. No man page or package is added or removed.

Model: deepseek-v4-pro
2026-11-07 15:21:18 +01:00
66 changed files with 3220 additions and 890 deletions

No files matched your search

+11 -16
View File
@@ -124,20 +124,6 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
# ubuntu 26.04, 2026-09-27 # ubuntu 26.04, 2026-09-27
FROM ubuntu@sha256:f144425ff09be612d6d9ad965196e9cdc23dae1f42110a8a11a3e9a8198759f7 FROM ubuntu@sha256:f144425ff09be612d6d9ad965196e9cdc23dae1f42110a8a11a3e9a8198759f7
# The image's own labels, in place of the ones it would inherit from
# Ubuntu's. script/docker and script/cibuild pass the build arguments;
# a build that passes none labels the image unknown rather than leave
# Ubuntu's values.
ARG VERSION=unknown
ARG CREATED=unknown
ARG REVISION=unknown
LABEL org.opencontainers.image.title="smallwebwaf" \
org.opencontainers.image.description="A web application firewall for self-hosted web apps: sww rproxy in front of an app, and sww discriminator, which decides." \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.created="${CREATED}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.source="https://git.eeqj.de/sneak/smallwebwaf"
# runit's install creates its _runit-log user with minsysusers, which # runit's install creates its _runit-log user with minsysusers, which
# reads this file in place of runit's /usr/lib/sysusers.d/runit.conf. # reads this file in place of runit's /usr/lib/sysusers.d/runit.conf.
# runit's line leaves out the shell, and minsysusers prints a Perl # runit's line leaves out the shell, and minsysusers prints a Perl
@@ -150,10 +136,19 @@ RUN mkdir /etc/sysusers.d \
# ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches # ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
# them over plain HTTP and checks each one against the archive's signed # them over plain HTTP and checks each one against the archive's signed
# package lists, so no CA certificates are needed. # package lists, so no CA certificates are needed.
RUN apt-get update \ #
# nix-bin depends on xz-utils, whose install links man pages with
# update-alternatives. The image's dpkg config drops man pages, so each
# link warns. z-man, read after /etc/dpkg/dpkg.cfg.d/excludes, re-includes
# the man1 pages for this install alone; the same step deletes them again,
# so no man page or package is added.
RUN echo 'path-include=/usr/share/man/man1/*' > /etc/dpkg/dpkg.cfg.d/z-man \
&& apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \ && DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
ca-certificates nix-bin runit \ ca-certificates nix-bin runit \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/* /etc/dpkg/dpkg.cfg.d/z-man \
&& find /usr/share/man/man1 ! -type d -delete \
&& find /etc/alternatives -type l -xtype l -delete
# Nix run by root expects a group of build users, which nix-bin does not # Nix run by root expects a group of build users, which nix-bin does not
# create; with the setting empty, root's builds run without them. # create; with the setting empty, root's builds run without them.
+7 -3
View File
@@ -1,11 +1,12 @@
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \ .PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \
run example-app hooks build run example-app
# Makefile targets are thin shims; the implementations live in script/ # Makefile targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does, # of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
# which test checks. build and run are for working on the code by hand; # which test checks. build and run are for working on the code by hand;
# example-app checks the image with an app built on it. # example-app checks the image with an app built on it; publish builds
# the image on the Docker host and pushes it to the registry.
bootstrap: bootstrap:
@script/bootstrap @script/bootstrap
@@ -34,6 +35,9 @@ check:
docker: docker:
@script/docker @script/docker
publish:
@script/publish
hooks: hooks:
@script/install-precommit @script/install-precommit
+62 -30
View File
@@ -68,8 +68,9 @@ Not built, and not planned:
### The image ### The image
No image is published. Build it from a clone. `make docker` runs the tests and The image is published at `git.eeqj.de/sneak/smallwebwaf`. You can also build it
the linter, then builds the image, tagged `smallwebwaf`. from a clone. `make docker` runs the tests and the linter, then builds the
image, tagged `smallwebwaf`.
```sh ```sh
git clone https://git.eeqj.de/sneak/smallwebwaf.git git clone https://git.eeqj.de/sneak/smallwebwaf.git
@@ -216,7 +217,7 @@ account, a key, a token or an alert destination stays off until you give one.
Every setting the code reads, with its default: Every setting the code reads, with its default:
| Variable | Default | What it does | | Variable | Default | What it does |
| ---------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. | | `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. | | `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. | | `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
@@ -256,14 +257,14 @@ Every setting the code reads, with its default:
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. | | `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. | | `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. | | `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
| `SWW_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. | | `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWW_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. | | `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
| `SWW_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. | | `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWW_ASN_LIMIT_PERCENT`. | | `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWW_COUNTRY_LIMIT_PERCENT`. | | `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. | | `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. | | `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). |
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. | | `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. | | `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. | | `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
@@ -274,6 +275,7 @@ Every setting the code reads, with its default:
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. | | `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. | | `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. | | `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWW_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;cloud.example.com:gitea`; only those apps' fixes apply there. Unset, every app's fixes apply to every hostname. |
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. | | `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. | | `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. | | `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
@@ -413,8 +415,7 @@ services:
SWW_RATE_LIMIT_PER_HOUR: "600" SWW_RATE_LIMIT_PER_HOUR: "600"
SWW_RATE_LIMIT_PER_DAY: "3000" SWW_RATE_LIMIT_PER_DAY: "3000"
# Hosting networks and one country get a share of those limits. # Hosting networks and one country get a share of those limits.
SWW_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25 SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25
SWW_COUNTRY_LIMIT_PERCENT: cn:25
# Paths only scanners ask for; one request bans the client. # Paths only scanners ask for; one request bans the client.
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
# Answer a refused client with 429 rather than 403. # Answer a refused client with 429 rather than 403.
@@ -506,31 +507,34 @@ of the request reaches the app.
`discriminator` takes the checks: `discriminator` takes the checks:
3. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`. 3. The tag lists.
4. The bans. 4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
5. The lookup of the client's AS number and country. 5. The bans.
6. The country lists. 6. The lookup of the client's AS number and country.
7. The reputation sources. 7. The country list.
8. The client's limit percentage, from its AS number, its country or a 8. The reputation sources.
reputation hit. 9. The tag refusals of `SWW_TAG_DENY`.
9. The rate limits. 10. The client's limit percentage, from its tags, its AS number, its country or
10. The trap paths. a reputation hit.
11. The rule files. 11. The rate limits.
12. The header limits, and then the Core Rule Set. 12. The trap paths.
13. A path under `/_sww/` is answered `404`, so that the app never sees that 13. The rule files.
14. The header limits, and then the Core Rule Set.
15. The tag refusals again, so that `exploitsignature` can refuse too.
16. A path under `/_sww/` is answered `404`, so that the app never sees that
prefix. prefix.
`rproxy` takes the rest, with `discriminator` counting at the end: `rproxy` takes the rest, with `discriminator` counting at the end:
14. The request is passed to the app, streaming. 17. The request is passed to the app, streaming.
15. After the answer, `rproxy` sends `discriminator` a report with the status 18. After the answer, `rproxy` sends `discriminator` a report with the status
and the bytes each way. For an allowed request, `discriminator` counts the and the bytes each way. For an allowed request, `discriminator` counts the
bytes toward the byte limits and adds the request to the client's history. bytes toward the byte limits and adds the request to the client's history.
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
request over the header limits of step 12 is answered `431`, and bans nobody request over the header limits of step 14 is answered `431`, and bans nobody
either. `rproxy` itself answers `405` to a method it does not pass on, and the either. `rproxy` itself answers `405` to a method it does not pass on, and the
status for a size or time limit, such as `413` for a body too large. status for a size or time limit, such as `413` for a body too large.
@@ -549,17 +553,36 @@ status for a size or time limit, such as `413` for a body too large.
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)). the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
### Request tags
Each request gets tags from what `discriminator` knows of its client, written in
the `tags` field of its log line:
- `asn=<number>` and `country=<code>`, from the lookup;
- one tag per list of `SWW_TAG_LISTS` that holds the client, such as `torexit`
or `vpn=mullvad`;
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
the client;
- `exploitsignature`, when a rule file or the Core Rule Set matched the request.
`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a
client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
### Attack detection ### Attack detection
- The Core Rule Set reads the method, the URL with its query string, and the - The Core Rule Set reads the method, the URL with its query string, and the
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
front of a code forge, bodies carry what people write and publish, which the front of a code forge, bodies carry what people write and publish, which the
rules take for attacks. rules take for attacks.
- `smallwebwaf` changes the Core Rule Set in eight ways so that it lets ordinary - `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary
requests to a code forge through, such as git over HTTP and sign-in through requests to a code forge through, such as git over HTTP and sign-in through
Git Credential Manager. On gitea's own paths, its searches, file names and Git Credential Manager. No setting undoes the changes; "Attack detection" in
branch names get through too. No setting undoes the changes; "Attack [`SPEC.md`](SPEC.md) lists them.
detection" in [`SPEC.md`](SPEC.md) lists them. - Each supported app also has its own fixes, which let its ordinary requests
through. gitea's let its searches, file names and branch names through.
`SWW_APPS` names the apps each hostname serves. A hostname gets only its own
apps' fixes, and a hostname it does not list gets none. Unset, every app's
fixes apply to every hostname.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own - `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
fields or paths. fields or paths.
- Rule files are plain text, one rule to a line, edited by hand and read again - Rule files are plain text, one rule to a line, edited by hand and read again
@@ -665,6 +688,15 @@ on the host.
`make check`. `make check`.
- `script/docker`: builds the image, whose build runs the tests and the linter - `script/docker`: builds the image, whose build runs the tests and the linter
first. `make docker`. first. `make docker`.
- `script/publish`: runs `script/docker`, whose build happens on the Docker host
named by `DOCKER_HOST` (`ssh://root@fsn1app1` unless set). It then pushes the
image to `PUBLISH_IMAGE` (`git.eeqj.de/sneak/smallwebwaf` unless set). It
refuses a working tree with uncommitted changes. `make publish`. It pushes
four tags:
- the UTC date as `YYYY-MM-DD`;
- the short commit hash;
- the branch name;
- `latest`.
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the - `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
image build. image build.
- `script/precommit`: run by the git pre-commit hook; runs `script/check`. - `script/precommit`: run by the git pre-commit hook; runs `script/check`.
+190 -113
View File
@@ -81,8 +81,8 @@ to them.
of bans is full. of bans is full.
- `internal/rules`: reads the rule files at start and again when they - `internal/rules`: reads the rule files at start and again when they
change, and tells which rules a request matches. change, and tells which rules a request matches.
- `internal/waf`: the Core Rule Set, with the eight changes listed under - `internal/waf`: the Core Rule Set, with the six changes and the apps'
"Attack detection", as Coraza directives. fixes listed under "Attack detection", as Coraza directives.
- `internal/lookup`: looks clients up through GeoJS or in the lookup - `internal/lookup`: looks clients up through GeoJS or in the lookup
database. database.
- `internal/reputation`: fetches the lists, asks the DNS blocklists and - `internal/reputation`: fetches the lists, asks the DNS blocklists and
@@ -119,12 +119,14 @@ to them.
## What happens to a request ## What happens to a request
`rproxy` runs steps 1, 2 and 14, and sends the report of step 15. `rproxy` runs steps 1, 2 and 17, and sends the report of step 18.
`discriminator` runs steps 3 to 13, and does the counting of step 15. The first `discriminator` runs steps 3 to 16, and does the counting of step 18. The first
step that produces a final answer ends the checks. Step 15 runs for each request step that produces a final answer ends the checks. Step 18 runs for each request
`discriminator` answers with `allow`, `deny` or `close`. `rproxy`'s size and `discriminator` answers with `allow`, `deny` or `close`. `rproxy`'s size and
time limits apply to the whole exchange. Each step names the section that holds time limits apply to the whole exchange. Each step names the section that holds
its rules. its rules. A step that names a heuristic in parentheses runs that heuristic. The
request log's `heuristic` field gives that name. `refusal_limit` and
`byte_limits` give theirs in `after_answer_heuristic` instead.
1. `GET /_sww/healthz` is answered at once by `rproxy`, before any check, so a 1. `GET /_sww/healthz` is answered at once by `rproxy`, before any check, so a
health checker never needs an exemption and is never refused. health checker never needs an exemption and is never refused.
@@ -141,55 +143,67 @@ its rules.
- An IPv6 client is its group: the netblock of `SWW_IPV6_GROUP_PREFIX` - An IPv6 client is its group: the netblock of `SWW_IPV6_GROUP_PREFIX`
around its address, a /64 by default, since one abuser usually controls a around its address, a /64 by default, since one abuser usually controls a
whole /64. A client is therefore one IPv4 address or one IPv6 group. whole /64. A client is therefore one IPv4 address or one IPv6 group.
3. The static lists. A client in `SWW_ALLOW_NETS` skips steps 4 to 12, the byte 3. The tag lists (`tag_lists`; see "Request tags"). Each list that holds the
limits and the error burst. A client in `SWW_DENY_NETS` is refused; a client client adds its tag. A client in `SWW_ALLOW_NETS` gets no tag.
in both lists is let through. 4. The static lists. A client in `SWW_ALLOW_NETS` skips steps 5 to 15, the byte
4. Bans. A client under a ban is refused (see "Bans"). limits and the error burst. A client in `SWW_DENY_NETS` is refused
5. The lookup, unless `SWW_LOOKUP_SOURCE` is `off` (see "Lookup of AS number and (`deny_nets`); a client in both lists is let through.
5. Bans. A client under a ban is refused (`active_ban`; see "Bans").
6. The lookup, unless `SWW_LOOKUP_SOURCE` is `off` (see "Lookup of AS number and
country"). The request goes on at once. It waits for the first GeoJS answer, country"). The request goes on at once. It waits for the first GeoJS answer,
up to `SWW_LOOKUP_TIMEOUT`, only when one of these needs it: up to `SWW_LOOKUP_TIMEOUT`, only when one of these needs it:
- a country list; - the country list;
- a biased threshold; - a biased threshold;
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming an `asn=` or `country=`
tag;
- `SWW_ADD_LOOKUP_HEADERS`. - `SWW_ADD_LOOKUP_HEADERS`.
A request that ends before the answer comes is logged without it. A request that ends before the answer comes is logged without it.
6. The country lists (see "Country lists"). 7. The country list (`countries`; see "Country list").
7. The reputation sources (see "Reputation"). A client on a fetched blocklist 8. The reputation sources (see "Reputation"), in this order:
gets `SWW_BLOCKLIST_ACTION`. A client on the CrowdSec decision list is - A client on a fetched blocklist gets `SWW_BLOCKLIST_ACTION`
banned. A client listed by a DNS blocklist, or scored at least (`blocklists`).
`SWW_ABUSEIPDB_MIN_SCORE` by AbuseIPDB, gets `SWW_REPUTATION_ACTION`. - A client on the CrowdSec decision list is banned (`crowdsec`).
8. The client's limit percentage (see "Biased thresholds"). - A client listed by a DNS blocklist gets `SWW_REPUTATION_ACTION` (`dnsbl`).
9. Rate limiting (see "Request rate limits, per client"). A request over a limit - A client scored at least `SWW_ABUSEIPDB_MIN_SCORE` by AbuseIPDB gets
times the percentage is refused and bans the client. `SWW_REPUTATION_ACTION` (`abuseipdb`).
10. The trap paths (see "Attack detection"). A request for one is refused and 9. The tag refusals (`tag_deny`; see "Request tags"). A request carrying a tag
bans the client. in `SWW_TAG_DENY` is refused.
11. The rule files (see "Rule files"). Matching stops at the first rule that 10. The client's limit percentage (see "Biased thresholds").
refuses. 11. Rate limiting (`rate_limits`; see "Request rate limits, per client"). A
12. The header limits (see "Answering in time"), and then the Core Rule Set (see request over a limit times the percentage is refused and bans the client.
"Attack detection"). A Core Rule Set match is refused with `403` while 12. The trap paths (`trap_paths`; see "Attack detection"). A request for one is
`SWW_WAF_MODE` is `block`, and only logged and alerted while it is `detect`. refused and bans the client.
13. A path under `/_sww/` is answered `404`, so that the app never sees that 13. The rule files (`rule_files`; see "Rule files"). Matching stops at the first
rule that refuses.
14. The header limits (`header_limits`; see "Answering in time"), and then the
Core Rule Set (`core_rule_set`; see "Attack detection"). A Core Rule Set
match is refused with `403` while `SWW_WAF_MODE` is `block`, and only logged
and alerted while it is `detect`.
15. The tag refusals again (`tag_deny`), so that `exploitsignature` can refuse
too.
16. A path under `/_sww/` is answered `404`, so that the app never sees that
prefix. prefix.
14. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing 17. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing
a request to the app"). With `SWW_ADD_LOOKUP_HEADERS`, `X-Client-ASN` and a request to the app"). With `SWW_ADD_LOOKUP_HEADERS`, `X-Client-ASN` and
`X-Client-Country` are added for the app's own logs; a header whose value is `X-Client-Country` are added for the app's own logs; a header whose value is
unknown is left out. Whatever that setting says, any such header the client unknown is left out. Whatever that setting says, any such header the client
sent is removed. sent is removed.
15. After the visitor's answer has ended, `rproxy` sends the report (see "The 18. After the visitor's answer has ended, `rproxy` sends the report (see "The
report"). `discriminator` then counts: report"). `discriminator` then counts:
- For a request it allowed, the response bytes, the request bytes or both - For a request it allowed, the response bytes, the request bytes or both
are added to the byte counters, as `SWW_BYTES_COUNT` says. For a are added to the byte counters, as `SWW_BYTES_COUNT` says. For a
WebSocket, what it carried each way counts once it closes. A client whose WebSocket, what it carried each way counts once it closes. A client whose
total passes a byte limit times the percentage has broken that limit and total passes a byte limit times the percentage has broken that limit and
is banned. The response in progress is not cut off. is banned (`byte_limits`). The response in progress is not cut off.
- For a request it allowed, the client's history and the anomaly counters - For a request it allowed, the client's history and the anomaly counters
are updated, and the alert thresholds are evaluated. are updated, and the alert thresholds are evaluated.
For a request it refused, `discriminator` adds nothing to the byte limits. For a request it refused, `discriminator` adds nothing to the byte limits.
It updates the history and the anomaly counters at its decision. It counts It updates the history and the anomaly counters at its decision. It counts
the refusals the error burst counts at the decision too (see "Attack the refusals the error burst counts at the decision too (`refusal_limit`;
detection"). see "Attack detection").
In `SWW_MODE=observe`, every refusal above is logged as what would have In `SWW_MODE=observe`, every refusal above is logged as what would have
happened, nothing is refused, and `discriminator` makes no ban of its own. A happened, nothing is refused, and `discriminator` makes no ban of its own. A
@@ -279,7 +293,8 @@ no offence:
- `SWW_DENY_NETS`; - `SWW_DENY_NETS`;
- a blocklist or reputation hit whose action is `deny`; - a blocklist or reputation hit whose action is `deny`;
- the country lists. - the country list;
- `SWW_TAG_DENY`.
Every ban carries notes with what an admin needs to decide whether to lift it, Every ban carries notes with what an admin needs to decide whether to lift it,
drawn from what `discriminator` already knows. No lookup is made for the notes drawn from what `discriminator` already knows. No lookup is made for the notes
@@ -386,7 +401,7 @@ Conventions:
### Static lists ### Static lists
- `SWW_ALLOW_NETS` skips the checks step 3 names, for monitoring or the owner's - `SWW_ALLOW_NETS` skips the checks step 4 names, for monitoring or the owner's
own networks. `SWW_RATE_LIMIT_EXEMPT_NETS` bypasses the request and byte own networks. `SWW_RATE_LIMIT_EXEMPT_NETS` bypasses the request and byte
limits only; the error burst, attack detection and bans still apply. limits only; the error burst, attack detection and bans still apply.
- Many people can share one address, as in an office, on a mobile carrier or - Many people can share one address, as in an office, on a mobile carrier or
@@ -436,7 +451,7 @@ the same way.
By default there is no minute limit, so a client can download in a burst at full By default there is no minute limit, so a client can download in a burst at full
speed. The hour limit defaults to 50 GiB and the day limit to 100 GiB. They speed. The hour limit defaults to 50 GiB and the day limit to 100 GiB. They
allow several downloads of a few GiB each. A response's bytes are counted when allow several downloads of a few GiB each. A response's bytes are counted when
it ends (see step 15). it ends (see step 18).
### Lookup of AS number and country ### Lookup of AS number and country
@@ -465,12 +480,12 @@ service that keeps its visitors' addresses on its own host.
dropped from that table. The answer reaches the client's history and ban notes dropped from that table. The answer reaches the client's history and ban notes
when it comes. when it comes.
- `SWW_LOOKUP_TIMEOUT` is how long a visitor's request waits for its client's - `SWW_LOOKUP_TIMEOUT` is how long a visitor's request waits for its client's
first answer, when a setting needs it (see step 5). It also bounds each first answer, when a setting needs it (see step 6). It also bounds each
request to GeoJS. A client whose answer has not come in time counts as unknown request to GeoJS. A client whose answer has not come in time counts as unknown
until it comes, and its later requests do not wait. Its address is asked about until it comes, and its later requests do not wait. Its address is asked about
again in the background. again in the background.
- A client the lookup cannot place has an unknown country and AS number, which - A client the lookup cannot place has an unknown country and AS number, which
the country lists and the biased thresholds treat as described there. Nothing the country list and the biased thresholds treat as described there. Nothing
else treats it differently. else treats it differently.
- GeoJS publishes no rate limit, but its terms forbid "an excessive amount of - GeoJS publishes no rate limit, but its terms forbid "an excessive amount of
API requests", judged by GeoJS alone, and let it block a caller. While GeoJS API requests", judged by GeoJS alone, and let it block a caller. While GeoJS
@@ -490,12 +505,12 @@ service that keeps its visitors' addresses on its own host.
`file_error` alert and ignored. The database already loaded stays in use. A `file_error` alert and ignored. The database already loaded stays in use. A
client whose address the file does not hold counts as unknown. client whose address the file does not hold counts as unknown.
### Country lists ### Country list
Both are empty by default: the defaults judge a client by what it does, not by `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` is empty by default: the defaults judge a
where it comes from. client by what it does, not by where it comes from. A `country=` tag in
`SWW_TAG_DENY` refuses one country (see "Request tags").
- `SWW_DENIED_COUNTRIES` refuses every request from a listed country.
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` lets through only clients placed in a - `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` lets through only clients placed in a
listed country. Every request from any other country is refused. So is every listed country. Every request from any other country is refused. So is every
request from a client the lookup cannot place, such as internal monitoring or request from a client the lookup cannot place, such as internal monitoring or
@@ -504,45 +519,84 @@ where it comes from.
client in whenever GeoJS stops answering. The cost: while GeoJS fails, this client in whenever GeoJS stops answering. The cost: while GeoJS fails, this
list refuses every new client. No other failure of GeoJS, a reputation source list refuses every new client. No other failure of GeoJS, a reputation source
or an alert destination comes this close to taking the service down. or an alert destination comes this close to taking the service down.
- Both may be set. `SWW_DENIED_COUNTRIES` then adds nothing, since the exclusive - A code in this list whose `country=` tag is in `SWW_TAG_DENY` stops the start
list already refuses every other country. A code on both lists stops the start
with a message naming it. with a message naming it.
- A refused request is answered with `SWW_BAN_RESPONSE`, as soon as the client's - A refused request is answered with `SWW_BAN_RESPONSE`, as soon as the client's
address has been looked up. It is logged with the action `country_denied`, and address has been looked up. It is logged with the action `country_denied`, and
counted in the metrics. counted in the metrics.
### Request tags
Each request gets tags from what `discriminator` knows of its client and what
the checks found. The operator refuses or slows clients by tag. Tags appear in
the log line as `tags`, sorted, with empty lists left out (see
`docs/request-log.md`).
Each tag and where it comes from:
- `asn=<number>` and `country=<code>`, such as `asn=64496` and `country=DE`,
from the lookup. A client the lookup cannot place has neither. A request
refused by `deny_nets` or `active_ban` has neither, because those checks come
before the lookup.
- One tag per list of `SWW_TAG_LISTS` that holds the client (`tag_lists`), the
tag being the list's name, such as `torexit` or `vpn=mullvad`. These lists are
fetched and kept like a blocklist. `discriminator` ships none of its own.
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
the client, whatever the source's action. A zone with a key is named with the
key masked, as the `reputation` field gives it.
- `exploitsignature`, when a rule file or the Core Rule Set matched the request.
For the Core Rule Set a match is a score at or over
`SWW_WAF_ANOMALY_THRESHOLD`.
A client in `SWW_ALLOW_NETS` skips the checks and gets no tag. Every tag is in
lower case, except a country code, which is in capitals. `SWW_TAG_DENY` and
`SWW_TAG_LIMIT_PERCENT` accept tags in either case.
Two settings act on tags:
- `SWW_TAG_DENY` lists tags whose requests are refused with the action `denied`
(`tag_deny`). The check runs twice: just before the rate limits, and again
after the Core Rule Set, so that `exploitsignature` can refuse too.
- `SWW_TAG_LIMIT_PERCENT` lists tags with a percentage from 1 to 99. Their
clients get that percentage of every request and byte limit. The lowest
percentage among a client's tags applies. `exploitsignature` cannot be listed,
since a request gets it only after the rate limits have counted it.
The tag settings are empty by default, for the same reason as the country list.
The lookup waits for GeoJS when either names an `asn=` or `country=` tag. A
setting that names one stops the start while `SWW_LOOKUP_SOURCE` is `off`.
### Biased thresholds ### Biased thresholds
The biased thresholds lower the limits of clients in listed AS numbers and The biased thresholds lower the limits of clients by AS number, country and tag.
countries. The lists are empty by default, for the same reason as the country The lists are empty by default, for the same reason as the country list. The
lists. The request log and the metrics show which AS numbers and countries a request log and the metrics show which AS numbers and countries a service's
service's abuse comes from. abuse comes from.
- Each list gives AS numbers or countries a percentage, such as `AS14061:50`. - `SWW_TAG_LIMIT_PERCENT` gives a tag a percentage, such as `asn=14061:50` (see
Their clients get that percentage of every request and byte limit "Request tags"). A lowered limit is rounded down.
(`SWW_ASN_LIMIT_PERCENT`, `SWW_COUNTRY_LIMIT_PERCENT`). A lowered limit is - `SWW_UNKNOWN_LIMIT_PERCENT` at `0` is a zero allowance: the client's first
rounded down. `0` is a zero allowance: the client's first request breaks a request breaks a limit and bans it.
limit and bans it.
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` set the byte limits' - `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` set the byte limits'
percentage alone, when it should differ from the request limits'. For an AS percentage alone, when it should differ from the request limits'. For an AS
number they list, they replace its percentage from `SWW_ASN_LIMIT_PERCENT` and number they list, they replace its `asn=` tag's percentage and its entry in
`SWW_ASN_LIMIT_PERCENT_URL`; for a country, the one from `SWW_ASN_LIMIT_PERCENT_URL`; for a country, its `country=` tag's percentage.
`SWW_COUNTRY_LIMIT_PERCENT`.
- `SWW_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so one - `SWW_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so one
abuse-source list can be shared by every `discriminator` in the fleet. It is abuse-source list can be shared by every `discriminator` in the fleet. It is
fetched and kept like a blocklist (see "Reputation"). Where it and fetched and kept like a blocklist (see "Reputation"). Each entry counts like
`SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`. Where both give a percentage, the
lower applies.
- A client's limit percentage is the lowest of those that apply to it: its AS - A client's limit percentage is the lowest of those that apply to it: its AS
number's, its country's, or a reputation hit's `limit:<percent>`. A client number's, its country's, its tags', or a reputation hit's `limit:<percent>`. A
without a country gets `SWW_UNKNOWN_LIMIT_PERCENT` in place of a country's. client without a country gets `SWW_UNKNOWN_LIMIT_PERCENT` in place of a
The percentage is 100 if none applies. Clients given a lower percentage are country's. The percentage is 100 if none applies. Clients given a lower
banned after fewer requests, first for a while and then, for repeated abuse, percentage are banned after fewer requests, first for a while and then, for
permanently. The log line of each counted request, and the notes of each ban, repeated abuse, permanently. The log line of each counted request, and the
say which setting lowered the limit. notes of each ban, say which setting lowered the limit.
- No budget is shared by a whole country or AS number. One abuser could use it - No budget is shared by a whole country or AS number. One abuser could use it
up and lock out everyone else there: a denial of service nobody chose, and the up and lock out everyone else there: a denial of service nobody chose, and the
one this tool exists to prevent. Refusing a whole country is left to the one this tool exists to prevent. Refusing a whole country is left to the
operator's choice, through the country lists. operator's choice, through `SWW_TAG_DENY` or the country list.
### Attack detection ### Attack detection
@@ -584,7 +638,7 @@ files, the limits and the bans still apply to the client that sends it.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as - `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as
Coraza directives. An exclusion switches one rule off for some fields or paths Coraza directives. An exclusion switches one rule off for some fields or paths
only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file
holds an app's own exclusions; the eighth change below holds gitea's. holds an app's own exclusions; gitea's fixes below hold gitea's.
- `discriminator` loads the file after `smallwebwaf`'s own setup rules and - `discriminator` loads the file after `smallwebwaf`'s own setup rules and
before the Core Rule Set's rules. The Core Rule Set loads its own before the Core Rule Set's rules. The Core Rule Set loads its own
`REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point. `REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point.
@@ -630,7 +684,7 @@ files, the limits and the bans still apply to the client that sends it.
scanner from an ordinary client. In front of a code forge, registry and git scanner from an ordinary client. In front of a code forge, registry and git
clients get `404` and `401` by design, hundreds of times a minute. clients get `404` and `401` by design, hundreds of times a minute.
`smallwebwaf` changes the Core Rule Set 4.25.0 in eight ways, since in front of `smallwebwaf` changes the Core Rule Set 4.25.0 in six ways, since in front of
gitea it would otherwise refuse ordinary requests. The changes hold in front of gitea it would otherwise refuse ordinary requests. The changes hold in front of
every app, and no setting undoes them: every app, and no setting undoes them:
@@ -649,8 +703,52 @@ every app, and no setting undoes them:
and tea, which gitea registers for OAuth sign-in, ask to be sent back to and tea, which gitea registers for OAuth sign-in, ask to be sent back to
`http://127.0.0.1` on the user's own machine. The server never fetches that `http://127.0.0.1` on the user's own machine. The server never fetches that
address. address.
4. Ten query parameters are not checked against the Core Rule Set's lists of 4. `Referer` is not checked for a Unix command given without arguments (932340),
system files (930120), shell paths (932160) and command names (932260): for Java starting a process (944110) or for a Java class name (944130).
`Referer` names the page a request comes from, on everything that page loads
and on every link followed from it. The Core Rule Set takes some page
addresses for attacks:
- a search for `env`;
- OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`;
- a file named `java.lang.String.md`.
Every request from such a page would be refused. `Referer` keeps the other
rules, except after some gitea searches (gitea's fixes). An app that passes
`Referer` to a shell must check it itself.
5. Responses are not inspected. A raw file from a repository, such as a shell
script, looks to the response rules like source code leaking from the server.
6. The `from` parameter of a container registry's upload path,
`/v2/<image>/blobs/uploads/`, is not checked for command names (932260). When
a push reuses a layer, `from` names the image the layer comes from. An image
such as `owner/python3-app` would have that push refused.
Each supported app also has its own fixes, which let its ordinary requests
through. An app's fixes hold for the hostnames `SWW_APPS` lists the app for:
- `SWW_APPS` holds entries separated by semicolons. Each is a hostname, a colon,
then one or more apps separated by commas, for example
`git.example.com:gitea;code.example.com:gitea`.
- The request's `Host` is matched by exact name, without its port and without
regard to case. There are no wildcards.
- A hostname not listed gets no app's fixes, so every rule checks it in full.
Set empty, `SWW_APPS` lists no hostname.
- Unset, every app's fixes hold for every hostname. An app's fixes then weaken
the check of any other app that serves the same paths or fields.
- Any of these mistakes stops the start:
- an unknown app;
- a hostname that is not a DNS name, such as `*.example.com`;
- a hostname listed twice;
- an app listed twice for one hostname;
- an entry without a colon.
`gitea` is the only app supported. Each app's fixes are in one file, such as
`internal/waf/gitea.conf`, and one entry of `apps` in `internal/waf/waf.go`.
gitea's fixes are:
1. Ten query parameters are not checked against the Core Rule Set's lists of
system files (930120), shell paths (932160) and command names (932260), on
any path:
- `path`, `files`, `skip-to` and `sub_path`; - `path`, `files`, `skip-to` and `sub_path`;
- `ref`, `sha`, `branch` and `workflow`; - `ref`, `sha`, `branch` and `workflow`;
- `artifactName` and `redirect_to`. - `artifactName` and `redirect_to`.
@@ -663,41 +761,18 @@ every app, and no setting undoes them:
catch reaches the app, such as the command `|cat /etc/passwd`. Path catch reaches the app, such as the command `|cat /etc/passwd`. Path
traversal, SQL and script injection, and PHP, Java and Node.js code are traversal, SQL and script injection, and PHP, Java and Node.js code are
still refused there. Other parameters keep all three rules, except on the still refused there. Other parameters keep all three rules, except on the
gitea paths the eighth change names. An app that uses one of these gitea paths the third fix names.
parameters as a file on the server, or passes it to a shell, must check the
value itself. A rule file can refuse it instead.
5. Two cookies are left out, and `Referer` keeps fewer rules: 2. 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. gitea puts a message naming what was just done into `redirect_to` cookies. gitea puts a message naming what was just done into
`gitea_flash`. It puts into `redirect_to` the page to return to after `gitea_flash`. It puts into `redirect_to` the page to return to after signing
signing in. The Core Rule Set takes file names in them, such as in. The Core Rule Set takes file names in them, such as `package.json`, for
`package.json`, for attacks. A browser sends such a cookie with every attacks. A browser sends such a cookie with every request until gitea
request until gitea replaces it. gitea cannot replace it while those replaces it. gitea cannot replace it while those requests are refused, so one
requests are refused, so one refusal would keep the browser out of the refusal would keep the browser out of the whole site. gitea shows the message
whole site. gitea shows the message with any script removed and returns with any script removed and returns only to its own pages. Every other cookie
only to its own pages. Every other cookie is read in full. An app that is read in full.
reads a cookie of either name must check it itself. 3. On gitea's paths, some fields skip the rules that refused ordinary gitea
- `Referer` is not checked for a Unix command given without arguments
(932340), for Java starting a process (944110) or for a Java class name
(944130). `Referer` names the page a request comes from, on everything
that page loads and on every link followed from it. The Core Rule Set
takes some page addresses for attacks:
- a search for `env`;
- OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`;
- a file named `java.lang.String.md`.
Every request from such a page would be refused. `Referer` keeps the
other rules, except after some gitea searches (the eighth change). An
app that passes `Referer` to a shell must check it itself.
6. Responses are not inspected. A raw file from a repository, such as a shell
script, looks to the response rules like source code leaking from the server.
7. The `from` parameter of a container registry's upload path,
`/v2/<image>/blobs/uploads/`, is not checked for command names (932260). When
a push reuses a layer, `from` names the image the layer comes from. An image
such as `owner/python3-app` would have that push refused.
8. On gitea's paths, some fields skip the rules that refused ordinary gitea
requests there. They are the fields where gitea sends a search, a file name requests there. They are the fields where gitea sends a search, a file name
or a branch name. `internal/waf/gitea.conf` lists each rule and the request or a branch name. `internal/waf/gitea.conf` lists each rule and the request
it refused. The fields are: it refused. The fields are:
@@ -715,8 +790,7 @@ every app, and no setting undoes them:
- packages. - packages.
A user's or an organisation's page is any path of one segment, so on A user's or an organisation's page is any path of one segment, so on
every such path, on any app, `q` skips these three rules, for example on every such path `q` skips these three rules, for example on `/search`.
`/search`.
Some of these searches also skip the rule for Java class names (944130), Some of these searches also skip the rule for Java class names (944130),
which refused `org.apache.commons.logging`. which refused `org.apache.commons.logging`.
@@ -754,11 +828,14 @@ every app, and no setting undoes them:
`actions/upload-artifact@v3` (920450). `actions/upload-artifact@v3` (920450).
Elsewhere these fields keep every rule. The paths are those of gitea at the Elsewhere these fields keep every rule. The paths are those of gitea at the
site root. An app other than gitea that serves one of them must check these site root.
fields itself.
The parameter and cookie names in these changes are matched without regard to On a hostname that gets gitea's fixes, an app other than gitea must check these
case, as Coraza matches them. fields and cookies itself. Listing each hostname's own apps in `SWW_APPS` keeps
the full check for the others.
The parameter names in these changes and fixes are matched without regard to
case, and the cookie names exactly.
### Ban lengths, response and scope ### Ban lengths, response and scope
@@ -897,7 +974,7 @@ left unset sends no alert. An alert needs a destination only the operator can
supply, and what counts as unusual depends on each service's normal traffic, supply, and what counts as unusual depends on each service's normal traffic,
which the metrics show. They reveal a widely distributed scraper, thousands of which the metrics show. They reveal a widely distributed scraper, thousands of
addresses at low rates each, which the per-client limits do not see. A low addresses at low rates each, which the per-client limits do not see. A low
`SWW_ASN_LIMIT_PERCENT` for its AS number is the response. percentage for its `asn=` tag in `SWW_TAG_LIMIT_PERCENT` is the response.
- Requests and bytes are counted per minute and per hour, for each of: - Requests and bytes are counted per minute and per hour, for each of:
- a client; - a client;
@@ -1175,7 +1252,7 @@ the metrics.
### The answer ### The answer
`discriminator` runs steps 3 to 13 of "What happens to a request", in that `discriminator` runs steps 3 to 16 of "What happens to a request", in that
order. Every answer but the last below carries `X-Sww-Disposition`. order. Every answer but the last below carries `X-Sww-Disposition`.
- `X-Sww-Disposition: allow`, status `200`, no body. `rproxy` passes the request - `X-Sww-Disposition: allow`, status `200`, no body. `rproxy` passes the request
@@ -1214,13 +1291,13 @@ seconds, and `SWRP_DEFAULT_DISPOSITION` then decides. So at the defaults,
and whatever its outside sources do. and whatever its outside sources do.
GeoJS is the only outside source a question can wait on. A question waits only GeoJS is the only outside source a question can wait on. A question waits only
when step 5 says so, and for at most `SWW_LOOKUP_TIMEOUT`, by default 800 when step 6 says so, and for at most `SWW_LOOKUP_TIMEOUT`, by default 800
milliseconds. A client without an answer by then counts as unknown. No question milliseconds. A client without an answer by then counts as unknown. No question
waits on these, which run in the background: waits on these, which run in the background:
- the DNS blocklists and AbuseIPDB; - the DNS blocklists and AbuseIPDB;
- fetching the blocklists, the CrowdSec decision list and the file - fetching the blocklists, the tag lists, the CrowdSec decision list and the
`SWW_ASN_LIMIT_PERCENT_URL` names. file `SWW_ASN_LIMIT_PERCENT_URL` names.
In `enforce` mode, a question does not wait for GeoJS when it is a request: In `enforce` mode, a question does not wait for GeoJS when it is a request:
-4
View File
@@ -9,10 +9,6 @@
ARG SWW_IMAGE=smallwebwaf ARG SWW_IMAGE=smallwebwaf
FROM ${SWW_IMAGE} FROM ${SWW_IMAGE}
# Its own title and description, in place of the smallwebwaf image's.
LABEL org.opencontainers.image.title="smallwebwaf-example-app" \
org.opencontainers.image.description="An example app built on the smallwebwaf image."
# Packages the app needs, from the nixpkgs in the image. # Packages the app needs, from the nixpkgs in the image.
RUN nix-env -iA nixpkgs.busybox RUN nix-env -iA nixpkgs.busybox
+34 -2
View File
@@ -96,8 +96,8 @@ Every one of these lines has:
- `action`: what was done: - `action`: what was done:
- `forward`: answered `allow`, so that `rproxy` passes it to the app; - `forward`: answered `allow`, so that `rproxy` passes it to the app;
- `denied`: refused for `SWW_DENY_NETS`, or for a reputation source with the - `denied`: refused for `SWW_DENY_NETS`, for a reputation source with the
action `deny`; action `deny`, or for a tag in `SWW_TAG_DENY`;
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a - `banned`: refused under a ban, or for a `ban` rule, a trap path or a
CrowdSec decision, each of which bans the client; CrowdSec decision, each of which bans the client;
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`: - `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
@@ -108,6 +108,36 @@ Every one of these lines has:
- `would_action`: in `observe` mode, what `enforce` mode would have done with a - `would_action`: in `observe` mode, what `enforce` mode would have done with a
request it would have refused. `action` then names what was done, normally request it would have refused. `action` then names what was done, normally
`forward`. `forward`.
- `heuristic`: the heuristic that refused the request, in `observe` mode too. It
is left out when none did.
- `heuristic_figures`: an object of the figures that heuristic acted on. Each
heuristic and its figures:
- `deny_nets`: `netblock`, the entry of `SWW_DENY_NETS` the client is in;
- `active_ban`: `netblock`, the ban's netblock, and `ban_expires`;
- `countries`: `country`, and `setting`, the country list that refused it;
- `tag_deny`: `tag`, the tag of `SWW_TAG_DENY` the request carries;
- `blocklists`: `lists`, the blocklist URLs that list the client;
- `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the
decision ends;
- `dnsbl`: `zones`, the DNS blocklist zones that list the client, their keys
masked;
- `abuseipdb`: `score`, and `min_score`, `SWW_ABUSEIPDB_MIN_SCORE`;
- `rate_limits`: `window`, `count` and `limit`;
- `trap_paths`: `path`, the trap path asked for;
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
- `core_rule_set`: `score`, and `threshold`, `SWW_WAF_ANOMALY_THRESHOLD`;
- `refusal_limit`: `window`, `count` and `limit`, of the error burst;
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
- `after_answer_heuristic`: the heuristic that banned the client after
`discriminator` answered the request, in `observe` mode too: `refusal_limit`
or `byte_limits`.
- `after_answer_heuristic_figures`: an object of the figures that heuristic
acted on, as listed above.
`byte_limits` bans when the report comes, so only the `report` line names
it.
- `limit_percent` and `limit_percent_setting`: the client's percentage of the - `limit_percent` and `limit_percent_setting`: the client's percentage of the
rate limits and the setting that lowered it, when one did. `bytes_percent` and rate limits and the setting that lowered it, when one did. `bytes_percent` and
`bytes_percent_setting` do the same for the byte limits. `bytes_percent_setting` do the same for the byte limits.
@@ -139,6 +169,8 @@ Every one of these lines has:
- the CrowdSec decision list's URL; - the CrowdSec decision list's URL;
- DNS blocklist zones; - DNS blocklist zones;
- `abuseipdb`. - `abuseipdb`.
- `tags`: the request's tags, sorted, left out when it has none. Each tag and
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
- `offence`: `limit`, for a request that broke a limit. - `offence`: `limit`, for a request that broke a limit.
- `ban_expires`: when the client's ban ends, for a request that made one or was - `ban_expires`: when the client's ban ends, for a request that made one or was
refused under one: a time, or `permanent`. refused under one: a time, or `permanent`.
+70 -17
View File
@@ -90,11 +90,11 @@ between the two (see "The image's command" below).
## Static lists ## Static lists
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring - `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
(see step 3 in [`SPEC.md`](../SPEC.md)). (see step 4 in [`SPEC.md`](../SPEC.md)).
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are - `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
exempt from rate limiting and the byte limits. exempt from rate limiting and the byte limits.
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step - `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
3 in [`SPEC.md`](../SPEC.md)). 4 in [`SPEC.md`](../SPEC.md)).
## Request rate limits, per client ## Request rate limits, per client
@@ -130,9 +130,11 @@ between the two (see "The image's command" below).
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops - `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
the start while a setting that needs a lookup is set: the start while a setting that needs a lookup is set:
- the two country lists; - `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
- `SWW_ADD_LOOKUP_HEADERS`; - `SWW_ADD_LOOKUP_HEADERS`;
- the four biased threshold lists and `SWW_ASN_LIMIT_PERCENT_URL`; - `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming an `asn=` or `country=`
tag;
- the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`;
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`; - `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
- an anomaly threshold per AS number. - an anomaly threshold per AS number.
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its - `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
@@ -144,28 +146,61 @@ between the two (see "The image's command" below).
the client's AS number in `X-Client-ASN` and its country in the client's AS number in `X-Client-ASN` and its country in
`X-Client-Country`. `X-Client-Country`.
## Country lists ## Country list
- `SWW_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
such as `cn,ru,kp`.
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only - `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
countries whose clients get through, such as `us,de`. A country on both lists countries whose clients get through, such as `us,de`. A country whose
stops the start. `country=` tag is in `SWW_TAG_DENY` stops the start.
## Request tags
Every request gets tags from what `discriminator` knows of its client. See
"Request tags" in [`SPEC.md`](../SPEC.md).
- `SWW_TAG_LISTS` (default empty): address lists that each add a tag, as
`name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the
tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or
`https` URL fetched like a blocklist. A name or URL listed twice is refused.
So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`,
or the CrowdSec decision list.
- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as
`torexit,country=CN`. A tag listed twice, an `asn=` tag without a number, or a
`country=` tag without a country, is refused.
- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as
tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from
`1` to `99`, of every rate limit and byte limit. The lowest percentage among a
client's tags applies. A tag listed twice is refused. So is
`exploitsignature`: a request gets that tag after the rate limits have counted
it.
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag
that no request can get stops the start. A request can get only these:
- a name in `SWW_TAG_LISTS`;
- an `asn=` or `country=` tag;
- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key
masked as the `reputation` field shows it;
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set;
- `exploitsignature`, in `SWW_TAG_DENY` only, while `SWW_RULES_ENABLED` is
`true`, or while neither `SWW_WAF_MODE` nor `SWW_WAF_ANOMALY_THRESHOLD` is
`off`.
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses
the request first.
## Biased thresholds ## Biased thresholds
- `SWW_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:` - `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number percentage of the byte limits alone, for the AS numbers or countries listed,
listed twice is refused. such as `AS14061:50` or `cn:25`. It takes the place of the percentage
- `SWW_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as `SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or
`cn:25,ru:50`. country listed twice is refused.
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): the
same, for the byte limits alone.
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the - `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
lookup cannot place. lookup cannot place.
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a - `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
text file with one AS number and percentage on each line, such as text file with one AS number and percentage on each line, such as
`AS64496:50`. A URL that is also in `SWW_BLOCKLIST_URLS` is refused. `AS64496:50`. Each entry counts like an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`.
A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
## Attack detection ## Attack detection
@@ -198,6 +233,24 @@ between the two (see "The image's command" below).
a range. So does a directive that removes or changes rules as the file loads, a range. So does a directive that removes or changes rules as the file loads,
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
yet. It has no `_FILE` form. yet. It has no `_FILE` form.
- `SWW_APPS` (default unset): the apps each hostname serves. A request gets the
fixes of its hostname's apps only (see "Attack detection" in
[`SPEC.md`](../SPEC.md)).
- Entries are separated by semicolons. Each is a hostname, a colon, then one
or more apps separated by commas, for example
`git.example.com:gitea;code.example.com:gitea`.
- `gitea` is the only app supported.
- The request's `Host` is matched by exact name, without its port and
without regard to case. There are no wildcards.
- A hostname not listed gets no app's fixes, so every rule checks it in
full. Set empty, it lists no hostname.
- Unset, every app's fixes apply to every hostname.
- Any of these mistakes stops the start:
- an unknown app;
- a hostname that is not a DNS name, such as `*.example.com`;
- a hostname listed twice;
- an app listed twice for one hostname;
- an entry without a colon.
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client in - `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client in
a minute (see "Attack detection" in `SPEC.md`). A count or `off`. a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
+487 -57
View File
@@ -29,6 +29,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/remotelog" "sneak.berlin/go/smallwebwaf/internal/remotelog"
"sneak.berlin/go/smallwebwaf/internal/waf"
) )
// Config is the settings of `sww discriminator`. A time or size limit of // Config is the settings of `sww discriminator`. A time or size limit of
@@ -93,29 +94,30 @@ type Config struct {
LookupDBPath string LookupDBPath string
LookupTimeout time.Duration LookupTimeout time.Duration
AddLookupHeaders bool AddLookupHeaders bool
// DeniedCountries are the countries whose clients are refused // ExclusivelyAllowedCountries, when not empty, are the only countries
// (SWW_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not // whose clients are let through (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES),
// empty, are the only countries whose clients are let through // two-letter codes in capitals, as GeoJS gives them.
// (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES). Both hold two-letter codes in
// capitals, as GeoJS gives them.
DeniedCountries []string
ExclusivelyAllowedCountries []string ExclusivelyAllowedCountries []string
// The biased thresholds. ASNLimitPercent and CountryLimitPercent give // TagLists are the address lists that each add a tag to the requests of
// the clients of the AS numbers and the countries they list that // the clients they hold (SWW_TAG_LISTS). TagDeny are the tags whose
// percentage of every rate limit and byte limit // requests are refused (SWW_TAG_DENY). TagLimitPercent gives the clients
// (SWW_ASN_LIMIT_PERCENT and SWW_COUNTRY_LIMIT_PERCENT). // with a tag that percentage of every rate limit and byte limit, from 1
// ASNBytesPercent and CountryBytesPercent give those they list a // to 99 (SWW_TAG_LIMIT_PERCENT). All hold tags as requests carry them: in
// percentage of the byte limits in place of that one // lower case, a country code in capitals.
TagLists []TagList
TagDeny []string
TagLimitPercent map[string]int64
// The biased thresholds. ASNBytesPercent and CountryBytesPercent give
// those they list a percentage of the byte limits in place of the one
// TagLimitPercent gives their asn= or country= tag
// (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds // (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds
// percentages from 0 to 100, by AS number, written as AS64496, or by // percentages from 0 to 100, by AS number, written as AS64496, or by
// country, a two-letter code in capitals, as the lookup gives them. // country, a two-letter code in capitals, as the lookup gives them.
// UnknownLimitPercent is the percentage of every limit a client without // UnknownLimitPercent is the percentage of every limit a client without
// a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is // a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
// where a file of AS:percent lines is fetched from, whose percentages // where a file of AS:percent lines is fetched from, whose percentages
// count as those of ASNLimitPercent do (SWW_ASN_LIMIT_PERCENT_URL), "" // count like asn= tags in TagLimitPercent (SWW_ASN_LIMIT_PERCENT_URL),
// while it is unset. // "" while it is unset.
ASNLimitPercent map[string]int64
CountryLimitPercent map[string]int64
ASNBytesPercent map[string]int64 ASNBytesPercent map[string]int64
CountryBytesPercent map[string]int64 CountryBytesPercent map[string]int64
UnknownLimitPercent int64 UnknownLimitPercent int64
@@ -164,8 +166,8 @@ type Config struct {
// 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
// (SWW_BAN_RESPONSE). It answers a banned client, a request that // (SWW_BAN_RESPONSE). It answers a banned client, a request that
// breaks a rate limit or matches a ban rule, SWW_DENY_NETS and the // breaks a rate limit or matches a ban rule, SWW_DENY_NETS,
// country lists. // SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY.
BanResponse int BanResponse int
// LimitBanDuration is the ban for a first broken rate limit // LimitBanDuration is the ban for a first broken rate limit
// (SWW_LIMIT_BAN_DURATION). A limit broken again within // (SWW_LIMIT_BAN_DURATION). A limit broken again within
@@ -230,6 +232,11 @@ type Config struct {
WAFExemptPaths []string WAFExemptPaths []string
WAFBodyLimit int64 WAFBodyLimit int64
CoreRuleSetExclusionsFile string CoreRuleSetExclusionsFile string
// Apps are the names of the apps each hostname serves, by hostname in
// lowercase (SWW_APPS): a request gets only its hostname's apps' fixes
// to the Core Rule Set. Apps is nil while SWW_APPS is unset, and every
// request gets every app's fixes.
Apps map[string][]string
// TrapPaths are the paths a request for which is a clear sign of // TrapPaths are the paths a request for which is a clear sign of
// attack (SWW_TRAP_PATHS), each starting with / and without a ?. // attack (SWW_TRAP_PATHS), each starting with / and without a ?.
TrapPaths []string TrapPaths []string
@@ -367,7 +374,7 @@ var (
errHeaderTakenOut = errors.New( errHeaderTakenOut = errors.New(
"is taken out of every request by Go's HTTP server, so it can never " + "is taken out of every request by Go's HTTP server, so it can never " +
"be logged") "be logged")
errOnBothLists = errors.New("is in SWW_DENIED_COUNTRIES too") errOnBothLists = errors.New("is in SWW_TAG_DENY too")
errNotLookupSource = errors.New("is not geojs, file or off") errNotLookupSource = errors.New("is not geojs, file or off")
errNeedsLookups = errors.New("it needs each client looked up") errNeedsLookups = errors.New("it needs each client looked up")
errNeedsDBPath = errors.New("it names the file to look clients up in") errNeedsDBPath = errors.New("it names the file to look clients up in")
@@ -397,18 +404,26 @@ var (
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( errNotTrapPath = errors.New(
"is not a path starting with / and without a ?, such as /wp-login.php") "is not a path starting with / and without a ?, such as /wp-login.php",
)
errNotAppsEntry = errors.New(
"is not a hostname, : and a list of apps, such as git.example.com:gitea",
)
errNotHostname = errors.New("is not a DNS name such as git.example.com")
errNotApp = errors.New("is not an app sww has fixes for; the apps are")
errNotWAFMode = errors.New("is not off, detect or block") errNotWAFMode = errors.New("is not off, detect or block")
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4") errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
errNotRuleID = errors.New( errNotRuleID = errors.New(
"is not the id of a Core Rule Set rule, a whole number such as 942100") "is not the id of a Core Rule Set rule, a whole number such as 942100",
)
errSetupRuleID = errors.New( errSetupRuleID = errors.New(
"is from 900000 to 900999, the ids of the rules that set the Core Rule Set " + "is from 900000 to 900999, the ids of the rules that set the Core Rule Set " +
"up and of sww's own, which cannot be switched off") "up and of sww'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, " +
"and nothing more, such as syslog+tls://logs.example:6514") "and nothing more, such as syslog+tls://logs.example:6514",
)
errNoCertificate = errors.New("holds no PEM certificate") errNoCertificate = errors.New("holds no PEM certificate")
errNotFacility = errors.New("is not a syslog facility such as local0 or daemon") errNotFacility = errors.New("is not a syslog facility such as local0 or daemon")
errNotAppName = errors.New( errNotAppName = errors.New(
@@ -421,16 +436,21 @@ var (
"is not a header name followed by : and the header's value, " + "is not a header name followed by : and the header's value, " +
"such as Authorization:Bearer <token>") "such as Authorization:Bearer <token>")
errControlCharacter = errors.New( errControlCharacter = errors.New(
"holds a control character, such as the carriage return of a Windows line end") "holds a control character, such as the carriage return of a Windows line end",
)
errNotAlertEvent = errors.New( errNotAlertEvent = errors.New(
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " + "is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
"source_failure or file_error") "source_failure or file_error")
errNotNumberOrOff = errors.New("is not a whole number above zero, such as 60, or off") errNotNumberOrOff = errors.New(
"is not a whole number above zero, such as 60, or off",
)
errNotUTF8 = errors.New("is not valid UTF-8") errNotUTF8 = errors.New("is not valid UTF-8")
errNotASN = errors.New("is not an AS number such as AS64496") errNotASN = errors.New("is not an AS number such as AS64496")
errNotPercentItem = errors.New( errNotPercentItem = errors.New(
"is not a code, : and a percentage, such as AS64496:50 or cn:25") "is not a code, : and a percentage, such as AS64496:50 or cn:25")
errNotPercent = errors.New("is not a percentage, a whole number from 0 to 100") errNotPercent = errors.New(
"is not a percentage, a whole number from 0 to 100",
)
errListedTwice = errors.New("is listed twice") errListedTwice = errors.New("is listed twice")
errNotListURL = errors.New( errNotListURL = errors.New(
"is not an http or https URL without a user or a fragment, " + "is not an http or https URL without a user or a fragment, " +
@@ -439,11 +459,17 @@ var (
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h") errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
errNotAction = errors.New( errNotAction = errors.New(
"is not deny, limit:<percent> such as limit:25, or log") "is not deny, limit:<percent> such as limit:25, or log")
errNotZone = errors.New("is not a DNS zone such as dnsbl.dronebl.org") errNotZone = errors.New(
errZoneTooLong = errors.New("is longer than 189 characters, too long for the " + "is not a DNS zone such as dnsbl.dronebl.org",
"names IPv6 clients are asked about by") )
errNotResolver = errors.New("is not an IP address with an optional port, " + errZoneTooLong = errors.New(
"such as 192.0.2.53 or [2001:db8::53]:5353") "is longer than 189 characters, too long for the " +
"names IPv6 clients are asked about by",
)
errNotResolver = errors.New(
"is not an IP address with an optional port, " +
"such as 192.0.2.53 or [2001:db8::53]:5353",
)
errNotLAPIURL = errors.New( errNotLAPIURL = errors.New(
"is not an http or https URL without a user or a fragment, " + "is not an http or https URL without a user or a fragment, " +
"such as http://172.17.0.1:8080") "such as http://172.17.0.1:8080")
@@ -451,6 +477,25 @@ var (
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL") errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
errAnotherList = errors.New( errAnotherList = errors.New(
"is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too") "is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too")
errFetchedOtherwise = errors.New("is in SWW_BLOCKLIST_URLS, is " +
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too")
errNotTagList = errors.New(
"is not a name, = and an http or https URL, such as " +
"torexit=https://example.com/tor.txt")
errNotTagPercentItem = errors.New(
"is not a tag, : and a percentage, such as vpn:50 or asn=14061:25")
errNotTagPercent = errors.New("is not a percentage from 1 to 99")
errNotASNTag = errors.New("is not asn= and a number, such as asn=14061")
errExploitTag = errors.New(
"cannot lower limits: the rate limits count a request before its " +
"rules are matched")
errCrowdSecTag = errors.New(
"cannot be used: a CrowdSec decision refuses the request first")
errNoRequestGetsTag = errors.New(
"is a tag no request can get: it is not a name in SWW_TAG_LISTS, an " +
"asn= or country= tag, a dnsbl= tag of a zone in SWW_DNSBL_ZONES, " +
"abuseipdb with SWW_ABUSEIPDB_KEY set, or exploitsignature with " +
"rule files or the Core Rule Set on")
) )
// FromEnvironment reads the settings with lookupEnv, normally // FromEnvironment reads the settings with lookupEnv, normally
@@ -460,7 +505,9 @@ var (
// setting that is set but invalid is an error that names it. // setting that is set but invalid is an error that names it.
// //
//nolint:funlen // one line for each setting, a list that grows with them //nolint:funlen // one line for each setting, a list that grows with them
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) { func FromEnvironment(
lookupEnv func(string) (string, bool),
) (*Config, error) {
env := &environment{lookupEnv: lookupEnv} env := &environment{lookupEnv: lookupEnv}
cfg := &Config{ cfg := &Config{
ListenAddr: env.address("SWW_LISTEN_ADDR"), ListenAddr: env.address("SWW_LISTEN_ADDR"),
@@ -483,11 +530,11 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""), LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""),
LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"), LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"),
AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"), AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"),
DeniedCountries: env.countries("SWW_DENIED_COUNTRIES", ""),
ExclusivelyAllowedCountries: env.countries( ExclusivelyAllowedCountries: env.countries(
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""), "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
ASNLimitPercent: env.percents("SWW_ASN_LIMIT_PERCENT", ParseASN), TagLists: env.tagLists("SWW_TAG_LISTS"),
CountryLimitPercent: env.percents("SWW_COUNTRY_LIMIT_PERCENT", parseCountry), TagDeny: env.tags("SWW_TAG_DENY"),
TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"),
ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN), ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN),
CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry), CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry),
UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"), UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"),
@@ -530,6 +577,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off), WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
CoreRuleSetExclusionsFile: env.filePath( CoreRuleSetExclusionsFile: env.filePath(
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"), "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
Apps: env.apps("SWW_APPS"),
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"), TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"), ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"), LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
@@ -567,6 +615,8 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
env.checkCountriesAndLookups(cfg) env.checkCountriesAndLookups(cfg)
env.checkASNLimitPercentURL(cfg) env.checkASNLimitPercentURL(cfg)
env.checkCrowdSec(cfg) env.checkCrowdSec(cfg)
env.checkTagLists(cfg)
env.checkTags(cfg)
if env.err != nil { if env.err != nil {
return nil, env.err return nil, env.err
@@ -658,7 +708,10 @@ func (e *environment) lookup(name string) (string, bool) {
} }
if set { if set {
e.check(name, fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice)) e.check(
name,
fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice),
)
return value, set return value, set
} }
@@ -780,6 +833,25 @@ func (e *environment) pathPrefixes(name, defaultValue string) []string {
return prefixes return prefixes
} }
// apps reads the setting that is the apps each hostname serves. It is nil
// while the setting is unset, and logged as null, unlike a setting set
// empty.
func (e *environment) apps(name string) map[string][]string {
value, set := e.lookup(name)
if !set {
e.settings = append(e.settings, slog.Any(name, nil))
return nil
}
e.settings = append(e.settings, slog.String(name, value))
apps, err := parseApps(value)
e.check(name, err)
return apps
}
// trapPaths reads the setting that is the list of trap paths. It is empty // trapPaths reads the setting that is the list of trap paths. It is empty
// by default. // by default.
func (e *environment) trapPaths(name string) []string { func (e *environment) trapPaths(name string) []string {
@@ -841,6 +913,31 @@ func (e *environment) percents(
return percents return percents
} }
// tagLists reads SWW_TAG_LISTS, which is empty by default.
func (e *environment) tagLists(name string) []TagList {
lists, err := parseTagLists(e.value(name, ""))
e.check(name, err)
return lists
}
// tags reads a setting that is a list of tags. It is empty by default.
func (e *environment) tags(name string) []string {
tags, err := parseTags(e.value(name, ""))
e.check(name, err)
return tags
}
// tagPercents reads a setting that is a list of tags, each with a
// percentage. It is empty by default.
func (e *environment) tagPercents(name string) map[string]int64 {
percents, err := parseTagPercents(e.value(name, ""))
e.check(name, err)
return percents
}
// percent reads a setting that is a percentage, from 0 to 100. // percent reads a setting that is a percentage, from 0 to 100.
func (e *environment) percent(name, defaultValue string) int64 { func (e *environment) percent(name, defaultValue string) int64 {
percent, err := ParsePercent(e.value(name, defaultValue)) percent, err := ParsePercent(e.value(name, defaultValue))
@@ -916,7 +1013,10 @@ func (e *environment) zones(name string) []string {
logged[i] = MaskZoneKey(zone) logged[i] = MaskZoneKey(zone)
} }
e.settings = append(e.settings, slog.String(name, strings.Join(logged, ","))) e.settings = append(
e.settings,
slog.String(name, strings.Join(logged, ",")),
)
return zones return zones
} }
@@ -975,15 +1075,17 @@ func (e *environment) checkLookupDBPath(cfg *Config) {
} }
} }
// checkCountriesAndLookups refuses a country on both country lists, and, // checkCountriesAndLookups refuses a country in
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES whose tag is in SWW_TAG_DENY, and,
// while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked // while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked
// up: the country lists, SWW_ADD_LOOKUP_HEADERS, the biased thresholds, // up: the country list, SWW_ADD_LOOKUP_HEADERS, the tag settings that name
// an asn= or country= tag, the biased thresholds,
// SWW_ASN_LIMIT_PERCENT_URL among them, of which // SWW_ASN_LIMIT_PERCENT_URL among them, of which
// SWW_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a // SWW_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
// limit, and the anomaly thresholds per AS number. // limit, and the anomaly thresholds per AS number.
func (e *environment) checkCountriesAndLookups(cfg *Config) { func (e *environment) checkCountriesAndLookups(cfg *Config) {
for _, country := range cfg.ExclusivelyAllowedCountries { for _, country := range cfg.ExclusivelyAllowedCountries {
if slices.Contains(cfg.DeniedCountries, country) { if slices.Contains(cfg.TagDeny, CountryTagPrefix+country) {
e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
fmt.Errorf("%q %w", country, errOnBothLists)) fmt.Errorf("%q %w", country, errOnBothLists))
} }
@@ -997,11 +1099,10 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
name string name string
set bool set bool
}{ }{
{"SWW_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0},
{"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0}, {"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
{"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders}, {"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
{"SWW_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0}, {"SWW_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, isLookupTag)},
{"SWW_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0}, {"SWW_TAG_LIMIT_PERCENT", tagLimitNeedsLookups(cfg)},
{"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0}, {"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
{"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0}, {"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
{"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100}, {"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
@@ -1047,6 +1148,90 @@ func (e *environment) checkCrowdSec(cfg *Config) {
} }
} }
// checkTagLists refuses a list of SWW_TAG_LISTS whose URL is fetched as
// another list too: the file at a URL is read as one kind of list.
func (e *environment) checkTagLists(cfg *Config) {
for _, list := range cfg.TagLists {
if slices.Contains(cfg.BlocklistURLs, list.URL) ||
list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL {
e.check("SWW_TAG_LISTS", fmt.Errorf("%q %w", list.URL, errFetchedOtherwise))
}
}
}
// checkTags refuses a tag of SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT that would
// never apply: crowdsec, whose request is refused before tags are acted on, and
// any tag these settings cannot give a request.
func (e *environment) checkTags(cfg *Config) {
for _, tag := range cfg.TagDeny {
e.check("SWW_TAG_DENY", checkTag(cfg, tag))
}
for tag := range cfg.TagLimitPercent {
e.check("SWW_TAG_LIMIT_PERCENT", checkTag(cfg, tag))
}
}
// checkTag returns an error when tag would never apply, as checkTags says.
func checkTag(cfg *Config, tag string) error {
if tag == CrowdSecTag {
return fmt.Errorf("%q %w", tag, errCrowdSecTag)
}
if !requestCanGetTag(cfg, tag) {
return fmt.Errorf("%q %w", MaskZoneKey(tag), errNoRequestGetsTag)
}
return nil
}
// requestCanGetTag reports whether anything cfg sets up can give a request tag.
func requestCanGetTag(cfg *Config, tag string) bool {
isListName := func(list TagList) bool { return list.Name == tag }
if isLookupTag(tag) || slices.ContainsFunc(cfg.TagLists, isListName) {
return true
}
if tag == ExploitSignatureTag {
coreRuleSetOn := cfg.WAFMode != WAFModeOff && cfg.WAFAnomalyThreshold != 0
return cfg.RulesEnabled || coreRuleSetOn
}
if tag == AbuseIPDBTag {
return cfg.AbuseIPDBKey != ""
}
zone, isZoneTag := strings.CutPrefix(tag, DNSBLTagPrefix)
return isZoneTag && slices.ContainsFunc(cfg.DNSBLZones, func(each string) bool {
return strings.ToLower(MaskZoneKey(each)) == zone
})
}
// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT
// names an asn= or country= tag, which only the lookup can give a request.
func (c *Config) TagsNeedLookup() bool {
return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c)
}
// tagLimitNeedsLookups reports whether SWW_TAG_LIMIT_PERCENT names an asn=
// or country= tag.
func tagLimitNeedsLookups(cfg *Config) bool {
for tag := range cfg.TagLimitPercent {
if isLookupTag(tag) {
return true
}
}
return false
}
// isLookupTag reports whether tag is an asn= or country= tag.
func isLookupTag(tag string) bool {
return strings.HasPrefix(tag, ASNTagPrefix) || strings.HasPrefix(tag, CountryTagPrefix)
}
// 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 {
@@ -1058,7 +1243,9 @@ func (e *environment) headerNames(name, defaultValue string) []string {
// durationNotOff reads a setting that is a duration and, unlike a // durationNotOff reads a setting that is a duration and, unlike a
// timeout, cannot be off. // timeout, cannot be off.
func (e *environment) durationNotOff(name, defaultValue string) time.Duration { func (e *environment) durationNotOff(
name, defaultValue string,
) time.Duration {
duration, err := ParseDurationNotOff(e.value(name, defaultValue)) duration, err := ParseDurationNotOff(e.value(name, defaultValue))
e.check(name, err) e.check(name, err)
@@ -1257,7 +1444,10 @@ func (e *environment) instanceName() string {
// appName reads the setting that is the APP-NAME of the records the log // appName reads the setting that is the APP-NAME of the records the log
// lines are sent in, by default the instance name. Its value is checked // lines are sent in, by default the instance name. Its value is checked
// when it is set, and, while lines are sent, when it is the instance name. // when it is set, and, while lines are sent, when it is the instance name.
func (e *environment) appName(name, instanceName string, sending bool) string { func (e *environment) appName(
name, instanceName string,
sending bool,
) string {
value, set := e.lookup(name) value, set := e.lookup(name)
if !set { if !set {
value = instanceName value = instanceName
@@ -1280,7 +1470,10 @@ func (e *environment) appName(name, instanceName string, sending bool) string {
// checkInstanceNameForNtfy refuses an instance name that holds a control // checkInstanceNameForNtfy refuses an instance name that holds a control
// character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent // character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent
// the instance name in a header, which cannot hold one. // the instance name in a header, which cannot hold one.
func (e *environment) checkInstanceNameForNtfy(instanceName string, ntfySet bool) { func (e *environment) checkInstanceNameForNtfy(
instanceName string,
ntfySet bool,
) {
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) { if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
e.check("SWW_INSTANCE_NAME", fmt.Errorf( e.check("SWW_INSTANCE_NAME", fmt.Errorf(
"%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set", "%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set",
@@ -1604,7 +1797,10 @@ func parseNamedNetblocks(value string) ([]anomaly.NamedNetblock, error) {
return nil, fmt.Errorf("%q %w", name, errListedTwice) return nil, fmt.Errorf("%q %w", name, errListedTwice)
} }
named = append(named, anomaly.NamedNetblock{Name: name, Netblock: netblock}) named = append(
named,
anomaly.NamedNetblock{Name: name, Netblock: netblock},
)
} }
return named, nil return named, nil
@@ -1646,6 +1842,65 @@ func parseTrapPaths(value string) ([]string, error) {
return paths, nil return paths, nil
} }
// parseApps reads the apps each hostname serves: entries separated by
// semicolons, each a hostname, a colon, and a comma-separated list of the
// names waf.AppNames returns, such as
// git.example.com:gitea;cloud.example.com:nextcloud,wordpress. Each
// hostname is a DNS name, without a wildcard. It returns them by hostname
// in lowercase, since a hostname is matched without regard to case. An
// empty value lists no hostname.
func parseApps(value string) (map[string][]string, error) {
apps := map[string][]string{}
if strings.TrimSpace(value) == "" {
return apps, nil
}
for entry := range strings.SplitSeq(value, ";") {
hostname, list, found := strings.Cut(entry, ":")
if !found {
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
}
hostname = strings.ToLower(strings.TrimSpace(hostname))
if hostname == "" {
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
}
if !isZone(hostname) {
return nil, fmt.Errorf("%q %w", hostname, errNotHostname)
}
if _, listed := apps[hostname]; listed {
return nil, fmt.Errorf("%q %w", hostname, errListedTwice)
}
names, err := parseList(list)
if err != nil {
return nil, err
}
if len(names) == 0 {
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
}
for i, name := range names {
if !slices.Contains(waf.AppNames(), name) {
return nil, fmt.Errorf("%q %w %s", name, errNotApp,
strings.Join(waf.AppNames(), ", "))
}
if slices.Contains(names[:i], name) {
return nil, fmt.Errorf("%q %w", name, errListedTwice)
}
}
apps[hostname] = names
}
return apps, nil
}
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set // parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
// rules, each a whole number above zero and outside firstSetupRuleID to // rules, each a whole number above zero and outside firstSetupRuleID to
// lastSetupRuleID. // lastSetupRuleID.
@@ -1752,6 +2007,161 @@ func ParseASN(value string) (string, error) {
return "AS" + strconv.FormatUint(number, 10), nil return "AS" + strconv.FormatUint(number, 10), nil
} }
// The tags a request can get that the settings can name, as discriminator
// writes them. The prefixes are followed by a value.
const (
ASNTagPrefix = "asn="
CountryTagPrefix = "country="
DNSBLTagPrefix = "dnsbl="
AbuseIPDBTag = "abuseipdb"
CrowdSecTag = "crowdsec"
// ExploitSignatureTag is the tag of a request a rule file or the Core
// Rule Set matched.
ExploitSignatureTag = "exploitsignature"
)
// TagList is an address list of SWW_TAG_LISTS: the tag it adds to the
// requests of the clients it holds, and the URL it is fetched from.
type TagList struct {
Name string
URL string
}
// parseTagLists reads a comma-separated list of name=URL pairs, such as
// torexit=https://example.com/tor.txt, and returns each list with its name
// in lower case. A name can hold a =, as vpn=mullvad does, and ends at the
// = before http. A name or a URL listed twice is an error.
func parseTagLists(value string) ([]TagList, error) {
items, err := parseList(value)
if err != nil {
return nil, err
}
lists := make([]TagList, 0, len(items))
for _, item := range items {
end := strings.Index(item, "=http")
if end <= 0 || !isListURL(item[end+1:]) {
return nil, fmt.Errorf("%q %w", item, errNotTagList)
}
list := TagList{Name: strings.ToLower(item[:end]), URL: item[end+1:]}
if slices.ContainsFunc(lists, func(earlier TagList) bool {
return earlier.Name == list.Name || earlier.URL == list.URL
}) {
return nil, fmt.Errorf("%q %w", item, errListedTwice)
}
lists = append(lists, list)
}
return lists, nil
}
// parseTags reads a comma-separated list of tags, which parseTag reads. A
// tag listed twice is an error, which shows the tag as MaskZoneKey does.
func parseTags(value string) ([]string, error) {
items, err := parseList(value)
if err != nil {
// parseList's error, for an empty item, shows the whole value, keys
// included.
return nil, errEmptyItem
}
tags := make([]string, 0, len(items))
for _, item := range items {
tag, err := parseTag(item)
if err != nil {
return nil, err
}
if slices.Contains(tags, tag) {
return nil, fmt.Errorf("%q %w", MaskZoneKey(item), errListedTwice)
}
tags = append(tags, tag)
}
return tags, nil
}
// parseTag reads a tag in either case, with spaces around it, and returns it
// as a request carries it: in lower case, but for a country code, which is in
// capitals, and an asn= tag's number without leading zeros. An asn= or
// country= tag must name a number or a country, so that a mistyped one stops
// the start instead of never matching.
func parseTag(value string) (string, error) {
tag := strings.ToLower(strings.TrimSpace(value))
if digits, isASN := strings.CutPrefix(tag, ASNTagPrefix); isASN {
number, err := strconv.ParseUint(digits, 10, 32)
if err != nil {
return "", fmt.Errorf("%q %w", value, errNotASNTag)
}
return ASNTagPrefix + strconv.FormatUint(number, 10), nil
}
if code, isCountry := strings.CutPrefix(tag, CountryTagPrefix); isCountry {
country, err := parseCountry(code)
if err != nil {
return "", err
}
return CountryTagPrefix + country, nil
}
return tag, nil
}
// parseTagPercents reads a comma-separated list of tags, each with : and a
// percentage from 1 to 99, such as vpn:50 or asn=14061:25. A tag listed
// twice is an error, and so is ExploitSignatureTag, which a request gets
// only after the rate limits have counted it.
func parseTagPercents(value string) (map[string]int64, error) {
items, err := parseList(value)
if err != nil {
// parseList's error, for an empty item, shows the whole value, keys
// included.
return nil, errEmptyItem
}
percents := make(map[string]int64, len(items))
for _, item := range items {
colon := strings.LastIndex(item, ":")
if colon <= 0 {
return nil, fmt.Errorf("%q %w", MaskZoneKey(item), errNotTagPercentItem)
}
tag, err := parseTag(item[:colon])
if err != nil {
return nil, err
}
percent, err := strconv.ParseInt(item[colon+1:], 10, 64)
if err != nil || percent < 1 || percent > 99 {
return nil, fmt.Errorf("%q %w", MaskZoneKey(item[colon+1:]), errNotTagPercent)
}
if tag == ExploitSignatureTag {
return nil, fmt.Errorf("%q %w", tag, errExploitTag)
}
if _, listed := percents[tag]; listed {
// tag, since item[:colon] may end in a space, which MaskZoneKey
// would not mask.
return nil, fmt.Errorf("%q %w", MaskZoneKey(tag), errListedTwice)
}
percents[tag] = percent
}
return percents, nil
}
// parsePercents reads a comma-separated list of items, each an AS number // parsePercents reads a comma-separated list of items, each an AS number
// or a country, which parseCode reads, then : and a percentage, such as // or a country, which parseCode reads, then : and a percentage, such as
// AS64496:50 or cn:25, and returns each one's percentage. An empty value // AS64496:50 or cn:25, and returns each one's percentage. An empty value
@@ -1843,8 +2253,11 @@ func parseHeaderNames(value string) ([]string, error) {
header := strings.ToLower(item) header := strings.ToLower(item)
switch header { switch header {
case "host": case "host":
return nil, fmt.Errorf("%q %w; the request's host is the field host", return nil, fmt.Errorf(
item, errHeaderTakenOut) "%q %w; the request's host is the field host",
item,
errHeaderTakenOut,
)
case "transfer-encoding": case "transfer-encoding":
return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut) return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut)
} }
@@ -1881,9 +2294,11 @@ func parseUpstreamURL(value string) (*url.URL, error) {
} }
onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") && onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") &&
upstream.Hostname() != "" && upstream.User == nil && upstream.Opaque == "" && upstream.Hostname() != "" && upstream.User == nil &&
upstream.Opaque == "" &&
(upstream.Path == "" || upstream.Path == "/") && (upstream.Path == "" || upstream.Path == "/") &&
upstream.RawQuery == "" && upstream.Fragment == "" upstream.RawQuery == "" &&
upstream.Fragment == ""
if !onlySchemeAndHost { if !onlySchemeAndHost {
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL) return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
} }
@@ -1907,7 +2322,11 @@ func parseLogRemoteURL(value string) (*url.URL, error) {
return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL) return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL)
} }
schemes := []string{remotelog.SchemeUDP, remotelog.SchemeTCP, remotelog.SchemeTLS} schemes := []string{
remotelog.SchemeUDP,
remotelog.SchemeTCP,
remotelog.SchemeTLS,
}
port, err := strconv.ParseUint(remote.Port(), 10, 16) port, err := strconv.ParseUint(remote.Port(), 10, 16)
onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) && onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) &&
@@ -1970,7 +2389,9 @@ func isHTTPURL(u *url.URL) bool {
return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" && return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" &&
(u.Port() == "" || (err == nil && port != 0)) && (u.Port() == "" || (err == nil && port != 0)) &&
u.User == nil && u.Opaque == "" && u.Fragment == "" u.User == nil &&
u.Opaque == "" &&
u.Fragment == ""
} }
// isListURL reports whether value is a URL a list can be fetched from, as // isListURL reports whether value is a URL a list can be fetched from, as
@@ -2037,9 +2458,12 @@ func parseZones(value string) ([]string, error) {
for i, zone := range zones { for i, zone := range zones {
shown := MaskZoneKey(zone) shown := MaskZoneKey(zone)
listedBefore := slices.ContainsFunc(zones[:i], func(earlier string) bool { listedBefore := slices.ContainsFunc(
zones[:i],
func(earlier string) bool {
return strings.EqualFold(MaskZoneKey(earlier), shown) return strings.EqualFold(MaskZoneKey(earlier), shown)
}) },
)
switch { switch {
case len(zone) > maxZoneLength: case len(zone) > maxZoneLength:
@@ -2072,7 +2496,8 @@ func MaskZoneKey(zone string) string {
return masked + "." + rest 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. It
// also checks the hostnames of SWW_APPS, which are DNS names too.
func isZone(zone string) bool { func isZone(zone string) bool {
for label := range strings.SplitSeq(zone, ".") { for label := range strings.SplitSeq(zone, ".") {
badChar := strings.ContainsFunc(label, func(char rune) bool { badChar := strings.ContainsFunc(label, func(char rune) bool {
@@ -2126,8 +2551,13 @@ func parseWebhookHeaders(value string) (http.Header, string, error) {
name, headerValue, found := strings.Cut(item, ":") name, headerValue, found := strings.Cut(item, ":")
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if !found || !IsHeaderName(name) || strings.ContainsAny(headerValue, "\r\n\x00") { if !found || !IsHeaderName(name) ||
return nil, "", fmt.Errorf("item %d %w", i+1, errNotWebhookHeader) strings.ContainsAny(headerValue, "\r\n\x00") {
return nil, "", fmt.Errorf(
"item %d %w",
i+1,
errNotWebhookHeader,
)
} }
headers.Add(name, strings.TrimSpace(headerValue)) headers.Add(name, strings.TrimSpace(headerValue))
+339 -53
View File
@@ -42,10 +42,10 @@ const (
lookupDBPath = "SWW_LOOKUP_DB_PATH" lookupDBPath = "SWW_LOOKUP_DB_PATH"
lookupTimeout = "SWW_LOOKUP_TIMEOUT" lookupTimeout = "SWW_LOOKUP_TIMEOUT"
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS" addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
deniedCountries = "SWW_DENIED_COUNTRIES"
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES" allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
asnLimitPercent = "SWW_ASN_LIMIT_PERCENT" tagLists = "SWW_TAG_LISTS"
countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT" tagDeny = "SWW_TAG_DENY"
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
asnBytesPercent = "SWW_ASN_BYTES_PERCENT" asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT" countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT" unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
@@ -88,6 +88,7 @@ const (
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS" wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_BODY_LIMIT" wafBodyLimit = "SWW_WAF_BODY_LIMIT"
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE" exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
apps = "SWW_APPS"
trapPaths = "SWW_TRAP_PATHS" trapPaths = "SWW_TRAP_PATHS"
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD" errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
logRemoteURL = "SWW_LOG_REMOTE_URL" logRemoteURL = "SWW_LOG_REMOTE_URL"
@@ -208,6 +209,9 @@ const off = "off"
// enabled is true, as a setting's value. // enabled is true, as a setting's value.
const enabled = "true" const enabled = "true"
// disabled is false, as a setting's value.
const disabled = "false"
// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and // defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
// fileSource the source that is the lookup database. // fileSource the source that is the lookup database.
const ( const (
@@ -274,9 +278,13 @@ func TestDefaults(t *testing.T) {
wantNetblocks(t, cfg.AllowNets) wantNetblocks(t, cfg.AllowNets)
wantNetblocks(t, cfg.RateLimitExemptNets) wantNetblocks(t, cfg.RateLimitExemptNets)
wantNetblocks(t, cfg.DenyNets) wantNetblocks(t, cfg.DenyNets)
wantCountries(t, deniedCountries, cfg.DeniedCountries)
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries) wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
if len(cfg.TagLists) != 0 || len(cfg.TagDeny) != 0 || len(cfg.TagLimitPercent) != 0 {
t.Errorf("tag settings %v, %v and %v by default, want all empty",
cfg.TagLists, cfg.TagDeny, cfg.TagLimitPercent)
}
hostname, err := os.Hostname() hostname, err := os.Hostname()
if err != nil || hostname == "" || cfg.InstanceName != hostname { if err != nil || hostname == "" || cfg.InstanceName != hostname {
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName, t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
@@ -327,7 +335,6 @@ func TestValuesAsSet(t *testing.T) {
rateLimitPerMinute: "60", rateLimitPerMinute: "60",
rateLimitPerHour: "600", rateLimitPerHour: "600",
rateLimitPerDay: "6000", rateLimitPerDay: "6000",
deniedCountries: "cn, RU,kp,Xk",
allowedCountries: "de", allowedCountries: "de",
banResponse: "429", banResponse: "429",
limitBanDuration: "15m", limitBanDuration: "15m",
@@ -342,7 +349,7 @@ func TestValuesAsSet(t *testing.T) {
metricsToken: token, metricsToken: token,
metricsTopN: "10", metricsTopN: "10",
rulesDir: "/srv/waf-rules", rulesDir: "/srv/waf-rules",
rulesEnabled: "false", rulesEnabled: disabled,
}) })
wantSettings(t, cfg, config.Config{ wantSettings(t, cfg, config.Config{
@@ -370,7 +377,6 @@ func TestValuesAsSet(t *testing.T) {
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32") wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32") wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24") wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
wantCountries(t, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK")
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE") wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
} }
@@ -576,6 +582,92 @@ func TestCoreRuleSetSettings(t *testing.T) {
} }
} }
func TestApps(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
env environment
want map[string][]string
}{
{environment{}, nil},
{environment{apps: ""}, map[string][]string{}},
{
environment{apps: "Git.Example.com:gitea; cloud.example.com : gitea "},
map[string][]string{
"git.example.com": {"gitea"}, "cloud.example.com": {"gitea"},
},
},
} {
cfg := fromEnvironment(t, tc.env)
if !reflect.DeepEqual(cfg.Apps, tc.want) {
t.Errorf("%v gave %#v, want %#v", tc.env, cfg.Apps, tc.want)
}
}
}
// Unset and set empty, SWW_APPS has opposite effects, so it logs differently.
func TestUnsetAndEmptyAppsAreLoggedDifferently(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
env environment
want string
}{
{environment{}, `"` + apps + `":null`},
{environment{apps: ""}, `"` + apps + `":""`},
} {
var out bytes.Buffer
slog.New(slog.NewJSONHandler(&out, nil)).
Info("starting", "settings", fromEnvironment(t, tc.env))
if !strings.Contains(out.String(), tc.want) {
t.Errorf("%v logged %s, want %s in it", tc.env, out.String(), tc.want)
}
}
}
func TestInvalidAppsStopsTheStart(t *testing.T) {
t.Parallel()
const (
notEntry = " is not a hostname, : and a list of apps, " +
"such as git.example.com:gitea"
notHostname = " is not a DNS name such as git.example.com"
)
for _, tc := range []struct{ value, want string }{
{"git.example.com:gitea;blog.example.com", `"blog.example.com"` + notEntry},
{"git.example.com:gitea;", `""` + notEntry},
{":gitea", `":gitea"` + notEntry},
{"git.example.com:", `"git.example.com:"` + notEntry},
{"*.example.com:gitea", `"*.example.com"` + notHostname},
{"git.example.com/:gitea", `"git.example.com/"` + notHostname},
{"git example.com:gitea", `"git example.com"` + notHostname},
{
"git.example.com:gitea,nextcloud",
`"nextcloud" is not an app sww has fixes for; the apps are gitea`,
},
{
"git.example.com:gitea;GIT.example.com:gitea",
`"git.example.com" is listed twice`,
},
{"git.example.com:gitea,gitea", `"gitea" is listed twice`},
{"git.example.com:gitea,", `"gitea," has an empty item in its list`},
} {
t.Run(tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{apps: tc.value}.lookupEnv)
want := apps + ": " + tc.want
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
}
}
func TestWAFBodyLimitOf64K(t *testing.T) { func TestWAFBodyLimitOf64K(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1097,15 +1189,15 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
} }
} }
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) { func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
deniedCountries: "cn,ru", tagDeny: "country=cn,country=ru",
allowedCountries: "de,RU", allowedCountries: "de,RU",
}.lookupEnv) }.lookupEnv)
if err == nil { if err == nil {
t.Fatal("ru on both country lists was accepted") t.Fatal("ru in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY was accepted")
} }
if !strings.HasPrefix(err.Error(), allowedCountries+": ") || if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
@@ -1176,12 +1268,11 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
t.Parallel() t.Parallel()
for name, value := range map[string]string{ for name, value := range map[string]string{
deniedCountries: "kp",
allowedCountries: "de", allowedCountries: "de",
addLookupHeaders: enabled, addLookupHeaders: enabled,
tagDeny: "country=KP",
tagLimitPercent: "asn=64496:50",
asnLimitPercentURL: asnURL, asnLimitPercentURL: asnURL,
asnLimitPercent: "AS64496:50",
countryLimitPercent: "cn:25",
asnBytesPercent: "AS64496:50", asnBytesPercent: "AS64496:50",
countryBytesPercent: "cn:25", countryBytesPercent: "cn:25",
unknownLimitPercent: "99", unknownLimitPercent: "99",
@@ -1204,12 +1295,13 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
} }
// Set empty, the lists need nothing looked up, and nor does // Set empty, the lists need nothing looked up, and nor does
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly // SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the
// threshold per AS number that is off, any other anomaly threshold, or // lookup does not give, an anomaly threshold per AS number that is off,
// a blocklist. // any other anomaly threshold, or a blocklist.
env := environment{ env := environment{
lookupSource: off, deniedCountries: "", allowedCountries: "", lookupSource: off, allowedCountries: "", asnBytesPercent: "",
asnLimitPercent: "", countryLimitPercent: "", asnBytesPercent: "", tagLists: "torexit=https://example.com/tor.txt,vpn=https://example.com/vpn.txt",
tagDeny: torExitTag, tagLimitPercent: "vpn:50",
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "", countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
blocklistURLs: dropURL, blocklistURLs: dropURL,
} }
@@ -1355,34 +1447,194 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
} }
} }
// torExitTag, zenTag and exploitTag are tags these tests name.
const (
torExitTag = "torexit"
zenTag = "dnsbl=zen.example"
exploitTag = "exploitsignature"
)
func TestTagSettingsAsSet(t *testing.T) {
t.Parallel()
const torURL = "https://example.com/tor.txt"
// Names and tags in either case, an AS number with leading zeros, and a
// name holding a =.
cfg := fromEnvironment(t, environment{
tagLists: "TorExit=" + torURL +
", vpn=mullvad=https://example.com/mullvad.txt",
dnsblZones: "Zen.example",
tagDeny: "TorExit, Country=kp, asn=014061",
tagLimitPercent: "vpn=mullvad :50, asn=014061:25, COUNTRY=cn:1, dnsbl=zen.example:99",
})
wantLists := []config.TagList{
{Name: torExitTag, URL: torURL},
{Name: "vpn=mullvad", URL: "https://example.com/mullvad.txt"},
}
if !slices.Equal(cfg.TagLists, wantLists) {
t.Errorf("%s gave %+v, want %+v", tagLists, cfg.TagLists, wantLists)
}
wantDeny := []string{torExitTag, "country=KP", "asn=14061"}
if !slices.Equal(cfg.TagDeny, wantDeny) {
t.Errorf("%s gave %v, want %v", tagDeny, cfg.TagDeny, wantDeny)
}
wantPercents := map[string]int64{
"vpn=mullvad": 50, "asn=14061": 25, "country=CN": 1, zenTag: 99,
}
if !maps.Equal(cfg.TagLimitPercent, wantPercents) {
t.Errorf("%s gave %v, want %v", tagLimitPercent, cfg.TagLimitPercent, wantPercents)
}
}
func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
t.Parallel()
const (
list = "torexit=https://example.com/tor.txt"
key = "abc123"
zone = key + ".xbl.dq.spamhaus.net"
masked = "********.xbl.dq.spamhaus.net"
)
for _, tc := range []struct {
name string
env environment
want string // "" is accepted
}{
{"a list's name", environment{tagLists: list, tagDeny: torExitTag}, ""},
{
"a mistyped list's name", environment{tagLists: list, tagDeny: "torexti"},
`"torexti"`,
},
{"a masked zone", environment{dnsblZones: zone, tagDeny: "dnsbl=" + masked}, ""},
{
"a zone with its key", environment{dnsblZones: zone, tagDeny: "dnsbl=" + zone},
masked,
},
{"another zone", environment{dnsblZones: zone, tagDeny: zenTag}, zenTag},
{
"abuseipdb with a key",
environment{abuseIPDBKey: "k", tagLimitPercent: "abuseipdb:50"}, "",
},
{"crowdsec, though a list has its name", environment{
tagLists: "crowdsec=https://example.com/c.txt", tagDeny: "crowdsec",
}, `"crowdsec" cannot be used`},
{exploitTag, environment{tagDeny: exploitTag}, ""},
{
exploitTag + " with rule files off",
environment{tagDeny: exploitTag, rulesEnabled: disabled}, "",
},
{
exploitTag + " with the Core Rule Set off",
environment{tagDeny: exploitTag, wafMode: off}, "",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(tc.env.lookupEnv)
switch {
case tc.want == "" && err != nil:
t.Errorf("error %v, want none", err)
case tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)):
t.Errorf("error %v, want one holding %s", err, tc.want)
case err != nil && strings.Contains(err.Error(), key):
t.Errorf("error %v shows the zone's key", err)
}
})
}
}
func TestErrorOfATagSettingLeavesOutAZoneKey(t *testing.T) {
t.Parallel()
const (
tag = "dnsbl=" + spamhaus
emptyItem = "has an empty item in its list"
twice = `"` + spamhausMasked + `" is listed twice`
)
for _, tc := range []struct{ name, value, want string }{
{tagDeny, tag + "," + tag, twice},
{tagDeny, tag + ",,", emptyItem},
{
tagLimitPercent, tag,
`"` + spamhausMasked + `" is not a tag, : and a percentage, ` +
"such as vpn:50 or asn=14061:25",
},
{
tagLimitPercent, "50:" + tag,
`"` + spamhausMasked + `" is not a percentage from 1 to 99`,
},
{tagLimitPercent, tag + ":50," + tag + " :25", twice},
{tagLimitPercent, tag + ":50,,", emptyItem},
} {
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
t.Errorf("%s=%s gave the error %v, want %s", tc.name, tc.value, err, want)
}
}
}
func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
t.Parallel()
const lapi = "http://172.17.0.1:8080"
for _, tc := range []struct {
name, listURL string
env environment
}{
{"a blocklist", dropURL, environment{blocklistURLs: dropURL}},
{"the file of AS:percent lines", asnURL, environment{asnLimitPercentURL: asnURL}},
{
"the CrowdSec decision list", lapi + "/v1/decisions",
environment{"SWW_CROWDSEC_LAPI_URL": lapi, "SWW_CROWDSEC_LAPI_KEY": "key"},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.env[tagLists] = "dup=" + tc.listURL
want := tagLists + `: "` + tc.listURL + `" is in SWW_BLOCKLIST_URLS, is ` +
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too"
_, err := config.FromEnvironment(tc.env.lookupEnv)
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
}
}
func TestBiasedThresholdsAsSet(t *testing.T) { func TestBiasedThresholdsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{}) cfg := fromEnvironment(t, environment{})
if len(cfg.ASNLimitPercent) != 0 || len(cfg.CountryLimitPercent) != 0 || if len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
cfg.UnknownLimitPercent != 100 { cfg.UnknownLimitPercent != 100 {
t.Errorf("biased thresholds %v, %v, %v, %v and %d by default, "+ t.Errorf("biased thresholds %v, %v and %d by default, "+
"want four empty lists and 100", cfg.ASNLimitPercent, cfg.CountryLimitPercent, "want two empty lists and 100",
cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent) cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent)
} }
// AS numbers and countries in either case, an AS number with leading // AS numbers and countries in either case, an AS number with leading
// zeros, 0 and 100. // zeros, 0 and 100.
cfg = fromEnvironment(t, environment{ cfg = fromEnvironment(t, environment{
asnLimitPercent: "AS14061:50, as16276:0,AS045102:100",
countryLimitPercent: "cn:25,RU:50",
asnBytesPercent: "as16276:75", asnBytesPercent: "as16276:75",
countryBytesPercent: "ru:10", countryBytesPercent: "ru:10",
unknownLimitPercent: "0", unknownLimitPercent: "0",
}) })
for name, tc := range map[string]struct{ got, want map[string]int64 }{ for name, tc := range map[string]struct{ got, want map[string]int64 }{
asnLimitPercent: {
cfg.ASNLimitPercent,
map[string]int64{"AS14061": 50, "AS16276": 0, "AS45102": 100},
},
countryLimitPercent: {cfg.CountryLimitPercent, map[string]int64{"CN": 25, "RU": 50}},
asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}}, asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}},
countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}}, countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}},
} { } {
@@ -1406,16 +1658,16 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
) )
for _, tc := range []struct{ name, value, want string }{ for _, tc := range []struct{ name, value, want string }{
{asnLimitPercent, "14061:50", `"14061"` + notASN}, {asnBytesPercent, "14061:50", `"14061"` + notASN},
{asnLimitPercent, "AS4294967296:50", `"AS4294967296"` + notASN}, {asnBytesPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
{asnLimitPercent, "AS14061", `"AS14061"` + notItem}, {asnBytesPercent, "AS14061", `"AS14061"` + notItem},
{asnLimitPercent, "AS14061:101", `"101"` + notPercent}, {asnBytesPercent, "AS14061:101", `"101"` + notPercent},
{asnLimitPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`}, {asnBytesPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
{ {
countryLimitPercent, "nk:25", countryBytesPercent, "nk:25",
`"nk" is not a two-letter country code such as de or kp`, `"nk" is not a two-letter country code such as de or kp`,
}, },
{countryLimitPercent, "cn:25,CN:50", `"CN" is listed twice`}, {countryBytesPercent, "cn:25,CN:50", `"CN" is listed twice`},
{asnBytesPercent, "AS14061:-1", `"-1"` + notPercent}, {asnBytesPercent, "AS14061:-1", `"-1"` + notPercent},
{countryBytesPercent, "cn:50%", `"50%"` + notPercent}, {countryBytesPercent, "cn:50%", `"50%"` + notPercent},
{unknownLimitPercent, "101", `"101"` + notPercent}, {unknownLimitPercent, "101", `"101"` + notPercent},
@@ -2003,23 +2255,37 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""}, {lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"}, {lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
{addLookupHeaders, "yes"}, {addLookupHeaders, "yes"},
{deniedCountries, "nk"}, {allowedCountries, "nk"},
{deniedCountries, "kp,,ir"}, {allowedCountries, "kp,,ir"},
{deniedCountries, "prk"}, {allowedCountries, "prk"},
{deniedCountries, "408"}, {allowedCountries, "408"},
{deniedCountries, "k"}, {allowedCountries, "k"},
{deniedCountries, "eu"}, {allowedCountries, "eu"},
{deniedCountries, "un"}, {allowedCountries, "un"},
{deniedCountries, "su"}, {allowedCountries, "su"},
{allowedCountries, "ac"}, {allowedCountries, "ac"},
{allowedCountries, "uk"}, {allowedCountries, "uk"},
{allowedCountries, "zz"}, {allowedCountries, "zz"},
{allowedCountries, "de,germany"}, {allowedCountries, "de,germany"},
{asnLimitPercent, "AS14061:50,,AS16276:50"}, {asnLimitPercent, "ASX:50"}, {asnBytesPercent, "AS14061:50,,AS16276:50"}, {asnBytesPercent, "ASX:50"},
{asnLimitPercent, "AS14061:"}, {asnLimitPercent, "AS14061 :50"}, {asnBytesPercent, "AS14061:"}, {asnBytesPercent, "AS14061 :50"},
{asnLimitPercent, "AS14061:1.5"}, {asnLimitPercent, "AS-1:50"}, {asnBytesPercent, "AS14061:1.5"}, {asnBytesPercent, "AS-1:50"},
{countryLimitPercent, "cn"}, {countryLimitPercent, "cn:"}, {countryBytesPercent, "cn"}, {countryBytesPercent, "cn:"},
{countryLimitPercent, "cn:25:50"}, {countryLimitPercent, "china:25"}, {countryBytesPercent, "cn:25:50"}, {countryBytesPercent, "china:25"},
{tagDeny, "a,,b"}, {tagDeny, "country=nk"}, {tagDeny, "asn=abc"},
{tagDeny, "asn="}, {tagDeny, "torexit,TOREXIT"}, {tagDeny, "torexti"},
{tagDeny, zenTag}, {tagDeny, "abuseipdb"}, {tagDeny, "crowdsec"},
{tagLimitPercent, "torexti:50"}, {tagLimitPercent, "dnsbl=zen.example:50"},
{tagLimitPercent, "abuseipdb:50"}, {tagLimitPercent, "crowdsec:50"},
{tagLimitPercent, "vpn :50"}, {tagLimitPercent, "vpn"}, {tagLimitPercent, ":50"},
{tagLimitPercent, "vpn:"}, {tagLimitPercent, "vpn:0"}, {tagLimitPercent, "vpn:100"},
{tagLimitPercent, "vpn:1.5"}, {tagLimitPercent, "vpn:50,VPN:25"},
{tagLimitPercent, "exploitsignature:50"}, {tagLimitPercent, "asn=abc:50"},
{tagLists, torExitTag}, {tagLists, "=https://example.com/a.txt"},
{tagLists, "a,b=https://example.com/a.txt"},
{tagLists, "a=ftp://example.com/a.txt"},
{tagLists, "a=https://example.com/a.txt,A=https://example.com/b.txt"},
{tagLists, "a=https://example.com/a.txt,b=https://example.com/a.txt"},
{asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"}, {asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"},
{unknownLimitPercent, ""}, {unknownLimitPercent, "-1"}, {unknownLimitPercent, ""}, {unknownLimitPercent, "-1"},
{unknownLimitPercent, "50%"}, {unknownLimitPercent, "50%"},
@@ -2031,6 +2297,25 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""}, {logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""},
{rulesEnabled, "yes"}, {rulesEnabled, "True"}, {rulesEnabled, "yes"}, {rulesEnabled, "True"},
}) })
wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t)
}
// wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff checks that with rule
// files off, and either setting turning off the Core Rule Set, the tag
// exploitsignature in SWW_TAG_DENY stops the start.
func wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t *testing.T) {
t.Helper()
for _, name := range []string{wafMode, wafAnomalyThreshold} {
_, err := config.FromEnvironment(environment{
tagDeny: exploitTag, rulesEnabled: disabled, name: off,
}.lookupEnv)
if err == nil || !strings.HasPrefix(err.Error(), tagDeny+": ") {
t.Errorf("%s=off with rule files off gave the error %v, want one naming %s",
name, err, tagDeny)
}
}
} }
func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) { func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
@@ -2285,11 +2570,11 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
lookupSource: defaultLookupSource, lookupSource: defaultLookupSource,
lookupDBPath: "", lookupDBPath: "",
lookupTimeout: "800ms", lookupTimeout: "800ms",
addLookupHeaders: "false", addLookupHeaders: disabled,
deniedCountries: "",
allowedCountries: "", allowedCountries: "",
asnLimitPercent: "", tagLists: "",
countryLimitPercent: "", tagDeny: "",
tagLimitPercent: "",
asnBytesPercent: "", asnBytesPercent: "",
countryBytesPercent: "", countryBytesPercent: "",
unknownLimitPercent: "100", unknownLimitPercent: "100",
@@ -2332,6 +2617,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
wafExemptPaths: "", wafExemptPaths: "",
wafBodyLimit: off, wafBodyLimit: off,
exclusionsFile: "", exclusionsFile: "",
apps: "", // logged as null, which decodes as ""
trapPaths: "", trapPaths: "",
errorBurstThreshold: "30", errorBurstThreshold: "30",
logRemoteURL: "", logRemoteURL: "",
+58
View File
@@ -0,0 +1,58 @@
package discriminator
// The heuristic abuseipdb. It reads SWW_ABUSEIPDB_KEY,
// SWW_ABUSEIPDB_MIN_SCORE and SWW_REPUTATION_ACTION.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
// the score, when its score of the client is a hit, and refuses the request
// when SWW_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
// SWW_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
// checked in the background, by the request's address, if its history counts
// an offence, and the request does not wait for the answer. The score is then
// used for each address of the client. Its figures are the score and
// SWW_ABUSEIPDB_MIN_SCORE.
type abuseIPDB struct{}
func (abuseIPDB) name() string { return "abuseipdb" }
func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
if rq.h.config.AbuseIPDBKey == "" {
return nil
}
client := rq.h.clientGroup(rq.client)
held, _ := rq.h.limiter.Client(client)
offender := held.History.Offences != ratelimit.Offences{}
score, hit := rq.h.abuseIPDB.Hit(ctx, client, rq.client, offender)
if !hit {
return nil
}
rq.abuseIPDBHit = true
rq.addTag(config.AbuseIPDBTag)
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
if rq.h.config.ReputationAction != deny {
return nil
}
return &verdict{
action: requestlog.ActionDenied,
figures: map[string]any{
figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore,
},
}
}
+14 -2
View File
@@ -91,6 +91,18 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
} }
} }
func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
t.Parallel()
s := startDiscriminator(t, "", nil)
req := s.question(siteA, client, "/")
fillHeaders(req, headerFieldLimit+1)
do(t, req)
wantHeuristic(t, s.out.requestLine(t), "header_limits",
`{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`)
}
func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) { func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
t.Parallel() t.Parallel()
@@ -323,7 +335,7 @@ func newDiscriminatorInBubble(
) (*discriminator.Server, *output) { ) (*discriminator.Server, *output) {
t.Helper() t.Helper()
settings := map[string]string{deniedCountries: "KP"} settings := map[string]string{tagDeny: "country=KP"}
maps.Copy(settings, env) maps.Copy(settings, env)
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, settings) server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, settings)
@@ -530,7 +542,7 @@ func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
return discriminator.New(params) return discriminator.New(params)
}, held, time.Now, map[string]string{ }, held, time.Now, map[string]string{
deniedCountries: "KP", wafMode: block, tagDeny: "country=KP", wafMode: block,
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(), dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
abuseIPDBKey: accountKey, abuseIPDBKey: accountKey,
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt", blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
+25 -79
View File
@@ -1,7 +1,10 @@
package discriminator package discriminator
// The heuristic active_ban, and the code that makes bans, which several
// heuristics share. active_ban reads SWW_MODE; the bans are the ban ledger's.
import ( import (
"net/http" "context"
"net/netip" "net/netip"
"time" "time"
@@ -18,11 +21,15 @@ func (rq *request) banResponse(action string) *refusal {
return &refusal{status: rq.h.config.BanResponse, action: action} return &refusal{status: rq.h.config.BanResponse, action: action}
} }
// banned reports whether a ban on a netblock the client is in covers the // activeBan refuses a client a ban covers: a ban on a netblock the client is
// request at now, and notes for the log line when that ban ends. A // in, in force at now. It notes for the log line when that ban ends. A request
// request that makes the ban permanent, or in observe mode would have, // that makes the ban permanent, or in observe mode would have, raises the alert
// raises the alert for it. // for it. Its figures are the ban's netblock and when it ends.
func (rq *request) banned(now time.Time) bool { type activeBan struct{}
func (activeBan) name() string { return "active_ban" }
func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
check := rq.h.ledger.Check check := rq.h.ledger.Check
if rq.h.config.Observe { if rq.h.config.Observe {
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
@@ -38,85 +45,24 @@ func (rq *request) banned(now time.Time) bool {
rq.alertBan(ban) rq.alertBan(ban)
} }
return banned if !banned {
return nil
} }
// limitBroken counts the request for the rate limits at now, notes the return &verdict{
// client's counts for the log line, and reports whether the request takes action: requestlog.ActionBanned,
// the client over a rate limit, as its limit percentage lowers it, which figures: map[string]any{
// breaks it. figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
func (rq *request) limitBroken(now time.Time) bool { },
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
rq.limitPercent.percent)
rq.line.Counts = counts
if over {
rq.banForLimit(now, hit, rq.h.config.BanResponse)
}
return over
}
// countBytes counts the request's bytes, as countedBytes gives them, for
// the byte limits, once its report has come, and notes the client's byte totals
// for the log line; its requests stay there as the rate limits counted them.
// Only an allowed request has them counted, and only one the rate limits
// counted; in observe mode, not one that enforce mode would have refused. Bytes
// that take the client over a byte limit, as its limit percentage for the byte
// limits lowers it, break it; the response was passed on whole.
func (rq *request) countBytes() {
if !rq.counted || rq.line.WouldAction != "" {
return
}
now := rq.h.now()
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
rq.countedBytes(), rq.bytesPercent.percent)
rq.line.Counts.MinuteBytes = counts.MinuteBytes
rq.line.Counts.HourBytes = counts.HourBytes
rq.line.Counts.DayBytes = counts.DayBytes
if over {
rq.banForLimit(now, hit, rq.out.status)
} }
} }
// countRefusal counts the request for the error burst once it has been // limitFigures are the figures of a heuristic that broke hit, a limit: its
// answered, if discriminator refused it after a rule file match, a trap path or a // window, the count and the limit.
// Core Rule Set match, and in observe mode if enforce mode would have: more than func limitFigures(hit ratelimit.Hit) map[string]any {
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute return map[string]any{
// break a limit. A client in SWW_ALLOW_NETS, which the checks skip, is figureWindow: hit.Window, figureCount: hit.Count, figureLimit: hit.Limit,
// not counted, and nothing is while the threshold is off.
func (rq *request) countRefusal() {
cfg := rq.h.config
if cfg.ErrorBurstThreshold == 0 {
return
} }
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
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
+20 -4
View File
@@ -211,7 +211,7 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
banScopeV4Prefix: "24", banScopeV4Prefix: "24",
deniedCountries: "kp", tagDeny: denyKP,
}) })
// fromDE's ban covers otherClient, which is refused unasked about. // fromDE's ban covers otherClient, which is refused unasked about.
@@ -228,6 +228,22 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
} }
} }
func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
t.Parallel()
s, clk, _ := startWithClock(t, "", map[string]string{
rateLimitPerMinute: "1", banScopeV4Prefix: "24",
})
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
wantHeuristic(t, line, "active_ban",
`{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`)
}
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) { func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
t.Parallel() t.Parallel()
@@ -250,7 +266,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
denyNets: denied, denyNets: denied,
deniedCountries: "kp", tagDeny: denyKP,
} }
if tc.setting != "" { if tc.setting != "" {
@@ -260,7 +276,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
s, _, _ := startWithClock(t, geojsURL, env) s, _, _ := startWithClock(t, geojsURL, env)
s.get(denied, tc.status, requestlog.ActionDenied) s.get(denied, tc.status, requestlog.ActionDenied)
s.get(fromKP, tc.status, requestlog.ActionCountryDenied) s.get(fromKP, tc.status, requestlog.ActionDenied)
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, tc.status, requestlog.ActionRateLimited) s.get(fromDE, tc.status, requestlog.ActionRateLimited)
s.get(fromDE, tc.status, requestlog.ActionBanned) s.get(fromDE, tc.status, requestlog.ActionBanned)
@@ -275,7 +291,7 @@ func TestBanNotes(t *testing.T) {
s, clk, server := startWithClock(t, geojsURL, map[string]string{ s, clk, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
deniedCountries: "kp", tagDeny: denyKP,
}) })
start := clk.Now() start := clk.Now()
+36 -26
View File
@@ -17,30 +17,31 @@ type percentage struct {
} }
// biasedThresholdsSet reports whether a biased threshold can lower a // biasedThresholdsSet reports whether a biased threshold can lower a
// client's limits: one of its lists is not empty, // client's limits by its lookup: SWW_ASN_BYTES_PERCENT or
// SWW_UNKNOWN_LIMIT_PERCENT is below 100, or SWW_ASN_LIMIT_PERCENT_URL // SWW_COUNTRY_BYTES_PERCENT is not empty, SWW_UNKNOWN_LIMIT_PERCENT is
// is set. The client's lookup is then needed before its request goes on. // below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
// then needed before its request goes on.
func biasedThresholdsSet(cfg *config.Config) bool { func biasedThresholdsSet(cfg *config.Config) bool {
return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 || return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != "" cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
} }
// limitPercentages returns the client's limit percentages, for the rate // limitPercentages returns the client's limit percentages, for the rate
// limits and for the byte limits, by its AS number and country as looked // limits and for the byte limits, by its tags, its AS number and country
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB // as looked up, each "" when unknown, and the blocklists, DNSBL zones and
// that list it. Each is the lowest of those the settings give it, the // AbuseIPDB that list it. Each is the lowest of those the settings give
// first of them in the order below when several are lowest: the // it, the first of them in the order below when several are lowest: the
// percentage SWW_ASN_LIMIT_PERCENT gives its AS number, the one the file // percentage SWW_TAG_LIMIT_PERCENT gives its asn= tag, the one the file
// SWW_ASN_LIMIT_PERCENT_URL names gives it, the one // SWW_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
// SWW_COUNTRY_LIMIT_PERCENT gives its country, for a client without a // SWW_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
// country, unless its request skipped the wait for GeoJS, // other tags, for a client without a country, unless its request skipped
// SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the // the wait for GeoJS, SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
// percentage of SWW_BLOCKLIST_ACTION while it is limit, and for a client // lists, the percentage of SWW_BLOCKLIST_ACTION while it is limit, and
// a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the // for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
// percentage of SWW_REPUTATION_ACTION while it is limit. For the byte // a hit, the percentage of SWW_REPUTATION_ACTION while it is limit. For
// limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT take the // the byte limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT
// place of the first three for an AS number or a country they list. // take the place of the first three for an AS number or a country they
// list.
func (rq *request) limitPercentages() (percentage, percentage) { func (rq *request) limitPercentages() (percentage, percentage) {
cfg := rq.h.config cfg := rq.h.config
asn, country := rq.line.ASN, rq.line.Country asn, country := rq.line.ASN, rq.line.Country
@@ -65,10 +66,18 @@ func (rq *request) limitPercentages() (percentage, percentage) {
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"} reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
} }
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWW_ASN_LIMIT_PERCENT"), const tagSetting = "SWW_TAG_LIMIT_PERCENT"
fetched)
countryRequests := given(cfg.CountryLimitPercent, country, asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched)
"SWW_COUNTRY_LIMIT_PERCENT") countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting)
otherTags := percentage{percent: whole}
for _, tag := range rq.line.Tags {
if tag != asnTag(asn) && tag != countryTag(country) {
otherTags = lowest(otherTags, given(cfg.TagLimitPercent, tag, tagSetting))
}
}
asnBytes, countryBytes := asnRequests, countryRequests asnBytes, countryBytes := asnRequests, countryRequests
if _, listed := cfg.ASNBytesPercent[asn]; listed { if _, listed := cfg.ASNBytesPercent[asn]; listed {
@@ -79,12 +88,13 @@ func (rq *request) limitPercentages() (percentage, percentage) {
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT") countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
} }
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed), return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted,
lowest(asnBytes, countryBytes, unknown, blocklisted, reputationListed) reputationListed),
lowest(asnBytes, countryBytes, otherTags, unknown, blocklisted, reputationListed)
} }
// given returns the percentage percents, the setting named setting, gives // given returns the percentage percents, the setting named setting, gives
// code, an AS number or a country, or whole when it does not list code. // code, an AS number, a country or a tag, or whole when it does not list code.
func given(percents map[string]int64, code, setting string) percentage { func given(percents map[string]int64, code, setting string) percentage {
percent, listed := percents[code] percent, listed := percents[code]
if !listed { if !listed {
+41 -31
View File
@@ -22,8 +22,7 @@ import (
// The biased thresholds. // The biased thresholds.
const ( const (
asnLimitPercent = "SWW_ASN_LIMIT_PERCENT" tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
asnBytesPercent = "SWW_ASN_BYTES_PERCENT" asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT" countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT" unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
@@ -35,6 +34,11 @@ const (
asnDEHalf = asnDE + ":50" asnDEHalf = asnDE + ":50"
asnDEQuarter = asnDE + ":25" asnDEQuarter = asnDE + ":25"
countryDEHalf = "de:50" countryDEHalf = "de:50"
// The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags.
asnTagDEHalf = "asn=64496:50"
asnTagDEQuarter = "asn=64496:25"
countryTagDEHalf = "country=DE:50"
countryTagDEQuarter = "country=DE:25"
// noCountry is in an AS of its own, AS64500, and in no country. // noCountry is in an AS of its own, AS64500, and in no country.
noCountry = "192.0.2.80" noCountry = "192.0.2.80"
// fourAMinute is the rate limit these tests set: half of it is 2 // fourAMinute is the rate limit these tests set: half of it is 2
@@ -54,8 +58,8 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
setting, value, from string setting, value, from string
}{ }{
{asnLimitPercent, asnDEHalf, fromDE}, {tagLimitPercent, asnTagDEHalf, fromDE},
{countryLimitPercent, countryDEHalf, fromDE}, {tagLimitPercent, countryTagDEHalf, fromDE},
{unknownLimitPercent, "50", unplaced}, {unknownLimitPercent, "50", unplaced},
} { } {
t.Run(tc.setting, func(t *testing.T) { t.Run(tc.setting, func(t *testing.T) {
@@ -96,8 +100,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
setting, value, from string setting, value, from string
}{ }{
{asnLimitPercent, asnDEHalf, fromDE}, {tagLimitPercent, asnTagDEHalf, fromDE},
{countryLimitPercent, "DE:50", fromDE}, {tagLimitPercent, "Country=de:50", fromDE},
{unknownLimitPercent, "50", unplaced}, {unknownLimitPercent, "50", unplaced},
{asnBytesPercent, "as64496:50", fromDE}, {asnBytesPercent, "as64496:50", fromDE},
{countryBytesPercent, countryDEHalf, fromDE}, {countryBytesPercent, countryDEHalf, fromDE},
@@ -151,13 +155,13 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
}, },
{ {
"raising the byte limits back", "raising the byte limits back",
map[string]string{asnLimitPercent: asnDEHalf, asnBytesPercent: asnDE + ":100"}, map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
"50 from " + asnLimitPercent, none, "", "50 from " + tagLimitPercent, none, "",
}, },
{ {
"raising the byte limits back, by country", "raising the byte limits back, by country",
map[string]string{countryLimitPercent: countryDEHalf, countryBytesPercent: "de:100"}, map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
"50 from " + countryLimitPercent, none, "", "50 from " + tagLimitPercent, none, "",
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -184,19 +188,19 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
func TestZeroPercentIsAZeroAllowance(t *testing.T) { func TestZeroPercentIsAZeroAllowance(t *testing.T) {
t.Parallel() t.Parallel()
s, _, _ := startWithLookups(t, map[string]string{asnLimitPercent: asnDE + ":0"}) s, _, _ := startWithLookups(t, map[string]string{unknownLimitPercent: "0"})
// The first request breaks the limit, and bans the client; the log line // The first request breaks the limit, and bans the client; the log line
// gives the 0. // gives the 0.
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited) line := s.get(unplaced, http.StatusForbidden, requestlog.ActionRateLimited)
if line.fields["limit_percent"] != float64(0) || if line.fields["limit_percent"] != float64(0) ||
line.fields["limit_percent_setting"] != asnLimitPercent { line.fields["limit_percent_setting"] != unknownLimitPercent {
t.Errorf("log line has limit_percent %v from %v, want 0 from %s", t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
line.fields["limit_percent"], line.fields["limit_percent_setting"], line.fields["limit_percent"], line.fields["limit_percent_setting"],
asnLimitPercent) unknownLimitPercent)
} }
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned) s.get(unplaced, http.StatusForbidden, requestlog.ActionBanned)
} }
func TestLowestPercentageApplies(t *testing.T) { func TestLowestPercentageApplies(t *testing.T) {
@@ -211,29 +215,29 @@ func TestLowestPercentageApplies(t *testing.T) {
}{ }{
{ {
"the country's", "the country's",
map[string]string{asnLimitPercent: asnDEHalf, countryLimitPercent: "de:25"}, map[string]string{tagLimitPercent: asnTagDEHalf + "," + countryTagDEQuarter},
fromDE, "25 from " + countryLimitPercent, fromDE, "25 from " + tagLimitPercent,
}, },
{ {
"the AS number's", "the AS number's",
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: countryDEHalf}, map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEHalf},
fromDE, "25 from " + asnLimitPercent, fromDE, "25 from " + tagLimitPercent,
}, },
{ {
"the AS number's, the first of two alike", "the AS number's, the first of two alike",
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: "de:25"}, map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEQuarter},
fromDE, "25 from " + asnLimitPercent, fromDE, "25 from " + tagLimitPercent,
}, },
{ {
"that for a client without a country", "that for a client without a country",
map[string]string{asnLimitPercent: "AS64500:50", unknownLimitPercent: "25"}, map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
noCountry, "25 from " + unknownLimitPercent, noCountry, "25 from " + unknownLimitPercent,
}, },
{ {
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100. // SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
"the AS number's, for a client without a country", "the AS number's, for a client without a country",
map[string]string{asnLimitPercent: "AS64500:25"}, map[string]string{tagLimitPercent: "asn=64500:25"},
noCountry, "25 from " + asnLimitPercent, noCountry, "25 from " + tagLimitPercent,
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -304,8 +308,12 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
setting, value string setting, value string
waits bool waits bool
}{ }{
{asnLimitPercent, asnDEHalf, true}, {tagLimitPercent, asnTagDEHalf, true},
{countryLimitPercent, countryDEHalf, true}, {tagLimitPercent, countryTagDEHalf, true},
{tagDeny, denyKP, true},
// A tag the lookup does not give needs no answer.
{tagLimitPercent, "vpn:50", false},
{tagDeny, "vpn", false},
{asnBytesPercent, asnDEHalf, true}, {asnBytesPercent, asnDEHalf, true},
{countryBytesPercent, countryDEHalf, true}, {countryBytesPercent, countryDEHalf, true},
{unknownLimitPercent, "99", true}, {unknownLimitPercent, "99", true},
@@ -320,7 +328,9 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
// says, with a GeoJS that never answers. // says, with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value}) map[string]string{
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
})
began := time.Now() began := time.Now()
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab")) serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
@@ -363,15 +373,15 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
{ {
// A quarter of 12 requests a minute is 3: the fourth breaks it. // A quarter of 12 requests a minute is 3: the fourth breaks it.
"a rate limit", "a rate limit",
map[string]string{rateLimitPerMinute: "12", asnLimitPercent: asnDEQuarter}, map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
3, http.StatusForbidden, requestlog.ActionRateLimited, 3, http.StatusForbidden, requestlog.ActionRateLimited,
"requests per minute over the limit of 3", "25 from " + asnLimitPercent, "requests per minute over the limit of 3", "25 from " + tagLimitPercent,
}, },
{ {
// The byte limits' percentage, not the rate limits'. // The byte limits' percentage, not the rate limits'.
"a byte limit", "a byte limit",
map[string]string{ map[string]string{
bytesLimitPerMinute: twoUploads, asnLimitPercent: asnDEQuarter, bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
asnBytesPercent: asnDEHalf, asnBytesPercent: asnDEHalf,
}, },
0, http.StatusOK, requestlog.ActionForward, 0, http.StatusOK, requestlog.ActionForward,
@@ -487,7 +497,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
} }
// percentText gives a limit percentage and the setting that gave it as // percentText gives a limit percentage and the setting that gave it as
// text, such as "50 from SWW_ASN_LIMIT_PERCENT", or none when both are // text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are
// left out. // left out.
func percentText(percent *int64, setting string) string { func percentText(percent *int64, setting string) string {
switch { switch {
+41
View File
@@ -0,0 +1,41 @@
package discriminator
// The heuristic blocklists. It reads SWW_BLOCKLIST_ACTION, and the blocklists
// through the reputation lists.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// blocklists notes the blocklists that list the client, as noteListed does,
// and refuses the request when SWW_BLOCKLIST_ACTION is deny. Being limit, it
// lowers the client's limits instead (see limitPercentages), and being log, it
// does nothing more. Its figure is the blocklists that list the client.
type blocklists struct{}
func (blocklists) name() string { return "blocklists" }
func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
listedBy := rq.h.lists.ListedBy(rq.client)
rq.blocklisted = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a blocklist")
if !rq.refusedByBlocklists(listedBy) {
return nil
}
return &verdict{
action: requestlog.ActionDenied,
figures: map[string]any{"lists": listedBy},
}
}
// refusedByBlocklists reports whether the heuristic blocklists refuses a
// client, given listedBy, the blocklists that list it: it does when one lists
// it and SWW_BLOCKLIST_ACTION is deny.
func (rq *request) refusedByBlocklists(listedBy []string) bool {
return len(listedBy) > 0 && rq.h.config.BlocklistAction == deny
}
+43
View File
@@ -0,0 +1,43 @@
package discriminator
// The heuristic byte_limits. It reads SWW_BYTES_COUNT, and the byte limits
// and biased thresholds through the limiter and limitPercentages.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// byteLimits counts the request's bytes, as countedBytes gives them, for the
// byte limits, once its report has come, and notes the client's byte totals for
// the log line; its requests stay there as the rate limits counted them. Only
// an allowed request has them counted, and only one the rate limits counted; in
// observe mode, not one that enforce mode would have refused. Bytes that take
// the client over a byte limit, as its limit percentage for the byte limits
// lowers it, break it, which bans the client; the response was passed on whole.
// Its figures are the window, the count and the limit.
type byteLimits struct{}
func (byteLimits) name() string { return "byte_limits" }
func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
if !rq.counted || rq.line.WouldAction != "" {
return nil
}
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
rq.countedBytes(), rq.bytesPercent.percent)
rq.line.Counts.MinuteBytes = counts.MinuteBytes
rq.line.Counts.HourBytes = counts.HourBytes
rq.line.Counts.DayBytes = counts.DayBytes
if !over {
return nil
}
rq.banForLimit(now, hit, rq.out.status)
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
}
+15
View File
@@ -98,6 +98,21 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
} }
} }
func TestByteLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: byteLimit})
line, _ := s.download()
wantAfterAnswerHeuristic(t, line, "", "null")
// Two answers of 70 bytes are over the limit of 99.
line, _ = s.download()
wantHeuristic(t, line, "", "null")
wantAfterAnswerHeuristic(t, line, "byte_limits",
`{"window": "minute", "count": 140, "limit": 99}`)
}
func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) { func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
t.Parallel() t.Parallel()
+22 -10
View File
@@ -1,6 +1,10 @@
package discriminator package discriminator
// The heuristic core_rule_set. It reads SWW_WAF_MODE, SWW_WAF_EXEMPT_PATHS
// and SWW_WAF_ANOMALY_THRESHOLD.
import ( import (
"context"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
@@ -9,18 +13,22 @@ import (
"sneak.berlin/go/smallwebwaf/internal/waf" "sneak.berlin/go/smallwebwaf/internal/waf"
) )
// checkCoreRuleSet inspects the request with the Core Rule Set, unless // coreRuleSet inspects the request with the Core Rule Set, unless
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as // SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
// pathExempt decides, and notes the rules it matched and its score in the // pathExempt decides, and notes the rules it matched and its score in the
// log line, and the rules in the metrics. A score at or over // log line, and the rules in the metrics. A score at or over
// SWW_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert, // SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature,
// and in block mode refuses the request, which is an offence its client's // raises the waf_block alert, and in block mode refuses the request with 403,
// history counts, and so returns ActionWAFBlocked. It returns "" for a // which is an offence its client's history counts. Its figures are the score
// request it does not refuse. // and the threshold.
func (rq *request) checkCoreRuleSet() string { type coreRuleSet struct{}
func (coreRuleSet) name() string { return "core_rule_set" }
func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
cfg := rq.h.config cfg := rq.h.config
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) { if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
return "" return nil
} }
start := time.Now() start := time.Now()
@@ -40,18 +48,22 @@ func (rq *request) checkCoreRuleSet() string {
threshold := cfg.WAFAnomalyThreshold threshold := cfg.WAFAnomalyThreshold
if threshold == 0 || result.Score < threshold { if threshold == 0 || result.Score < threshold {
return "" return nil
} }
rq.addTag(config.ExploitSignatureTag)
rq.alertWAFBlock(result) rq.alertWAFBlock(result)
if cfg.WAFMode == config.WAFModeDetect { if cfg.WAFMode == config.WAFModeDetect {
return "" return nil
} }
rq.wafBlocked = true rq.wafBlocked = true
return requestlog.ActionWAFBlocked return &verdict{
action: requestlog.ActionWAFBlocked,
figures: map[string]any{figureScore: result.Score, "threshold": threshold},
}
} }
// alertWAFBlock raises the waf_block alert for the request, which the Core // alertWAFBlock raises the waf_block alert for the request, which the Core
@@ -10,6 +10,7 @@ import (
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
@@ -21,6 +22,7 @@ const (
wafDisabledRules = "SWW_WAF_DISABLED_RULES" wafDisabledRules = "SWW_WAF_DISABLED_RULES"
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS" wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_BODY_LIMIT" wafBodyLimit = "SWW_WAF_BODY_LIMIT"
apps = "SWW_APPS"
block = "block" block = "block"
detect = "detect" detect = "detect"
) )
@@ -61,6 +63,41 @@ func wantWAF(t *testing.T, line logLine, score *int, ruleIDs ...int) {
} }
} }
func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
mode string
status int
action string
}{
{block, block, http.StatusForbidden, requestlog.ActionWAFBlocked},
{detect, detect, http.StatusOK, requestlog.ActionForward},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
wantTags(t, s.request(client, sqlInjection, tc.status, tc.action),
"exploitsignature")
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
})
}
}
func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: detect, tagDeny: "exploitsignature",
})
line := s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
}
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) { func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
t.Parallel() t.Parallel()
@@ -110,6 +147,16 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
} }
} }
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionWAFBlocked)
wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`)
}
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) { func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
t.Parallel() t.Parallel()
@@ -237,6 +284,27 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
} }
} }
func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
t.Parallel()
// Only gitea's fixes let a file name through in path, a parameter
// gitea sends file names in.
const fileName = "/?path=.gitignore"
s := startDiscriminator(t, "", map[string]string{
wafMode: block, apps: siteA + ":gitea",
})
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
http.StatusOK, discriminator.DispositionAllow)
wantDisposition(t, do(t, s.question(siteB, client, fileName)),
http.StatusForbidden, discriminator.DispositionDeny)
lines := s.out.requestLines(t, 2)
wantLine(t, lines[0], http.StatusOK, requestlog.ActionForward)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionWAFBlocked)
}
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) { func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
t.Parallel() t.Parallel()
+23 -9
View File
@@ -1,21 +1,35 @@
package discriminator package discriminator
// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
import ( import (
"context"
"slices" "slices"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// countryDenied reports whether the country lists refuse the request, by // countries refuses a request when SWW_EXCLUSIVELY_ALLOWED_COUNTRIES is set and
// the client's country as it was looked up. A client without a country, // does not hold the client's country as it was looked up. A client without a
// or whose country cannot be found, is refused only by // country, or whose country cannot be found, is refused. Its figures are the
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES. // country and the setting that refused it.
func (rq *request) countryDenied() bool { type countries struct{}
denied := rq.h.config.DeniedCountries
func (countries) name() string { return "countries" }
func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
allowed := rq.h.config.ExclusivelyAllowedCountries allowed := rq.h.config.ExclusivelyAllowedCountries
country := rq.line.Country country := rq.line.Country
if slices.Contains(denied, country) { if len(allowed) == 0 || slices.Contains(allowed, country) {
return true return nil
} }
return len(allowed) > 0 && !slices.Contains(allowed, country) return &verdict{
action: requestlog.ActionCountryDenied,
figures: map[string]any{
"country": country, "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
},
}
} }
+21 -4
View File
@@ -33,13 +33,13 @@ func TestCountryLists(t *testing.T) {
env map[string]string env map[string]string
refused []string refused []string
}{ }{
{"denied", map[string]string{deniedCountries: "kp"}, []string{fromKP}},
{ {
"exclusively allowed", map[string]string{allowedCountries: "DE"}, "exclusively allowed", map[string]string{allowedCountries: "DE"},
[]string{fromKP, unplaced}, []string{fromKP, unplaced},
}, },
{ {
"both", map[string]string{deniedCountries: "kp", allowedCountries: "de,fr"}, "with a country in SWW_TAG_DENY",
map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"},
[]string{fromKP, unplaced}, []string{fromKP, unplaced},
}, },
} { } {
@@ -90,6 +90,23 @@ func TestCountryLists(t *testing.T) {
} }
} }
func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
t.Parallel()
geojsURL, _ := startGeoJS(t)
s, _, _ := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h", allowedCountries: "de",
})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
wantHeuristic(t, line, "countries",
`{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
wantHeuristic(t, line, "countries",
`{"country": "", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
}
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) { func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
t.Parallel() t.Parallel()
@@ -102,7 +119,7 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{ addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
lookupTimeout: "1h", lookupTimeout: "1h",
deniedCountries: "kp", allowedCountries: "de",
}) })
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body")) req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
@@ -185,7 +202,7 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
env map[string]string env map[string]string
}{ }{
{"no setting needs the lookup", nil}, {"no setting needs the lookup", nil},
{"a country list is set", map[string]string{deniedCountries: "kp"}}, {"a tag setting needs it", map[string]string{tagDeny: denyKP}},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
+51
View File
@@ -0,0 +1,51 @@
package discriminator
// The heuristic crowdsec. It reads SWW_CROWDSEC_LAPI_URL, and the decisions
// through the reputation lists.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// crowdSec refuses a request when a decision of the CrowdSec decision list on
// the client is in force at now. It then adds the tag crowdsec, notes the
// list, as noteHit does, and bans the client until that decision ends. Its
// figures are the decision's scenario and when it ends.
type crowdSec struct{}
func (crowdSec) name() string { return "crowdsec" }
func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
decision, listed := rq.crowdSecDecisionInForce(now)
if !listed {
return nil
}
rq.addTag(config.CrowdSecTag)
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
"listed by the CrowdSec decision list")
rq.banForCrowdSec(now, decision)
return &verdict{
action: requestlog.ActionBanned,
figures: map[string]any{
"scenario": decision.Scenario,
"expires": requestlog.FormatTime(decision.Expires),
},
}
}
// crowdSecDecisionInForce returns the decision of the CrowdSec decision list on
// the client that is in force at now, and reports whether there is one, which
// the heuristic crowdsec refuses the request for.
func (rq *request) crowdSecDecisionInForce(
now time.Time,
) (reputation.Decision, bool) {
return rq.h.lists.CrowdSecDecision(rq.client, now)
}
+16
View File
@@ -95,6 +95,22 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
float64(fetched.Unix())) float64(fetched.Unix()))
} }
func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
t.Parallel()
s, clk, server, _ := startWithAlerts(t, map[string]string{
crowdSecURL: lapi, crowdSecKey: bouncerKey,
})
loadDecisions(t, server, clk.Now(), `[{"duration": "4h0m0s", `+
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
`"value": "`+client+`"}]`)
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
wantTags(t, line, "crowdsec")
}
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) { func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
t.Parallel() t.Parallel()
+29
View File
@@ -0,0 +1,29 @@
package discriminator
// The heuristic deny_nets. It reads SWW_DENY_NETS.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// denyNets refuses a client in SWW_DENY_NETS. Its figure is the netblock of
// the setting the client is in.
type denyNets struct{}
func (denyNets) name() string { return "deny_nets" }
func (denyNets) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, netblock := range rq.h.config.DenyNets {
if netblock.Contains(rq.client) {
return &verdict{
action: requestlog.ActionDenied,
figures: map[string]any{figureNetblock: netblock.String()},
}
}
}
return nil
}
+20 -54
View File
@@ -2,6 +2,7 @@ package discriminator
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -52,13 +53,6 @@ const ReportPath = "/_sww/report"
// QuestionBodyMaxBytes is the most of a visitor's body a question carries. // QuestionBodyMaxBytes is the most of a visitor's body a question carries.
const QuestionBodyMaxBytes = 64 << 10 const QuestionBodyMaxBytes = 64 << 10
// The header limits: the most cookies and header fields a request may have
// when the checks reach the Core Rule Set. Real clients never reach them.
const (
maxCookies = 50
maxHeaderFields = 100
)
const ( const (
// maxAwaitingReport is the most answered requests held until their // maxAwaitingReport is the most answered requests held until their
// reports come. Past it, the request answered longest ago is dropped. // reports come. Past it, the request answered longest ago is dropped.
@@ -148,48 +142,20 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// refusedWhateverTheAnswer reports whether the request is one that does not wait // refusedWhateverTheAnswer reports whether the request is one that does not wait
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about // for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
// its client: it is over the header limits, a blocklist lists its client while // its client: the heuristic crowdsec, header_limits, blocklists or rate_limits
// SWW_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is // will refuse it, as a function in that heuristic's file says. In observe mode,
// over a whole rate limit. In observe mode, which refuses nothing, none is. // which refuses nothing, none is. rproxy lets a request through when
// rproxy lets a request through when discriminator answers too late. // discriminator answers too late.
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool { func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
if rq.h.config.Observe { if rq.h.config.Observe {
return false return false
} }
lists := rq.h.lists _, decided := rq.crowdSecDecisionInForce(now)
_, decided := lists.CrowdSecDecision(rq.client, now)
return decided || rq.overHeaderLimits() || return decided || overHeaderLimits(rq.headerCounts()) ||
(rq.h.config.BlocklistAction == deny && len(lists.ListedBy(rq.client)) > 0) || rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) ||
(rq.countedByRateLimits() && rq.overAWholeRateLimit(now)
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now))
}
// overHeaderLimits reports whether discriminator refuses the request for its
// headers: it has more than maxHeaderFields fields, each value of a header sent
// twice being one, or more than maxCookies cookies, each part of a Cookie header
// between semicolons being one unless it is empty or only spaces and tabs. The
// Core Rule Set runs each of its rules on each cookie, so that thousands of small
// cookies keep it busy for over a second. It trims only spaces and control
// characters, of which a header value can hold only tabs, so a part of Unicode
// spaces is a cookie to it.
func (rq *request) overHeaderLimits() bool {
fields, cookies := 0, 0
for _, values := range rq.in.Header {
fields += len(values)
}
for _, value := range rq.in.Header.Values("Cookie") {
for part := range strings.SplitSeq(value, ";") {
if strings.Trim(part, " \t") != "" {
cookies++
}
}
}
return fields > maxHeaderFields || cookies > maxCookies
} }
// answerQuestion runs the checks on the visitor's request a question // answerQuestion runs the checks on the visitor's request a question
@@ -220,18 +186,18 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
if refused == nil && !underOwnPrefix { if refused == nil && !underOwnPrefix {
// In observe mode, what enforce mode would have refused counts here. // In observe mode, what enforce mode would have refused counts here.
defer rq.countRefusal() defer rq.runAfterAnswer(r.Context())
rq.allow() rq.allow()
return return
} }
// The last deferred runs first: countRefusal before addToHistory, so that a // The last deferred runs first: runAfterAnswer before addToHistory, so that
// broken error burst is in the client's history. // a broken refusal limit is in the client's history.
defer rq.addToHistory() defer rq.addToHistory()
defer rq.countAnomalies() defer rq.countAnomalies()
defer rq.countRefusal() defer rq.runAfterAnswer(r.Context())
if refused != nil { if refused != nil {
rq.deny(*refused) rq.deny(*refused)
@@ -413,7 +379,7 @@ func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
// Remove reports true to one report only, of several that name the id. A // Remove reports true to one report only, of several that name the id. A
// question still being handled is held as nil. // question still being handled is held as nil.
if found && held != nil && h.awaitingReport.Remove(report.RequestID) { if found && held != nil && h.awaitingReport.Remove(report.RequestID) {
h.finishWith(held, report) h.finishWith(r.Context(), held, report)
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -476,11 +442,11 @@ func (report *Report) checkRanges() error {
// finishWith finishes a request with what its report says, and writes the // finishWith finishes a request with what its report says, and writes the
// request's last log line, which holds its decision's fields and the report's. // request's last log line, which holds its decision's fields and the report's.
// For an allowed request it first counts the request's bytes, and adds it to its // For an allowed request it first runs the heuristics of onReport, and adds the
// client's history, the anomaly counters and the metrics, with the status and // request to its client's history, the anomaly counters and the metrics, with
// bytes the report gives. A denied request's counting was finished at its // the status and bytes the report gives. A denied request's counting was
// decision. // finished at its decision. ctx is the report's own context.
func (h *handler) finishWith(held *decision, report Report) { func (h *handler) finishWith(ctx context.Context, held *decision, report Report) {
rq := &request{ rq := &request{
decision: *held, decision: *held,
h: h, h: h,
@@ -497,7 +463,7 @@ func (h *handler) finishWith(held *decision, report Report) {
if rq.allowed { if rq.allowed {
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal
rq.countBytes() rq.runBanningHeuristics(ctx, onReport())
rq.countAnomalies() rq.countAnomalies()
rq.addToHistory() rq.addToHistory()
+2 -2
View File
@@ -414,7 +414,7 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) {
geojsURL, asked, release := startHeldGeoJS(t) geojsURL, asked, release := startHeldGeoJS(t)
s := startDiscriminator(t, geojsURL, map[string]string{ s := startDiscriminator(t, geojsURL, map[string]string{
lookupTimeout: "1h", deniedCountries: "kp", bytesLimitPerMinute: byteLimit, lookupTimeout: "1h", tagDeny: denyKP, bytesLimitPerMinute: byteLimit,
}) })
first := s.question(siteA, client, "/") first := s.question(siteA, client, "/")
@@ -765,7 +765,7 @@ func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) {
geojsURL, _ := startGeoJS(t) geojsURL, _ := startGeoJS(t)
s := startDiscriminator(t, geojsURL, map[string]string{ s := startDiscriminator(t, geojsURL, map[string]string{
addLookupHeaders: tc.setting, lookupTimeout: "1h", deniedCountries: "kp", addLookupHeaders: tc.setting, lookupTimeout: "1h", tagDeny: denyKP,
}) })
got := s.ask(fromDE, "/") got := s.ask(fromDE, "/")
+43
View File
@@ -0,0 +1,43 @@
package discriminator
// The heuristic dnsbl. It reads SWW_REPUTATION_ACTION, and the DNSBL zones'
// verdicts through the DNSBL checker.
import (
"context"
"strings"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
// the request when SWW_REPUTATION_ACTION is deny. Being
// limit, it lowers the client's limits instead (see limitPercentages), and
// being log, it does nothing more. A zone without a verdict on the client is
// asked about it in the background, and the request does not wait for the
// answer. Its figure is the zones that list the client, their keys masked.
type dnsbl struct{}
func (dnsbl) name() string { return "dnsbl" }
func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
listedBy := rq.h.dnsbl.ListedBy(ctx, rq.client)
rq.dnsblListed = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a DNSBL zone")
for _, zone := range listedBy {
rq.addTag(config.DNSBLTagPrefix + strings.ToLower(zone))
}
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
return nil
}
return &verdict{
action: requestlog.ActionDenied,
figures: map[string]any{"zones": listedBy},
}
}
+71
View File
@@ -0,0 +1,71 @@
package discriminator
// The heuristic header_limits. It reads no setting: its limits are fixed.
import (
"context"
"strings"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// The header limits: the most cookies and header fields a request may have
// when the checks reach the Core Rule Set. Real clients never reach them.
const (
maxCookies = 50
maxHeaderFields = 100
)
// headerLimits refuses a request over the header limits, with 431. Its figures
// are the request's header fields and cookies, and the limit of each.
type headerLimits struct{}
func (headerLimits) name() string { return "header_limits" }
func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict {
fields, cookies := rq.headerCounts()
if !overHeaderLimits(fields, cookies) {
return nil
}
return &verdict{
action: requestlog.ActionTooLarge,
figures: map[string]any{
"fields": fields, "fields_limit": maxHeaderFields,
"cookies": cookies, "cookies_limit": maxCookies,
},
}
}
// overHeaderLimits reports whether a request with these counts of header fields
// and cookies, as headerCounts counts them, is over the header limits: it has
// more than maxHeaderFields fields or more than maxCookies cookies.
func overHeaderLimits(fields, cookies int) bool {
return fields > maxHeaderFields || cookies > maxCookies
}
// headerCounts returns the request's header fields, each value of a header sent
// twice being one, and its cookies, each part of a Cookie header between
// semicolons being one unless it is empty or only spaces and tabs. The Core
// Rule Set runs each of its rules on each cookie, so that thousands of small
// cookies keep it busy for over a second. It trims only spaces and control
// characters, of which a header value can hold only tabs, so a part of Unicode
// spaces is a cookie to it.
func (rq *request) headerCounts() (int, int) {
fields, cookies := 0, 0
for _, values := range rq.in.Header {
fields += len(values)
}
for _, value := range rq.in.Header.Values("Cookie") {
for part := range strings.SplitSeq(value, ";") {
if strings.Trim(part, " \t") != "" {
cookies++
}
}
}
return fields, cookies
}
+139
View File
@@ -0,0 +1,139 @@
package discriminator
import (
"context"
"time"
)
// To add a heuristic, write one file with a type that implements heuristic,
// and add one line for it to one of the lists below. Its settings go in
// config.Config, and the comment at the top of its file names them.
// heuristic is one of the checks that decide what discriminator does with a
// request.
type heuristic interface {
// name is the heuristic's name, which the log line gives.
name() string
// check looks at the request at now, and returns nil to let it through.
// ctx is the request's own context.
check(ctx context.Context, rq *request, now time.Time) *verdict
}
// verdict is a heuristic refusing a request, or banning its client: the action
// the log line names, one of requestlog's Action constants, and the figures the
// heuristic acted on, by name, such as the count and the limit.
type verdict struct {
action string
figures map[string]any
}
// The names of the figures that more than one heuristic gives.
const (
figureWindow = "window"
figureCount = "count"
figureLimit = "limit"
figureScore = "score"
figureNetblock = "netblock"
)
// The lists of heuristics, each in the order its heuristics run. A client in
// SWW_ALLOW_NETS skips them all.
// beforeLookup are the heuristics that run before the client's AS number and
// country are looked up, so that a client they refuse is not looked up.
func beforeLookup() []heuristic {
return []heuristic{
tagLists{},
denyNets{},
activeBan{},
}
}
// afterLookup are the heuristics that run once the lookup has been made.
// tag_deny runs twice: before the rate limits, on the tags known by then, and
// last, on the tag rule_files and core_rule_set can add.
func afterLookup() []heuristic {
return []heuristic{
countries{},
blocklists{},
crowdSec{},
dnsbl{},
abuseIPDB{},
tagDeny{},
rateLimits{},
trapPaths{},
ruleFiles{},
headerLimits{},
coreRuleSet{},
tagDeny{},
}
}
// afterAnswer are the heuristics that run on a request once discriminator has
// answered it, before its decision line is written. They ban the client, and do
// not change what the request got: their verdict's action is not used.
func afterAnswer() []heuristic {
return []heuristic{
refusalLimit{},
}
}
// onReport are the heuristics that run on an allowed request when rproxy's
// report of it comes, before its report line is written. Like afterAnswer, they
// only ban.
func onReport() []heuristic {
return []heuristic{
byteLimits{},
}
}
// runAfterAnswer runs the heuristics of afterAnswer on the request, at the time
// it is called.
func (rq *request) runAfterAnswer(ctx context.Context) {
rq.runBanningHeuristics(ctx, afterAnswer())
}
// runHeuristics runs list, beforeLookup or afterLookup, on the request at now,
// and returns the first verdict, or nil when none gives one. The log line's
// heuristic and heuristic_figures name the heuristic that gave it, and give its
// figures. ctx is the request's own context.
func (rq *request) runHeuristics(
ctx context.Context, list []heuristic, now time.Time,
) *verdict {
name, v := rq.firstVerdict(ctx, list, now)
if v != nil {
rq.line.Heuristic = name
rq.line.HeuristicFigures = v.figures
}
return v
}
// runBanningHeuristics runs list, afterAnswer or onReport, on the request at
// the time it is called. The log line's after_answer_heuristic and
// after_answer_heuristic_figures name the heuristic that gave a verdict, and
// give its figures, so that heuristic and heuristic_figures still name the one
// that refused the request. ctx is the request's own context.
func (rq *request) runBanningHeuristics(ctx context.Context, list []heuristic) {
name, v := rq.firstVerdict(ctx, list, rq.h.now())
if v != nil {
rq.line.AfterAnswerHeuristic = name
rq.line.AfterAnswerHeuristicFigures = v.figures
}
}
// firstVerdict runs list on the request at now, in order, until a heuristic
// gives a verdict, and returns that heuristic's name and its verdict, or "" and
// nil when none gives one. ctx is the request's own context.
func (rq *request) firstVerdict(
ctx context.Context, list []heuristic, now time.Time,
) (string, *verdict) {
for _, each := range list {
v := each.check(ctx, rq, now)
if v != nil {
return each.name(), v
}
}
return "", nil
}
+1 -1
View File
@@ -20,7 +20,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
s, clk, server := startWithClock(t, geojsURL, map[string]string{ s, clk, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitPerMinute: "2", rateLimitPerMinute: "2",
deniedCountries: "kp", tagDeny: denyKP,
}) })
start := clk.Now() start := clk.Now()
+32 -1
View File
@@ -4,8 +4,10 @@ import (
"context" "context"
"net/http" "net/http"
"net/netip" "net/netip"
"strings"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
) )
@@ -19,7 +21,8 @@ const (
) )
// lookUp looks up the client's AS number and country, in the lookup // lookUp looks up the client's AS number and country, in the lookup
// database or through GeoJS, and notes them for the log line, unless // database or through GeoJS, and notes them for the log line and as the
// request's asn= and country= tags, unless
// SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or // SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or
// link-local address, which no lookup can place. The lookup database // link-local address, which no lookup can place. The lookup database
// answers at once. With GeoJS, while a setting needs the answer, such as a // answers at once. With GeoJS, while a setting needs the answer, such as a
@@ -47,6 +50,34 @@ func (rq *request) lookUp(ctx context.Context, now time.Time) {
rq.line.ASN = rq.lookupAnswer.ASN rq.line.ASN = rq.lookupAnswer.ASN
rq.line.ASName = rq.lookupAnswer.ASName rq.line.ASName = rq.lookupAnswer.ASName
rq.line.Country = rq.lookupAnswer.Country rq.line.Country = rq.lookupAnswer.Country
if tag := asnTag(rq.line.ASN); tag != "" {
rq.addTag(tag)
}
if tag := countryTag(rq.line.Country); tag != "" {
rq.addTag(tag)
}
}
// asnTag returns the tag of asn, an AS number as the lookup gives it, such as
// asn=64496 for AS64496, or "" for no AS number.
func asnTag(asn string) string {
if asn == "" {
return ""
}
return config.ASNTagPrefix + strings.TrimPrefix(asn, "AS")
}
// countryTag returns the tag of country, a code as the lookup gives it, such
// as country=DE for DE, or "" for no country.
func countryTag(country string) string {
if country == "" {
return ""
}
return config.CountryTagPrefix + country
} }
// addLookup adds answer, an answer about a client from the lookup // addLookup adds answer, an answer about a client from the lookup
+1 -1
View File
@@ -96,7 +96,7 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
map[string]string{ map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
lookupTimeout: "1h", lookupTimeout: "1h",
deniedCountries: "kp", tagDeny: denyKP,
}) })
requestFromDE := func() *http.Request { requestFromDE := func() *http.Request {
+4 -4
View File
@@ -198,7 +198,7 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
metricsToken: token, metricsToken: token,
metricsTopN: "2", metricsTopN: "2",
deniedCountries: "kp", allowedCountries: "de,fr",
} }
geojsURL, _ := startGeoJS(t) geojsURL, _ := startGeoJS(t)
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env) addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env)
@@ -324,11 +324,11 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
map[string]string{ map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
metricsToken: token, metricsToken: token,
deniedCountries: "kp", tagDeny: denyKP,
}) })
// GeoJS fails, so the client counts as coming from an unknown country, // GeoJS fails, so the client has no country= tag, and SWW_TAG_DENY does
// which SWW_DENIED_COUNTRIES does not refuse. // not refuse it.
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(forwardedFor, fromDE) req.Header.Set(forwardedFor, fromDE)
wantStatus(t, do(t, req), http.StatusOK) wantStatus(t, do(t, req), http.StatusOK)
+14 -2
View File
@@ -14,6 +14,18 @@ import (
// observe is the value of SWW_MODE for observe mode. // observe is the value of SWW_MODE for observe mode.
const observe = "observe" const observe = "observe"
func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
mode: observe, denyNets: client,
})
line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`)
}
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) { func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
t.Parallel() t.Parallel()
@@ -38,7 +50,7 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
denyNets: denied, denyNets: denied,
deniedCountries: "kp", tagDeny: denyKP,
} }
if tc.setting != "" { if tc.setting != "" {
@@ -59,7 +71,7 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
for _, sent := range []struct{ from, refusal string }{ for _, sent := range []struct{ from, refusal string }{
{denied, requestlog.ActionDenied}, {denied, requestlog.ActionDenied},
{banned, requestlog.ActionBanned}, {banned, requestlog.ActionBanned},
{fromKP, requestlog.ActionCountryDenied}, {fromKP, requestlog.ActionDenied},
{fromDE, requestlog.ActionRateLimited}, {fromDE, requestlog.ActionRateLimited},
} { } {
if !tc.observe { if !tc.observe {
+10 -6
View File
@@ -155,11 +155,12 @@ func newHandler(params Params) *handler {
h.geojs = lookup.New(lookup.Params{ h.geojs = lookup.New(lookup.Params{
URL: params.GeoJSURL, URL: params.GeoJSURL,
Timeout: params.Config.LookupTimeout, Timeout: params.Config.LookupTimeout,
// The country lists, the headers and the biased thresholds act on // The country list, the headers, the tag settings that name an asn=
// the answer before the request goes on. // or country= tag and the biased thresholds act on the answer before
Wait: len(params.Config.DeniedCountries) > 0 || // the request goes on.
len(params.Config.ExclusivelyAllowedCountries) > 0 || Wait: len(params.Config.ExclusivelyAllowedCountries) > 0 ||
params.Config.AddLookupHeaders || biasedThresholdsSet(params.Config), params.Config.AddLookupHeaders || params.Config.TagsNeedLookup() ||
biasedThresholdsSet(params.Config),
Answered: h.addLookup, Answered: h.addLookup,
Now: params.Now, Now: params.Now,
ProcessLog: params.ProcessLog, ProcessLog: params.ProcessLog,
@@ -183,6 +184,7 @@ func newReputation(
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,
TagLists: cfg.TagLists,
CrowdSecDecisionsURL: cfg.CrowdSecDecisionsURL, CrowdSecKey: cfg.CrowdSecKey, CrowdSecDecisionsURL: cfg.CrowdSecDecisionsURL, CrowdSecKey: cfg.CrowdSecKey,
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts, Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
}) })
@@ -210,7 +212,8 @@ func newReputation(
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL, // NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies // without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
// up to SWW_WAF_BODY_LIMIT, with the exclusions in // up to SWW_WAF_BODY_LIMIT, with the exclusions in
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It // SWW_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_APPS,
// or nil while SWW_WAF_MODE is off. It
// reads at most QuestionBodyMaxBytes less one byte: a question's body of // reads at most QuestionBodyMaxBytes less one byte: a question's body of
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a // QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
// JSON or XML body over its limit. An exclusions file that waf.New refuses is // JSON or XML body over its limit. An exclusions file that waf.New refuses is
@@ -225,6 +228,7 @@ func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) {
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules, ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1), BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile, ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
Apps: cfg.Apps,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err) return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
+3 -1
View File
@@ -69,7 +69,9 @@ const (
lookupDBPath = "SWW_LOOKUP_DB_PATH" lookupDBPath = "SWW_LOOKUP_DB_PATH"
lookupTimeout = "SWW_LOOKUP_TIMEOUT" lookupTimeout = "SWW_LOOKUP_TIMEOUT"
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS" addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
deniedCountries = "SWW_DENIED_COUNTRIES" tagDeny = "SWW_TAG_DENY"
// denyKP is the value of SWW_TAG_DENY that refuses fromKP's country.
denyKP = "country=KP"
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES" allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
banResponse = "SWW_BAN_RESPONSE" banResponse = "SWW_BAN_RESPONSE"
limitBanDuration = "SWW_LIMIT_BAN_DURATION" limitBanDuration = "SWW_LIMIT_BAN_DURATION"
+65
View File
@@ -0,0 +1,65 @@
package discriminator
// The heuristic rate_limits. It reads SWW_RATE_LIMIT_EXEMPT_NETS,
// SWW_RATE_LIMIT_EXEMPT_PATHS and SWW_BAN_RESPONSE, and the rate limits and
// biased thresholds through the limiter and limitPercentages.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// rateLimits counts the request for the rate limits, unless
// countedByRateLimits exempts it, by the client's limit percentages, which it
// notes for the log line with the client's counts. A request that takes the
// client over a rate limit, as its limit percentage lowers it, breaks it: it is
// refused, and bans the client. A request exempt from the rate limits is exempt
// from the byte limits too. Its figures are the window, the count and the
// limit.
type rateLimits struct{}
func (rateLimits) name() string { return "rate_limits" }
func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
rq.counted = rq.countedByRateLimits()
if !rq.counted {
return nil
}
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
rq.limitPercent.percent)
rq.line.Counts = counts
if !over {
return nil
}
rq.banForLimit(now, hit, rq.h.config.BanResponse)
return &verdict{action: requestlog.ActionRateLimited, figures: limitFigures(hit)}
}
// overAWholeRateLimit reports whether the heuristic rate_limits will refuse the
// request at now, whatever its client's limit percentages: the rate limits
// count it, and one more request from its client takes it over a whole rate
// limit. It counts nothing.
func (rq *request) overAWholeRateLimit(now time.Time) bool {
return rq.countedByRateLimits() &&
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now)
}
// countedByRateLimits reports whether the rate limits count the request: its
// client is not in SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
// under SWW_RATE_LIMIT_EXEMPT_PATHS.
func (rq *request) countedByRateLimits() bool {
cfg := rq.h.config
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
}
+15 -4
View File
@@ -72,6 +72,17 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
} }
} }
func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`)
}
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) { func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
t.Parallel() t.Parallel()
@@ -125,7 +136,7 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
rateLimitExemptPaths: "/assets/,/favicon.ico", rateLimitExemptPaths: "/assets/,/favicon.ico",
denyNets: denied, denyNets: denied,
deniedCountries: "kp", tagDeny: denyKP,
}) })
// The answers are kept before the requests, so that none waits for // The answers are kept before the requests, so that none waits for
@@ -151,12 +162,12 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
// /assets/, and breaks the limit. // /assets/, and breaks the limit.
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited) s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
// A ban, SWW_DENY_NETS and the country lists still refuse a path // A ban, SWW_DENY_NETS and SWW_TAG_DENY still refuse a path under a
// under a prefix. // prefix.
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied) s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
s.request(fromKP, "/assets/app.js", s.request(fromKP, "/assets/app.js",
http.StatusForbidden, requestlog.ActionCountryDenied) http.StatusForbidden, requestlog.ActionDenied)
} }
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) { func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
+54
View File
@@ -0,0 +1,54 @@
package discriminator
// The heuristic refusal_limit. It reads SWW_ERROR_BURST_THRESHOLD and
// SWW_BAN_RESPONSE.
import (
"context"
"net/http"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// refusalLimit counts the request once it has been answered, if discriminator
// refused it after a rule file match, a trap path or a Core Rule Set match, and
// in observe mode if enforce mode would have: more than
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
// a limit, which bans the client. A client in SWW_ALLOW_NETS, which the
// heuristics skip, is not counted, and nothing is while the threshold is off.
// Its figures are the window, the count and the limit.
type refusalLimit struct{}
func (refusalLimit) name() string { return "refusal_limit" }
func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdict {
cfg := rq.h.config
if cfg.ErrorBurstThreshold == 0 {
return nil
}
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
return nil
}
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
cfg.ErrorBurstThreshold)
if !over {
return nil
}
// What the client was sent, or in observe mode would have been.
status := rq.out.status
switch rq.line.WouldAction {
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
status = http.StatusForbidden
case requestlog.ActionBanned:
status = cfg.BanResponse
}
rq.banForLimit(now, hit, status)
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
}
@@ -106,6 +106,25 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
} }
} }
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _, _ := startForErrorBurst(t, "1", nil)
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
line := blockRule.send(s, client)
wantHeuristic(t, line, "rule_files", ruleFigures)
wantAfterAnswerHeuristic(t, line, "", "null")
// The second refusal is the request refusal_limit bans for, and the line
// names both.
line = blockRule.send(s, client)
wantHeuristic(t, line, "rule_files", ruleFigures)
wantAfterAnswerHeuristic(t, line, "refusal_limit",
`{"window": "minute", "count": 2, "limit": 1}`)
}
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) { func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
t.Parallel() t.Parallel()
@@ -172,7 +191,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
lookupTimeout: "1h", lookupTimeout: "1h",
errorBurstThreshold: "2", errorBurstThreshold: "2",
rulesDir: writeRules(t, testRules), rulesDir: writeRules(t, testRules),
countryLimitPercent: countryDEHalf, tagLimitPercent: countryTagDEHalf,
}) })
// Half of the threshold would be one, which the second refusal is over. // Half of the threshold would be one, which the second refusal is over.
+3 -77
View File
@@ -1,91 +1,17 @@
package discriminator package discriminator
import ( // What the reputation heuristics share: blocklists, crowdsec, dnsbl and
"context" // abuseipdb.
"time"
import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
) )
// deny is the SWW_BLOCKLIST_ACTION and the SWW_REPUTATION_ACTION that // deny is the SWW_BLOCKLIST_ACTION and the SWW_REPUTATION_ACTION that
// refuses the requests of a client a source lists. // refuses the requests of a client a source lists.
const deny = "deny" const deny = "deny"
// blocklistDenied notes the blocklists that list the client, as
// noteListed does, and reports whether SWW_BLOCKLIST_ACTION, being deny,
// refuses the request. Being limit, it lowers the client's limits instead
// (see limitPercentages), and being log, it does nothing more.
func (rq *request) blocklistDenied() bool {
listedBy := rq.h.lists.ListedBy(rq.client)
rq.blocklisted = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a blocklist")
return rq.blocklisted && rq.h.config.BlocklistAction == deny
}
// crowdSecBanned reports whether a decision of the CrowdSec decision list
// on the client is in force at now. If one is, it notes the list, as
// noteHit does, and bans the client until that decision ends.
func (rq *request) crowdSecBanned(now time.Time) bool {
decision, listed := rq.h.lists.CrowdSecDecision(rq.client, now)
if !listed {
return false
}
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
"listed by the CrowdSec decision list")
rq.banForCrowdSec(now, decision)
return true
}
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as
// noteListed does, and reports whether SWW_REPUTATION_ACTION, being
// deny, refuses the request. Being limit, it lowers the client's limits
// instead (see limitPercentages), and being log, it does nothing more. A
// zone without a verdict on the client is asked about it in the
// background, and the request does not wait for the answer. ctx is the
// request's own context.
func (rq *request) dnsblDenied(ctx context.Context) bool {
listedBy := rq.h.dnsbl.ListedBy(ctx, rq.client)
rq.dnsblListed = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a DNSBL zone")
return rq.dnsblListed && rq.h.config.ReputationAction == deny
}
// abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
// its score of the client is a hit, and reports whether
// SWW_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
// does for a zone. While SWW_ABUSEIPDB_KEY is unset it does nothing. A
// client without a score is checked in the background, by the request's
// address, if its history counts an offence, and the request does not
// wait for the answer. The score is then used for each address of the
// client. 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 SWW_ABUSEIPDB_MIN_SCORE")
return rq.h.config.ReputationAction == deny
}
// noteListed notes each of sources, the URLs of the blocklists or the // noteListed notes each of sources, the URLs of the blocklists or the
// DNSBL zones, their keys masked, that list the client, as noteHit does, // DNSBL zones, their keys masked, that list the client, as noteHit does,
// with reason. // with reason.
+101 -11
View File
@@ -118,11 +118,61 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
} }
} }
func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) { func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
heuristic string
env map[string]string
load func(*testing.T, *discriminator.Server)
figures string
}{
{
"blocklists",
map[string]string{blocklistURLs: dropURL, blocklistAction: actionDeny},
func(t *testing.T, server *discriminator.Server) {
t.Helper()
loadLists(t, server, map[string][]string{dropURL: {client}})
},
`{"lists": ["` + dropURL + `"]}`,
},
{
"dnsbl",
map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: actionDeny,
},
func(_ *testing.T, server *discriminator.Server) {
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
},
`{"zones": ["` + dnsblZone + `"]}`,
},
{
"abuseipdb",
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
func(_ *testing.T, server *discriminator.Server) {
loadScores(server, map[string]int64{client: 80})
},
`{"score": 80, "min_score": 75}`,
},
} {
t.Run(tc.heuristic, func(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, tc.env)
tc.load(t, server)
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, tc.heuristic, tc.figures)
})
}
}
func TestBlocklistsComeAfterAllowedCountriesAndSkipAllowNets(t *testing.T) {
t.Parallel() t.Parallel()
s, server, queue := startWithLookups(t, map[string]string{ s, server, queue := startWithLookups(t, map[string]string{
blocklistURLs: dropURL, deniedCountries: "kp", allowNets: fromDE, blocklistURLs: dropURL, allowedCountries: "de", allowNets: fromDE,
}) })
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}}) loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
@@ -161,18 +211,18 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
// percentText gives them, and limitHit its limit_hit. // percentText gives them, and limitHit its limit_hit.
want, limitHit string want, limitHit string
}{ }{
{limitHalf, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes}, {limitHalf, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
{limitQuarter, asnDEHalf, "25 from " + blocklistAction, minuteBytes}, {limitQuarter, asnTagDEHalf, "25 from " + blocklistAction, minuteBytes},
// The AS number's, the first of two alike. // The AS number's, the first of two alike.
{limitQuarter, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes}, {limitQuarter, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
{actionLog, asnDE + ":100", none, ""}, {actionLog, "asn=64511:25", none, ""},
} { } {
t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) { t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) {
t.Parallel() t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{ s, server, _ := startWithLookups(t, map[string]string{
bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL, bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL,
blocklistAction: tc.action, asnLimitPercent: tc.asnPercent, blocklistAction: tc.action, tagLimitPercent: tc.asnPercent,
}) })
loadLists(t, server, map[string][]string{dropURL: {fromDE}}) loadLists(t, server, map[string][]string{dropURL: {fromDE}})
@@ -196,7 +246,7 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
const ( const (
fromURL = "25 from " + asnLimitPercentURL fromURL = "25 from " + asnLimitPercentURL
fromSetting = "25 from " + asnLimitPercent fromSetting = "25 from " + tagLimitPercent
) )
for _, tc := range []struct { for _, tc := range []struct {
@@ -210,16 +260,16 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL}, {"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
{ {
"the file's, lower than the setting's", "the file's, lower than the setting's",
map[string]string{asnLimitPercent: asnDEHalf}, asnDEQuarter, fromURL, fromURL, map[string]string{tagLimitPercent: asnTagDEHalf}, asnDEQuarter, fromURL, fromURL,
}, },
{ {
"the setting's, lower than the file's", "the setting's, lower than the file's",
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEHalf, map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEHalf,
fromSetting, fromSetting, fromSetting, fromSetting,
}, },
{ {
"the setting's, the first of two alike", "the setting's, the first of two alike",
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEQuarter, map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEQuarter,
fromSetting, fromSetting, fromSetting, fromSetting,
}, },
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none}, {"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
@@ -432,6 +482,46 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
} }
} }
func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
env map[string]string
load func(*discriminator.Server)
want []string
}{
{
"dnsbl",
map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionLog,
},
func(server *discriminator.Server) {
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
},
[]string{"asn=64496", "country=DE", "dnsbl=" + dnsblZone},
},
{
abuseipdb,
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionLog},
func(server *discriminator.Server) {
loadScores(server, map[string]int64{fromDE: 80})
},
[]string{abuseipdb, "asn=64496", "country=DE"},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, tc.env)
tc.load(server)
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
tc.want...)
})
}
}
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) { func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
t.Parallel() t.Parallel()
+32 -85
View File
@@ -138,6 +138,15 @@ func (h *handler) startRequest(
return rq return rq
} }
// addTag adds tag to the request's tags, which stay sorted and hold each tag
// once.
func (rq *request) addTag(tag string) {
at, found := slices.BinarySearch(rq.line.Tags, tag)
if !found {
rq.line.Tags = slices.Insert(rq.line.Tags, at, tag)
}
}
// requestHeaders returns the headers of r that names lists, by name in // requestHeaders returns the headers of r that names lists, by name in
// lower case, each with its values joined by ", ". Authorization, Cookie // lower case, each with its values joined by ", ". Authorization, Cookie
// and Set-Cookie are never among them, whatever names says. // and Set-Cookie are never among them, whatever names says.
@@ -160,16 +169,20 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
} }
// check is the one place where a request can be refused. It returns nil to let // check is the one place where a request can be refused. It returns nil to let
// the request through. The checks of checkClient decide: a request they refuse // the request through. The heuristics of checkClient decide: a request they
// is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core Rule Set, // refuse is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core
// or 431 for the header limits. In observe mode nothing is refused, and the log // Rule Set, or 431 for the header limits. In observe mode nothing is refused,
// line names what enforce mode would have done. ctx is the request's own context. // and the log line names what enforce mode would have done. ctx is the
// request's own context.
func (rq *request) check(ctx context.Context) *refusal { func (rq *request) check(ctx context.Context) *refusal {
action := rq.checkClient(ctx) v := rq.checkClient(ctx)
if v == nil {
return nil
}
action := v.action
switch { switch {
case action == "":
return nil
case rq.h.config.Observe: case rq.h.config.Observe:
rq.line.WouldAction = action rq.line.WouldAction = action
@@ -183,35 +196,21 @@ func (rq *request) check(ctx context.Context) *refusal {
} }
} }
// checkClient runs the checks on the request's client, and returns the // checkClient runs the heuristics of beforeLookup on the request, then looks
// action of the first that refuses the request, or "" when none does. A // up the client's AS number and country, then runs those of afterLookup. It
// client in SWW_ALLOW_NETS skips them, and is not looked up. For any // returns the first verdict, or nil when none gives one. A client in
// other client, SWW_DENY_NETS comes first, then a ban on its netblock, // SWW_ALLOW_NETS skips them all, and is not looked up. ctx is the request's
// so that a client either refuses is not looked up, then the lookup of // own context.
// its AS number and country, then the country lists, then the blocklists, func (rq *request) checkClient(ctx context.Context) *verdict {
// then the CrowdSec decision list, which bans the client it lists, then if isInside(rq.client, rq.h.config.AllowNets) {
// the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of return nil
// them refuses is not counted for the rate limits. Then come the
// rate limits, unless the client is in SWW_RATE_LIMIT_EXEMPT_NETS or the
// request's path is exempt under SWW_RATE_LIMIT_EXEMPT_PATHS, so that
// every other request is counted, each of them by the client's limit
// percentages, then SWW_TRAP_PATHS, then the rule files, then the header
// limits, and last the Core Rule Set. A request exempt from the rate limits
// is exempt from the byte limits too. ctx is the request's own context.
func (rq *request) checkClient(ctx context.Context) string {
cfg := rq.h.config
if isInside(rq.client, cfg.AllowNets) {
return ""
} }
now := rq.h.now() now := rq.h.now()
if isInside(rq.client, cfg.DenyNets) { v := rq.runHeuristics(ctx, beforeLookup(), now)
return requestlog.ActionDenied if v != nil {
} return v
if rq.banned(now) {
return requestlog.ActionBanned
} }
rq.lookUp(ctx, now) rq.lookUp(ctx, now)
@@ -220,59 +219,7 @@ func (rq *request) checkClient(ctx context.Context) string {
// again: a request counted at the time it arrived could be counted // again: a request counted at the time it arrived could be counted
// after a later one of its client's, which the rate limits would take // after a later one of its client's, which the rate limits would take
// for the clock set back. // for the clock set back.
now = rq.h.now() return rq.runHeuristics(ctx, afterLookup(), rq.h.now())
if rq.countryDenied() {
return requestlog.ActionCountryDenied
}
if rq.blocklistDenied() {
return requestlog.ActionDenied
}
if rq.crowdSecBanned(now) {
return requestlog.ActionBanned
}
if rq.dnsblDenied(ctx) || rq.abuseIPDBDenied(ctx) {
return requestlog.ActionDenied
}
rq.counted = rq.countedByRateLimits()
if rq.counted {
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
}
if rq.counted && rq.limitBroken(now) {
return requestlog.ActionRateLimited
}
if rq.trapPath(now) {
return requestlog.ActionBanned
}
action := rq.checkRules(now)
if action != "" {
return action
}
if rq.overHeaderLimits() {
return requestlog.ActionTooLarge
}
return rq.checkCoreRuleSet()
}
// countedByRateLimits reports whether the rate limits count the request: its
// client is not in SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
// under SWW_RATE_LIMIT_EXEMPT_PATHS.
func (rq *request) countedByRateLimits() bool {
cfg := rq.h.config
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
} }
// pathExempt reports whether r is exempt under prefixes, // pathExempt reports whether r is exempt under prefixes,
+43
View File
@@ -1,6 +1,7 @@
package discriminator_test package discriminator_test
import ( import (
"encoding/json"
"io" "io"
"math" "math"
"net/http" "net/http"
@@ -272,6 +273,48 @@ func wantTimings(t *testing.T, line logLine, want ...string) {
} }
} }
// wantHeuristic checks that line names heuristic as the one that refused the
// request, with figures, a JSON object. A heuristic of "" with figures "null" is
// a line that names none.
func wantHeuristic(t *testing.T, line logLine, heuristic, figures string) {
t.Helper()
if line.Heuristic != heuristic ||
!reflect.DeepEqual(line.HeuristicFigures, decodeFigures(t, figures)) {
t.Errorf("log line has heuristic %q with heuristic_figures %v, want %q with %s",
line.Heuristic, line.HeuristicFigures, heuristic, figures)
}
}
// wantAfterAnswerHeuristic checks after_answer_heuristic and
// after_answer_heuristic_figures as wantHeuristic checks heuristic and
// heuristic_figures.
func wantAfterAnswerHeuristic(t *testing.T, line logLine, heuristic, figures string) {
t.Helper()
if line.AfterAnswerHeuristic != heuristic ||
!reflect.DeepEqual(line.AfterAnswerHeuristicFigures, decodeFigures(t, figures)) {
t.Errorf("log line has after_answer_heuristic %q with "+
"after_answer_heuristic_figures %v, want %q with %s",
line.AfterAnswerHeuristic, line.AfterAnswerHeuristicFigures, heuristic, figures)
}
}
// decodeFigures decodes figures, a JSON object of a heuristic's figures, as
// the log line's figures are decoded.
func decodeFigures(t *testing.T, figures string) map[string]any {
t.Helper()
var decoded map[string]any
err := json.Unmarshal([]byte(figures), &decoded)
if err != nil {
t.Fatalf("decode %s: %v", figures, err)
}
return decoded
}
// microseconds is a timing in whole microseconds. // microseconds is a timing in whole microseconds.
func microseconds(milliseconds float64) int64 { func microseconds(milliseconds float64) int64 {
return int64(math.Round(milliseconds * 1000)) return int64(math.Round(milliseconds * 1000))
+59
View File
@@ -0,0 +1,59 @@
package discriminator
// The heuristic rule_files. It reads the rules of the rule files, which
// SWW_RULES_DIR and SWW_RULES_ENABLED name.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// ruleFiles checks the request against the rules of the rule files at now,
// notes the ids of those it matches in the log line, adds the tag
// exploitsignature when it matches any, and refuses the request
// when the last rule it matches refuses it: with ActionRuleBlocked for a block
// rule, and ActionBanned for a ban rule. A ban rule bans the client's netblock
// for a clear sign of attack, or in observe mode raises the alert for the ban
// it would have made. Either rule's match is noted as an offence, for the
// client's history. Its figures are the rule's id and action.
type ruleFiles struct{}
func (ruleFiles) name() string { return "rule_files" }
func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict {
matched := rq.h.rules.Match(rq.in)
for _, rule := range matched {
rq.line.RuleIDs = append(rq.line.RuleIDs, rule.ID)
rq.h.metrics.RuleMatched(rule.ID, rule.Action)
}
if len(matched) == 0 {
return nil
}
rq.addTag(config.ExploitSignatureTag)
// Only the last rule matched can refuse the request.
last := matched[len(matched)-1]
figures := map[string]any{"rule_id": last.ID, "rule_action": last.Action}
switch last.Action {
case rules.ActionBlock:
rq.ruleBlocked = true
return &verdict{action: requestlog.ActionRuleBlocked, figures: figures}
case rules.ActionBan:
rq.attack = true
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
return &verdict{action: requestlog.ActionBanned, figures: figures}
default:
return nil
}
}
+29
View File
@@ -93,6 +93,35 @@ func TestEachRuleAction(t *testing.T) {
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned) s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
} }
func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
})
wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward),
"exploitsignature")
wantTags(t, s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionRuleBlocked), "exploitsignature")
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
}
func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
})
line := s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionRuleBlocked)
wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`)
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`)
}
func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) { func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
t.Parallel() t.Parallel()
-64
View File
@@ -1,64 +0,0 @@
package discriminator
import (
"slices"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// trapPath reports whether the request asks for a path in
// SWW_TRAP_PATHS: its path as a path rule sees it, without the query,
// as sent or once percent-decoded, is one of them. Such a request is a
// clear sign of attack, as a ban rule's match is: it bans the client's
// netblock, or in observe mode raises the alert for the ban it would have
// made. The ban's notes give the path as sent.
func (rq *request) trapPath(now time.Time) bool {
path := rules.Path(rq.in)
if !slices.Contains(rq.h.config.TrapPaths, path) &&
!slices.Contains(rq.h.config.TrapPaths, rules.DecodeOnce(path)) {
return false
}
rq.attack = true
rq.banForAttack(now, bans.Notes{TrapPath: path})
return true
}
// checkRules checks the request against the rules of the rule files at
// now, notes the ids of those it matches in the log line, and returns the
// action of the rule that refuses it, ActionRuleBlocked for a block rule
// and ActionBanned for a ban rule, or "" when none does. A ban rule bans
// the client's netblock for a clear sign of attack, or in observe mode
// raises the alert for the ban it would have made. Either rule's match
// is noted as an offence, for the client's history.
func (rq *request) checkRules(now time.Time) string {
matched := rq.h.rules.Match(rq.in)
for _, rule := range matched {
rq.line.RuleIDs = append(rq.line.RuleIDs, rule.ID)
rq.h.metrics.RuleMatched(rule.ID, rule.Action)
}
if len(matched) == 0 {
return ""
}
// Only the last rule matched can refuse the request.
switch last := matched[len(matched)-1]; last.Action {
case rules.ActionBlock:
rq.ruleBlocked = true
return requestlog.ActionRuleBlocked
case rules.ActionBan:
rq.attack = true
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
return requestlog.ActionBanned
default:
return ""
}
}
+17 -9
View File
@@ -32,13 +32,12 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
allowNets: "198.51.100.0/24", allowNets: "198.51.100.0/24",
denyNets: fromKP, denyNets: fromKP,
deniedCountries: "kp", tagDeny: denyKP,
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
}) })
// Neither SWW_DENY_NETS, the country lists nor the limit of one // Neither SWW_DENY_NETS, SWW_TAG_DENY nor the limit of one request a
// request a minute refuses the client, and its country is not looked // minute refuses the client, and its country is not looked up.
// up.
wantAnswers(t, addr, out, []sentRequest{ wantAnswers(t, addr, out, []sentRequest{
{fromKP, http.StatusOK, requestlog.ActionForward}, {fromKP, http.StatusOK, requestlog.ActionForward},
{fromKP, http.StatusOK, requestlog.ActionForward}, {fromKP, http.StatusOK, requestlog.ActionForward},
@@ -85,7 +84,7 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{ addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
denyNets: "203.0.113.0/24", denyNets: "203.0.113.0/24",
deniedCountries: "kp", tagDeny: denyKP,
}) })
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body")) req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
@@ -104,6 +103,15 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
} }
} }
func TestDenyNetsRefusalNamesItsHeuristicAndNetblock(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet})
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`)
}
func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) { func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
t.Parallel() t.Parallel()
@@ -133,19 +141,19 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitExemptNets: listedAddr + "," + fromKP, rateLimitExemptNets: listedAddr + "," + fromKP,
deniedCountries: "kp", tagDeny: denyKP,
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
}) })
// listedAddr's requests are neither refused nor counted, so the first // listedAddr's requests are neither refused nor counted, so the first
// request from unlistedAddr is within the limit of one a minute. The // request from unlistedAddr is within the limit of one a minute.
// country lists still refuse an exempt client. // SWW_TAG_DENY still refuses an exempt client.
wantAnswers(t, addr, out, []sentRequest{ wantAnswers(t, addr, out, []sentRequest{
{listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward},
{listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusOK, requestlog.ActionForward}, {unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited}, {unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
{fromKP, http.StatusForbidden, requestlog.ActionCountryDenied}, {fromKP, http.StatusForbidden, requestlog.ActionDenied},
}) })
} }
+30
View File
@@ -0,0 +1,30 @@
package discriminator
// The heuristic tag_deny. It reads SWW_TAG_DENY.
import (
"context"
"slices"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// tagDeny refuses a request that carries a tag in SWW_TAG_DENY. Its figure is
// the first such tag, in sorted order.
type tagDeny struct{}
func (tagDeny) name() string { return "tag_deny" }
func (tagDeny) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, tag := range rq.line.Tags {
if slices.Contains(rq.h.config.TagDeny, tag) {
return &verdict{
action: requestlog.ActionDenied,
figures: map[string]any{"tag": tag},
}
}
}
return nil
}
+121
View File
@@ -0,0 +1,121 @@
package discriminator_test
import (
"net/http"
"testing"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
t.Parallel()
// The setting takes a country code in either case; the request's tag is
// in capitals.
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`)
wantTags(t, line, "asn=64511", "country=KP")
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
func TestTagDenyRefusesAnASNTag(t *testing.T) {
t.Parallel()
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"})
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
"tag_deny", `{"tag": "asn=64511"}`)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
func TestTagDenyRefusesAListTagBeforeTheRateLimitsCount(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
tagLists: "torexit=" + torList, tagDeny: "torexit", rateLimitPerMinute: "1",
})
loadLists(t, server, map[string][]string{torList: {fromDE}})
// Each is refused for its tag: none is counted to break the limit of one.
for range 3 {
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionDenied),
"tag_deny", `{"tag": "torexit"}`)
}
}
func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
t.Parallel()
const mixedCaseZone = "DNSBL.example"
// The zone's tag is in lower case, however SWW_DNSBL_ZONES writes it.
s, server, _ := startWithLookups(t, map[string]string{
dnsblZones: mixedCaseZone, dnsblResolver: noResolver,
reputationAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
})
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionDenied),
"tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`)
}
func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature",
})
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
wantTags(t, line, "exploitsignature")
}
func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
rateLimitPerMinute: fourAMinute,
tagLists: "vpn=" + vpnList + ",tor=" + torList,
tagLimitPercent: "vpn:50,tor:25",
})
loadLists(t, server, map[string][]string{vpnList: {fromDE}, torList: {fromDE}})
// A quarter of 4 requests a minute: the second breaks the limit.
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
"25 from "+tagLimitPercent)
}
func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
bytesLimitPerMinute: twoUploads,
tagLists: "vpn=" + vpnList,
tagLimitPercent: "vpn:50",
})
loadLists(t, server, map[string][]string{vpnList: {fromDE}})
// The upload's 100 bytes are over half of 199, 99.
line := s.uploadFrom(fromDE)
if line.LimitHit != minuteBytes {
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
}
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
"50 from "+tagLimitPercent)
// fromKP, which the list does not hold, has the whole limit.
line = s.uploadFrom(fromKP)
if line.LimitHit != "" {
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
}
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, none)
}
+24
View File
@@ -0,0 +1,24 @@
package discriminator
// The heuristic tag_lists. It reads the address lists of SWW_TAG_LISTS through
// the reputation lists.
import (
"context"
"time"
)
// tagLists adds to the request one tag for each list of SWW_TAG_LISTS that
// holds the client, the tag being the list's name. It refuses nothing, and runs
// first so that every refusal's log line carries these tags.
type tagLists struct{}
func (tagLists) name() string { return "tag_lists" }
func (tagLists) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, name := range rq.h.lists.TagsOf(rq.client) {
rq.addTag(name)
}
return nil
}
+100
View File
@@ -0,0 +1,100 @@
package discriminator_test
import (
"net/http"
"slices"
"testing"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// tagLists is SWW_TAG_LISTS, and vpnList and torList lists it names.
const (
tagLists = "SWW_TAG_LISTS"
vpnList = "https://lists.example/vpn.txt"
torList = "https://lists.example/torexit.txt"
)
// wantTags checks the log line's tags.
func wantTags(t *testing.T, line logLine, want ...string) {
t.Helper()
got, _ := line.fields["tags"].([]any)
tags := make([]string, 0, len(got))
for _, tag := range got {
text, _ := tag.(string)
tags = append(tags, text)
}
if !slices.Equal(tags, want) {
t.Errorf("log line has tags %v, want %v", tags, want)
}
}
func TestLookupAndTagListsGiveTheRequestItsTagsSorted(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
tagLists: "vpn=mullvad=" + vpnList + ",TorExit=" + torList,
})
loadLists(t, server, map[string][]string{
vpnList: {fromDE}, torList: {fromDE + "/32", fromKP},
})
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
"asn=64496", "country=DE", "torexit", "vpn=mullvad")
wantTags(t, s.get(fromKP, http.StatusOK, requestlog.ActionForward),
"asn=64511", "country=KP", "torexit")
}
func TestRequestWithoutATagLeavesTheFieldOut(t *testing.T) {
t.Parallel()
// With no lookup, a client is not placed and no setting gives it a tag.
s, _, _ := startWithClock(t, "", map[string]string{lookupSource: "off"})
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
if _, present := line.fields["tags"]; present {
t.Errorf("log line has tags %v, want none", line.fields["tags"])
}
}
func TestTagListsTagEvenARequestTheCountryListRefuses(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
tagLists: "vpn=" + vpnList, allowedCountries: "de",
})
loadLists(t, server, map[string][]string{vpnList: {fromKP}})
wantTags(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
"asn=64511", "country=KP", "vpn")
}
func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
tagLists: "vpn=" + vpnList, denyNets: fromKP, rateLimitPerMinute: "1",
})
loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
if line.Heuristic != "deny_nets" {
t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic)
}
wantTags(t, line, "vpn")
// The second request breaks the limit of one a minute, which bans the client.
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
line = s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
if line.Heuristic != "active_ban" {
t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic)
}
wantTags(t, line, "vpn")
}
+39
View File
@@ -0,0 +1,39 @@
package discriminator
// The heuristic trap_paths. It reads SWW_TRAP_PATHS.
import (
"context"
"slices"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// trapPaths refuses a request for a path in SWW_TRAP_PATHS: its path as a
// path rule sees it, without the query, as sent or once percent-decoded, is one
// of them. Such a request is a clear sign of attack, as a ban rule's match is:
// it bans the client's netblock, or in observe mode raises the alert for the
// ban it would have made. The ban's notes give the path as sent, and so does
// its figure.
type trapPaths struct{}
func (trapPaths) name() string { return "trap_paths" }
func (trapPaths) check(_ context.Context, rq *request, now time.Time) *verdict {
path := rules.Path(rq.in)
if !slices.Contains(rq.h.config.TrapPaths, path) &&
!slices.Contains(rq.h.config.TrapPaths, rules.DecodeOnce(path)) {
return nil
}
rq.attack = true
rq.banForAttack(now, bans.Notes{TrapPath: path})
return &verdict{
action: requestlog.ActionBanned,
figures: map[string]any{"path": path},
}
}
+10
View File
@@ -83,6 +83,16 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
} }
} }
func TestTrapPathRefusalNamesItsHeuristicAndPath(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden,
requestlog.ActionBanned)
wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`)
}
func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) { func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
t.Parallel() t.Parallel()
+14 -4
View File
@@ -133,8 +133,11 @@ func (f *File) Watch(ctx context.Context) {
} }
if err != nil { if err != nil {
f.params.ProcessLog.Error("cannot watch the lookup database for replacements", f.params.ProcessLog.Error(
"error", err.Error()) "cannot watch the lookup database for replacements",
"error",
err.Error(),
)
return return
} }
@@ -194,7 +197,10 @@ func (f *File) readAgain() {
f.params.Alerts.Raise(alerts.Alert{ f.params.Alerts.Raise(alerts.Alert{
Event: alerts.EventFileError, Event: alerts.EventFileError,
Reason: kept, Reason: kept,
Detail: map[string]any{"file": f.params.Path, "error": err.Error()}, Detail: map[string]any{
"file": f.params.Path,
"error": err.Error(),
},
}) })
f.params.ProcessLog.Error(kept, "error", err.Error()) f.params.ProcessLog.Error(kept, "error", err.Error())
@@ -212,7 +218,11 @@ func (f *File) use(reader *maxminddb.Reader) {
f.lastRead = f.params.Now() f.lastRead = f.params.Now()
f.mu.Unlock() f.mu.Unlock()
f.params.ProcessLog.Info("read the lookup database", "file", f.params.Path) f.params.ProcessLog.Info(
"read the lookup database",
"file",
f.params.Path,
)
} }
// read reads the lookup database at path. The whole file is read into // read reads the lookup database at path. The whole file is read into
+3 -3
View File
@@ -25,7 +25,7 @@ type busiest struct {
requests *prometheus.CounterVec requests *prometheus.CounterVec
requestBytes *prometheus.CounterVec requestBytes *prometheus.CounterVec
responseBytes *prometheus.CounterVec responseBytes *prometheus.CounterVec
// refused are, by country, the requests the country lists refused; nil // refused are, by country, the requests the country list refused; nil
// by AS number. // by AS number.
refused *prometheus.CounterVec refused *prometheus.CounterVec
@@ -42,7 +42,7 @@ type busiest struct {
func newCountries(topN int) *busiest { func newCountries(topN int) *busiest {
countries := newBusiest(topN, "country", "the client's country") countries := newBusiest(topN, "country", "the client's country")
countries.refused = counterVec("sww_country_list_refusals_total", countries.refused = counterVec("sww_country_list_refusals_total",
"Requests the country lists refused, by the client's country.", "Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
[]string{"country"}) []string{"country"})
return countries return countries
@@ -88,7 +88,7 @@ func (b *busiest) Collect(ch chan<- prometheus.Metric) {
} }
// vecs returns the metrics: by AS number, those of requests and bytes; by // vecs returns the metrics: by AS number, those of requests and bytes; by
// country, the refusals by the country lists as well. // country, the refusals by the country list as well.
func (b *busiest) vecs() []*prometheus.CounterVec { func (b *busiest) vecs() []*prometheus.CounterVec {
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes} vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
if b.refused != nil { if b.refused != nil {
+39 -16
View File
@@ -1,7 +1,8 @@
// Package reputation fetches the lists the settings name by URL: the // Package reputation fetches the lists the settings name by URL: the
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines // blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
// SWW_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec // SWW_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_TAG_LISTS, and
// engine SWW_CROWDSEC_LAPI_URL names. It keeps the last good copy of // the decision list of the CrowdSec engine SWW_CROWDSEC_LAPI_URL names. It
// keeps the last good copy of
// each, whole, comment lines included, which is used while a fetch fails, // each, whole, comment lines included, which is used while a fetch fails,
// and when each was last tried. It also asks the DNSBL zones of // and when each was last tried. It also asks the DNSBL zones of
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks // SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
@@ -71,6 +72,8 @@ type Params struct {
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset. // (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
BlocklistURLs []string BlocklistURLs []string
ASNLimitPercentURL string ASNLimitPercentURL string
// TagLists are the address lists that add a tag (SWW_TAG_LISTS).
TagLists []config.TagList
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while // CrowdSecDecisionsURL is the CrowdSec decision list, "" while
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey // SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
// (SWW_CROWDSEC_LAPI_KEY). // (SWW_CROWDSEC_LAPI_KEY).
@@ -112,11 +115,11 @@ type list struct {
failures int failures int
} }
// 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 or a tag list, the
// it names, with the lengths among them, for the file of AS:percent lines, // netblocks it names, with the lengths among them, 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, and for the CrowdSec decision
// list, the decision on each netblock that ends last, with the lengths // list, the decision on each netblock that ends last, with the lengths among
// among them. // them.
type entries struct { type entries struct {
netblocks map[netip.Prefix]bool netblocks map[netip.Prefix]bool
lengths []int lengths []int
@@ -152,14 +155,18 @@ 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
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then // SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then the
// the CrowdSec decision list's. // tag lists', then 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)
} }
for _, list := range l.params.TagLists {
urls = append(urls, list.URL)
}
if l.params.CrowdSecDecisionsURL != "" { if l.params.CrowdSecDecisionsURL != "" {
urls = append(urls, l.params.CrowdSecDecisionsURL) urls = append(urls, l.params.CrowdSecDecisionsURL)
} }
@@ -184,6 +191,22 @@ func (l *Lists) ListedBy(addr netip.Addr) []string {
return listedBy return listedBy
} }
// TagsOf returns the names of the tag lists whose copy holds addr.
func (l *Lists) TagsOf(addr netip.Addr) []string {
l.mu.Lock()
defer l.mu.Unlock()
var names []string
for _, list := range l.params.TagLists {
if l.lists[list.URL].entries.contain(addr) {
names = append(names, list.Name)
}
}
return names
}
// ASNLimitPercent returns the percentage the copy of the file of // ASNLimitPercent returns the percentage the copy of the file of
// AS:percent lines gives asn, and whether it lists asn. // AS:percent lines gives asn, and whether it lists asn.
func (l *Lists) ASNLimitPercent(asn string) (int64, bool) { func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
@@ -481,10 +504,10 @@ func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
} }
// parse reads the lines of the list at listURL, fetched at fetched: those // parse reads the lines of the list at listURL, fetched at fetched: those
// of a blocklist, of the file of AS:percent lines, or of the CrowdSec // of a blocklist or a tag list, of the file of AS:percent lines, or of the
// decision list. In the first two, anything after a ; or a # on a line is // CrowdSec decision list. In all but the decision list, anything after a ; or
// left out, and so is a line left blank. Any other line that does not read // a # on a line is left out, and so is a line left blank. Any other line that
// is an error naming it by its number. // does not read is an error naming it by its number.
func (l *Lists) parse( func (l *Lists) parse(
listURL string, lines []string, fetched time.Time, listURL string, lines []string, fetched time.Time,
) (entries, error) { ) (entries, error) {
@@ -498,8 +521,8 @@ func (l *Lists) parse(
} }
} }
// parseNetblocks reads a blocklist's lines, each an address or a netblock // parseNetblocks reads the lines of a blocklist or a tag list, each an address
// as the settings take them. // or a netblock as the settings take them.
func parseNetblocks(lines []string) (entries, error) { func parseNetblocks(lines []string) (entries, error) {
found := entries{netblocks: map[netip.Prefix]bool{}} found := entries{netblocks: map[netip.Prefix]bool{}}
@@ -551,7 +574,7 @@ func parseNetblock(text string) (netip.Prefix, bool) {
} }
// parsePercents reads the lines of the file of AS:percent lines, each an // parsePercents reads the lines of the file of AS:percent lines, each an
// AS number, : and a percentage, as SWW_ASN_LIMIT_PERCENT takes them. An // AS number, : and a percentage, such as AS64496:50. An
// AS number listed more than once gets the lowest of its percentages. // AS number listed more than once gets the lowest of its percentages.
func parsePercents(lines []string) (entries, error) { func parsePercents(lines []string) (entries, error) {
found := entries{percents: map[string]int64{}} found := entries{percents: map[string]int64{}}
+38 -6
View File
@@ -19,28 +19,36 @@ const (
// ActionForward is a request discriminator allowed, which rproxy passes to // ActionForward is a request discriminator allowed, which rproxy passes to
// the app. // the app.
ActionForward = "forward" ActionForward = "forward"
// ActionTooLarge is a request over the header limits. // ActionTooLarge is a request over the header limits.
ActionTooLarge = "too_large" ActionTooLarge = "too_large"
// ActionRateLimited is a request refused because it took its client // ActionRateLimited is a request refused because it took its client
// over a rate limit, which bans the client. // over a rate limit, which bans the client.
ActionRateLimited = "rate_limited" ActionRateLimited = "rate_limited"
// ActionBanned is a request refused because a ban covers its client, // ActionBanned is a request refused because a ban covers its client,
// or because it matched a ban rule, asked for a trap path or the // or because it matched a ban rule, asked for a trap path or the
// CrowdSec decision list lists its client, each of which bans the // CrowdSec decision list lists its client, each of which bans the
// client. // 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 // ActionWAFBlocked is a request refused because the Core Rule Set
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD. // scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
ActionWAFBlocked = "waf_blocked" ActionWAFBlocked = "waf_blocked"
// ActionDenied is a request refused because its client is in // ActionDenied is a request refused because its client is in
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny, // SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while // or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
// SWW_REPUTATION_ACTION is deny. // SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
ActionDenied = "denied" ActionDenied = "denied"
// ActionCountryDenied is a request refused for its client's country.
// ActionCountryDenied is a request refused because its client's country
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
ActionCountryDenied = "country_denied" ActionCountryDenied = "country_denied"
// ActionAdmin is a request discriminator answered at one of its own // ActionAdmin is a request discriminator answered at one of its own
// endpoints, or a question about a path under /_sww/. // endpoints, or a question about a path under /_sww/.
@@ -131,6 +139,16 @@ type Line struct {
// ActionRuleBlocked, ActionWAFBlocked, or, in discriminator, ActionTooLarge // ActionRuleBlocked, ActionWAFBlocked, or, in discriminator, ActionTooLarge
// for the header limits. // for the header limits.
WouldAction string `json:"would_action,omitempty"` WouldAction string `json:"would_action,omitempty"`
// Heuristic is the name of the heuristic that refused the request, in
// observe mode too, and HeuristicFigures are the figures it acted on, by
// name.
Heuristic string `json:"heuristic,omitempty"`
HeuristicFigures map[string]any `json:"heuristic_figures,omitempty"`
// AfterAnswerHeuristic and AfterAnswerHeuristicFigures are the same for
// the heuristic that banned the client after discriminator answered the
// request.
AfterAnswerHeuristic string `json:"after_answer_heuristic,omitempty"`
AfterAnswerHeuristicFigures map[string]any `json:"after_answer_heuristic_figures,omitempty"` //nolint:lll // a struct tag cannot be split
// 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
// the rate limits below 100, that percentage and the setting that gave // the rate limits below 100, that percentage and the setting that gave
@@ -162,6 +180,8 @@ type Line struct {
// whose verdict lists it, their keys masked, then abuseipdb when its // whose verdict lists it, their keys masked, then abuseipdb when its
// score is a hit. // score is a hit.
Reputation []string `json:"reputation,omitempty"` Reputation []string `json:"reputation,omitempty"`
// Tags are the request's tags, sorted.
Tags []string `json:"tags,omitempty"`
// Offence is the offence the request was held as, OffenceLimit. // Offence is the offence the request was held as, OffenceLimit.
Offence string `json:"offence,omitempty"` Offence string `json:"offence,omitempty"`
// BanExpires is when the ban the request made, or was refused under, // BanExpires is when the ban the request made, or was refused under,
@@ -245,7 +265,11 @@ func FormatTime(t time.Time) string {
// Milliseconds is d in milliseconds, to the microsecond. // Milliseconds is d in milliseconds, to the microsecond.
func Milliseconds(d time.Duration) float64 { func Milliseconds(d time.Duration) float64 {
return float64(d.Microseconds()) / float64(time.Millisecond/time.Microsecond) return float64(
d.Microseconds(),
) / float64(
time.Millisecond/time.Microsecond,
)
} }
// NewProcessLogger returns the logger for the process's own messages: // NewProcessLogger returns the logger for the process's own messages:
@@ -253,17 +277,25 @@ func Milliseconds(d time.Duration) float64 {
// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance. // as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe; // It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
// the request lines Write writes are never held back. // the request lines Write writes are never held back.
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger { func NewProcessLogger(
w io.Writer,
instanceName string,
level slog.Level,
) *slog.Logger {
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{ handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: level, 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()),
)
} }
return attr return attr
}, },
}) })
return slog.New(handler).With("type", "process", "instance", instanceName) return slog.New(handler).
With("type", "process", "instance", instanceName)
} }
+2 -2
View File
@@ -618,7 +618,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
instanceName: instance, instanceName: instance,
lookupSource: "file", lookupSource: "file",
lookupDBPath: path, lookupDBPath: path,
"SWW_DENIED_COUNTRIES": "kp", "SWW_TAG_DENY": "country=KP",
// The requests sent until a replacement takes effect, and those // The requests sent until a replacement takes effect, and those
// for the metrics, must not break a rate limit, whose ban would // for the metrics, must not break a rate limit, whose ban would
// refuse them too. // refuse them too.
@@ -1280,7 +1280,7 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
"SWW_RATE_LIMIT_PER_HOUR": "1500", "SWW_RATE_LIMIT_PER_HOUR": "1500",
rateLimitPerDay: "6000", rateLimitPerDay: "6000",
"SWW_RATE_LIMIT_EXEMPT_PATHS": "", "SWW_RATE_LIMIT_EXEMPT_PATHS": "",
"SWW_DENIED_COUNTRIES": "", "SWW_TAG_DENY": "",
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "", "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
"SWW_BAN_RESPONSE": "403", "SWW_BAN_RESPONSE": "403",
"SWW_LIMIT_BAN_DURATION": "1h", "SWW_LIMIT_BAN_DURATION": "1h",
+52 -7
View File
@@ -1,8 +1,51 @@
# The eighth change: on gitea's paths, some fields skip the rules that refused # gitea's fixes: some fields skip the rules that refused ordinary gitea requests.
# ordinary gitea requests there. They are the fields where gitea sends a search, # They are the fields where gitea sends a search, a file name or a branch name.
# a file name or a branch name. Each exclusion runs with the request, before the # Each exclusion runs with the request, before the rules it switches off, those
# rules it switches off, those of phase 1 included, so this file is loaded before # of phase 1 included, so this file is loaded before the Core Rule Set's rules.
# the Core Rule Set's rules. Each comment names a request the rule refused. # Each comment names a request the rule refused.
# A request whose hostname SWW_APPS does not list gitea for skips every rule
# below. Inspect sets tx.app_gitea on the others.
SecRule &TX:app_gitea "@eq 0" \
"id:900590,phase:1,pass,nolog,skipAfter:END_GITEA"
# 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, on any path:
# 930120: .gitignore
# 932160: bin/docker-entrypoint
# 932260: docker-build
SecAction "id:900591,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:path,\
ctl:ruleRemoveTargetById=930120;ARGS:files,\
ctl:ruleRemoveTargetById=930120;ARGS:skip-to,\
ctl:ruleRemoveTargetById=930120;ARGS:sub_path,\
ctl:ruleRemoveTargetById=930120;ARGS:ref,\
ctl:ruleRemoveTargetById=930120;ARGS:sha,\
ctl:ruleRemoveTargetById=930120;ARGS:branch,\
ctl:ruleRemoveTargetById=930120;ARGS:workflow,\
ctl:ruleRemoveTargetById=930120;ARGS:artifactName,\
ctl:ruleRemoveTargetById=930120;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=932160;ARGS:path,\
ctl:ruleRemoveTargetById=932160;ARGS:files,\
ctl:ruleRemoveTargetById=932160;ARGS:skip-to,\
ctl:ruleRemoveTargetById=932160;ARGS:sub_path,\
ctl:ruleRemoveTargetById=932160;ARGS:ref,\
ctl:ruleRemoveTargetById=932160;ARGS:sha,\
ctl:ruleRemoveTargetById=932160;ARGS:branch,\
ctl:ruleRemoveTargetById=932160;ARGS:workflow,\
ctl:ruleRemoveTargetById=932160;ARGS:artifactName,\
ctl:ruleRemoveTargetById=932160;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=932260;ARGS:path,\
ctl:ruleRemoveTargetById=932260;ARGS:files,\
ctl:ruleRemoveTargetById=932260;ARGS:skip-to,\
ctl:ruleRemoveTargetById=932260;ARGS:sub_path,\
ctl:ruleRemoveTargetById=932260;ARGS:ref,\
ctl:ruleRemoveTargetById=932260;ARGS:sha,\
ctl:ruleRemoveTargetById=932260;ARGS:branch,\
ctl:ruleRemoveTargetById=932260;ARGS:workflow,\
ctl:ruleRemoveTargetById=932260;ARGS:artifactName,\
ctl:ruleRemoveTargetById=932260;ARGS:redirect_to"
# gitea's searches of code, of issues and pull requests, and of commits send the # gitea's searches of code, of issues and pull requests, and of commits send the
# text searched for in q, which skips these rules there: # text searched for in q, which skips these rules there:
@@ -155,8 +198,8 @@ SecRule REQUEST_FILENAME \
# 942140: information_schema.sql # 942140: information_schema.sql
# 944110: java/lang/Runtime.java # 944110: java/lang/Runtime.java
# 944130: java.lang.String.md # 944130: java.lang.String.md
# Each request skips the rules that refused it. The fourth change already has # Each request skips the rules that refused it. Rule 900591 already has path and
# path and redirect_to skip 930120, 932160 and 932260 on every path. # redirect_to skip 930120, 932160 and 932260 on every path.
# A file's commit graph, in file. # A file's commit graph, in file.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \ SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
@@ -281,3 +324,5 @@ SecRule REQUEST_FILENAME \
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \ "@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
"id:900640,phase:1,pass,nolog,\ "id:900640,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range" ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
SecMarker END_GITEA
+101 -57
View File
@@ -1,9 +1,9 @@
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the // 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 // method, the URL with its query and the headers of a request, and on its
// body while SWW_WAF_BODY_LIMIT is set, with the eight changes sww // body while SWW_WAF_BODY_LIMIT is set, with the six changes sww
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as // makes to it, the fixes for the apps SWW_APPS names, and the exclusions in
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no // SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings"
// response. // in SPEC.md describes them. It reads no response.
// //
// sww writes only to its state directory, so Coraza is built with // sww writes only to its state directory, so Coraza is built with
// its no_fs_access tag, as the Dockerfile and script/build build it: of a // its no_fs_access tag, as the Dockerfile and script/build build it: of a
@@ -16,6 +16,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"net"
"net/http" "net/http"
"net/netip" "net/netip"
"os" "os"
@@ -32,8 +34,8 @@ import (
// directives are the Core Rule Set as sww runs it, with the // directives are the Core Rule Set as sww runs it, with the
// paranoia level for %d, bodyDirectives for the first %s while // paranoia level for %d, bodyDirectives for the first %s while
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and // SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and the
// giteaExclusions for the third. // directives of every app in apps for the third.
// Each rule sww adds has an id from 900000 to 900999, the ids the // Each rule sww adds has an id from 900000 to 900999, the ids the
// Core Rule Set keeps for the rules that set it up, which // Core Rule Set keeps for the rules that set it up, which
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no // SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
@@ -81,44 +83,28 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
# before the rules of phase 1 too. # before the rules of phase 1 too.
%s %s
# The eighth change comes before the rules for the same reason. # The apps' fixes come before the rules for the same reason.
%s %s
# The sixth: only the rules for requests are loaded, and no response is # The fifth: only the rules for requests are loaded, and no response is
# inspected. # inspected.
Include @owasp_crs/REQUEST-*.conf Include @owasp_crs/REQUEST-*.conf
# The third: redirect_uri is not checked for a URL naming an IP address or # 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, # localhost. Coraza matches a parameter name here without regard to case.
# without regard to case. ARGS holds the fields of a form data or multipart # ARGS holds the fields of a form data or multipart body Coraza reads as
# body Coraza reads as well as the query parameters, so a field of one of # well as the query parameters, so a field of this name is left out too.
# these names is left out too.
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri" SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri" SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
# The fourth: the query parameters in which gitea sends names within a # The fourth: Referer is not checked for a Unix command without arguments,
# repository or its own records, or a page of its own site, are not # for Java starting a process, or for a Java class name. 944130 refused
# checked against the lists of system files, shell paths and command # every page opened from the page of java.lang.String.md.
# names. Coraza takes one rule id per directive.
SecRuleUpdateTargetById 930120 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
SecRuleUpdateTargetById 932160 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
# The fifth, for Referer: it is not checked for a Unix command without
# arguments, for Java starting a process, or for a Java class name. 944130
# refused every page opened from the page of java.lang.String.md. The
# cookies are left out in Inspect.
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
# The seventh: a container image push that reuses a layer names the image # The sixth: a container image push that reuses a layer names the image
# it takes the layer from in the query parameter from, which is not # it takes the layer from in the query parameter from, which is not
# checked for command names. 932260 refused the push of a layer from # checked for command names. 932260 refused the push of a layer from
# owner/python3-app. # owner/python3-app.
@@ -168,18 +154,36 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'" severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
` `
// giteaExclusions are the eighth change: the fields of gitea's own // app is an app whose ordinary requests the Core Rule Set would refuse,
// requests that skip the rules that refused them there, as Coraza // with the fixes that let them pass.
// directives. type app struct {
// // directives are Coraza exclusions, loaded before the Core Rule Set's
//go:embed gitea.conf // rules. Their first rule skips the rest unless Inspect sets the
var giteaExclusions string // transaction variable app_ and the app's name, and they end with the
// marker it skips to.
directives string
// cookies are the cookies the Core Rule Set reads a request without.
cookies []string
}
// cookiesNotRead are the cookies the Core Rule Set reads a request //go:embed gitea.conf
// without, the rest of the fifth change. var giteaDirectives string
// apps are the apps whose fixes sww has, by the name SWW_APPS gives each.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a map
var cookiesNotRead = []string{"gitea_flash", "redirect_to"} var apps = map[string]app{
"gitea": {
directives: giteaDirectives,
cookies: []string{"gitea_flash", "redirect_to"},
},
}
// AppNames returns the names of the apps whose fixes sww has, in
// alphabetical order.
func AppNames() []string {
return slices.Sorted(maps.Keys(apps))
}
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
@@ -195,6 +199,10 @@ type Params struct {
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while // Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
// there is none. // there is none.
ExclusionsFile string ExclusionsFile string
// Apps are the names of the apps each hostname serves, by hostname in
// lowercase (SWW_APPS). A request gets the fixes of its hostname's
// apps only, or of every app while Apps is nil.
Apps map[string][]string
} }
// The ids of sww's own rules, which an exclusions file may not // The ids of sww's own rules, which an exclusions file may not
@@ -286,13 +294,15 @@ func checkExclusions(exclusions string) error {
type CoreRuleSet struct { type CoreRuleSet struct {
waf coraza.WAF waf coraza.WAF
bodyLimit int64 bodyLimit int64
apps map[string][]string
} }
// New returns the Core Rule Set with the eight changes, at params' // New returns the Core Rule Set with the six changes and the apps' fixes,
// paranoia level, without the rules it switches off, reading request // at params' paranoia level, without the rules it switches off, reading
// bodies up to params' limit, and with params' exclusions file loaded // request bodies up to params' limit, and with params' exclusions file
// before the Core Rule Set's rules. An exclusions file is an error if it // loaded before the Core Rule Set's rules. An exclusions file is an error
// cannot be read or does not parse, or if checkExclusions refuses it. // if it cannot be read or does not parse, or if checkExclusions refuses
// it.
func New(params Params) (*CoreRuleSet, error) { func New(params Params) (*CoreRuleSet, error) {
body := "" body := ""
if params.BodyLimit > 0 { if params.BodyLimit > 0 {
@@ -315,8 +325,13 @@ func New(params Params) (*CoreRuleSet, error) {
} }
} }
var fixes strings.Builder
for _, name := range AppNames() {
fixes.WriteString(apps[name].directives + "\n")
}
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions, text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
giteaExclusions) fixes.String())
if len(params.DisabledRules) > 0 { if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules)) ids := make([]string, len(params.DisabledRules))
@@ -334,7 +349,7 @@ func New(params Params) (*CoreRuleSet, error) {
return nil, fmt.Errorf("load the Core Rule Set: %w", err) return nil, fmt.Errorf("load the Core Rule Set: %w", err)
} }
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit}, nil return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit, apps: params.Apps}, nil
} }
// Result is what the Core Rule Set found in a request. // Result is what the Core Rule Set found in a request.
@@ -346,11 +361,12 @@ type Result struct {
Score int Score int
} }
// Inspect runs the Core Rule Set on r, a request from client: on its // Inspect runs the Core Rule Set on r, a request from client, with the
// method, its URL with the query, and its headers, the Cookie header // fixes of the apps appsFor names for r's hostname: on its method, its URL
// without the cookies in cookiesNotRead, and on body, r's body as the // with the query, and its headers, the Cookie header without those apps'
// caller has it, as readBody reads it. It returns what it found, what it // cookies, and on body, r's body as the caller has it, as readBody reads
// read of body, and the error that ended the reading early, if one did. // it. It returns what it found, what it read of body, and the error that
// ended the reading early, if one did.
func (c *CoreRuleSet) Inspect( func (c *CoreRuleSet) Inspect(
r *http.Request, client netip.Addr, body io.Reader, r *http.Request, client netip.Addr, body io.Reader,
) (Result, []byte, error) { ) (Result, []byte, error) {
@@ -358,13 +374,24 @@ func (c *CoreRuleSet) Inspect(
// Closing would remove the files Coraza wrote, and it writes none. // Closing would remove the files Coraza wrote, and it writes none.
defer func() { _ = tx.Close() }() defer func() { _ = tx.Close() }()
// The transaction's variables are set only through Coraza's interface
// for plugins.
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
var cookiesNotRead []string
for _, name := range c.appsFor(r.Host) {
state.Variables().TX().Set("app_"+name, []string{"1"})
cookiesNotRead = append(cookiesNotRead, apps[name].cookies...)
}
tx.ProcessConnection(client.String(), 0, "", 0) tx.ProcessConnection(client.String(), 0, "", 0)
tx.ProcessURI(r.URL.String(), r.Method, r.Proto) tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
for name, values := range r.Header { for name, values := range r.Header {
for _, value := range values { for _, value := range values {
if name == "Cookie" { if name == "Cookie" {
value = withoutCookiesNotRead(value) value = withoutCookies(value, cookiesNotRead)
if value == "" { if value == "" {
continue // it held those cookies alone continue // it held those cookies alone
} }
@@ -457,9 +484,26 @@ func score(tx types.Transaction) int {
return n return n
} }
// withoutCookiesNotRead returns value, a Cookie header's, without the // appsFor returns the names of the apps whose fixes a request for host,
// cookies in cookiesNotRead. // its Host header, gets: every app while SWW_APPS is unset, and otherwise
func withoutCookiesNotRead(value string) string { // those SWW_APPS lists for host's name, without the port and in
// lowercase.
func (c *CoreRuleSet) appsFor(host string) []string {
if c.apps == nil {
return AppNames()
}
hostname, _, err := net.SplitHostPort(host)
if err != nil {
hostname = host // it has no port
}
return c.apps[strings.ToLower(hostname)]
}
// withoutCookies returns value, a Cookie header's, without the cookies
// named in cookiesNotRead.
func withoutCookies(value string, cookiesNotRead []string) string {
var kept []string var kept []string
for cookie := range strings.SplitSeq(value, ";") { for cookie := range strings.SplitSeq(value, ";") {
+92 -2
View File
@@ -120,8 +120,8 @@ func atDefaults(t *testing.T) *waf.CoreRuleSet {
} }
// wantChange checks that crs lets through passes, a gitea request one of // wantChange checks that crs lets through passes, a gitea request one of
// the eight changes is for, and still finds result in refused, a request // the six changes or gitea's fixes are for, and still finds result in
// like it that the change is not for. // refused, a request like it that they are not for.
func wantChange( func wantChange(
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result, t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
) { ) {
@@ -381,6 +381,96 @@ func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
matched(930120, 932160)) matched(930120, 932160))
} }
// giteaFixed are gitea requests that only gitea's fixes let through, one
// for each kind of fix, each with what the Core Rule Set finds in it
// without them.
//
//nolint:gochecknoglobals // a constant cannot be a list
var giteaFixed = []struct {
request request
result waf.Result
}{
// A search of code, on the path of gitea's search.
{get("/explore/code?q=SELECT%20*%20FROM%20users%20WHERE"), matched(942100)},
// A file name, in a parameter gitea sends names in, on any path.
{get("/?path=.gitignore"), matched(930120)},
// A file name, in a cookie of gitea's.
{
get("/owner/repo", "Cookie: gitea_flash=success%3DFile%2Bpackage.json%2Bdeleted"),
matched(930120),
},
}
// The hostnames of the tests of SWW_APPS: the one that serves gitea, and
// one that serves another app.
const (
giteaHost = "git.example"
otherHost = "blog.example"
)
// withHost returns r sent to host.
func withHost(r request, host string) request {
return request{r.method, r.target, append([]string{"Host: " + host}, r.headers...)}
}
// servingApps returns the Core Rule Set at the defaults, with SWW_APPS
// giving apps.
func servingApps(t *testing.T, apps map[string][]string) *waf.CoreRuleSet {
t.Helper()
crs, err := waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, Apps: apps,
})
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
return crs
}
func TestAppsUnsetGivesEveryHostnameEveryAppsFixes(t *testing.T) {
t.Parallel()
crs := servingApps(t, nil)
for _, fixed := range giteaFixed {
for _, host := range []string{giteaHost, otherHost} {
wantResult(t, crs, withHost(fixed.request, host), waf.Result{})
}
}
}
func TestListedHostnameGetsItsAppsFixes(t *testing.T) {
t.Parallel()
crs := servingApps(t, map[string][]string{giteaHost: {"gitea"}})
// The hostname is matched without the port and without regard to case.
for _, host := range []string{giteaHost, "GIT.Example:8443"} {
for _, fixed := range giteaFixed {
wantResult(t, crs, withHost(fixed.request, host), waf.Result{})
}
}
}
func TestUnlistedHostnameGetsTheFullCheck(t *testing.T) {
t.Parallel()
for _, apps := range []map[string][]string{
{giteaHost: {"gitea"}},
// SWW_APPS set empty lists no hostname.
{},
} {
crs := servingApps(t, apps)
for _, host := range []string{otherHost, otherHost + ":8443", "git.example.org"} {
for _, fixed := range giteaFixed {
wantResult(t, crs, withHost(fixed.request, host), fixed.result)
}
}
}
}
// withExclusions returns the Core Rule Set at the defaults, with the // withExclusions returns the Core Rule Set at the defaults, with the
// exclusions file at path. // exclusions file at path.
func withExclusions(path string) (*waf.CoreRuleSet, error) { func withExclusions(path string) (*waf.CoreRuleSet, error) {
-5
View File
@@ -20,13 +20,8 @@ main() {
# the version a build stage derives from the .git in the context. # the version a build stage derives from the .git in the context.
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"
revision="$(git rev-parse HEAD 2>/dev/null || true)"
[ -n "$revision" ] || revision="unknown"
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
docker build --no-cache \ docker build --no-cache \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
--build-arg REVISION="$revision" \
--build-arg CREATED="$created" \
-t "$("$SCRIPT_DIR/projectname")" . -t "$("$SCRIPT_DIR/projectname")" .
} }
-5
View File
@@ -16,13 +16,8 @@ main() {
# the version a build stage derives from the .git in the context. # the version a build stage derives from the .git in the context.
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"
revision="$(git rev-parse HEAD 2>/dev/null || true)"
[ -n "$revision" ] || revision="unknown"
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
docker build --no-cache \ docker build --no-cache \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
--build-arg REVISION="$revision" \
--build-arg CREATED="$created" \
-t "$("$SCRIPT_DIR/projectname")" . -t "$("$SCRIPT_DIR/projectname")" .
} }
Executable
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
# script/publish: build the image on the Docker host and push it to the
# registry under four tags: the UTC date (YYYY-MM-DD), the short commit
# hash, the branch name and latest. The build is script/docker's, so the
# tests and the linter run first. The host and the registry name are
# environment variables with defaults, for running it against another.
#
# The working tree must be clean: the hash tag names a commit, and an
# image built from uncommitted changes would carry a hash that does not
# describe it.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
: "${DOCKER_HOST:=ssh://root@fsn1app1}"
: "${PUBLISH_IMAGE:=git.eeqj.de/sneak/smallwebwaf}"
export DOCKER_HOST
main() {
cd "$ROOT"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`.
changes="$(git status --porcelain)"
if [ -n "$changes" ]; then
echo "script/publish: the working tree is not clean; commit or stash first" >&2
exit 1
fi
date="$(date -u +%Y-%m-%d)"
hash="$(git rev-parse --short HEAD)"
branch="$(git rev-parse --abbrev-ref HEAD)"
if [ "$branch" = "HEAD" ]; then
echo "script/publish: detached HEAD; check out a branch first" >&2
exit 1
fi
# A tag is [A-Za-z0-9_.-]; a branch name such as feature/x is not.
branch="$(printf '%s' "$branch" | tr -c 'A-Za-z0-9_.-' '-')"
local_tag="$("$SCRIPT_DIR/projectname")"
"$SCRIPT_DIR/docker"
for tag in "$date" "$hash" "$branch" latest; do
docker tag "$local_tag" "$PUBLISH_IMAGE:$tag"
done
for tag in "$date" "$hash" "$branch" latest; do
docker push "$PUBLISH_IMAGE:$tag"
done
echo "pushed $PUBLISH_IMAGE with tags: $date $hash $branch latest"
}
main "$@"