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

Model: opus-5-5
2026-11-07 12:39:34 +00:00
63 changed files with 791 additions and 2692 deletions

No files matched your search

+16 -11
View File
@@ -124,6 +124,20 @@ 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
@@ -136,19 +150,10 @@ 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/* /etc/dpkg/dpkg.cfg.d/z-man \ && rm -rf /var/lib/apt/lists/*
&& 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.
+3 -7
View File
@@ -1,12 +1,11 @@
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \ .PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
hooks build run example-app run example-app
# Makefile targets are thin shims; the implementations live in script/ # Makefile targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
# of README.md). 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; publish builds # example-app checks the image with an app built on it.
# the image on the Docker host and pushes it to the registry.
bootstrap: bootstrap:
@script/bootstrap @script/bootstrap
@@ -35,9 +34,6 @@ check:
docker: docker:
@script/docker @script/docker
publish:
@script/publish
hooks: hooks:
@script/install-precommit @script/install-precommit
+25 -52
View File
@@ -68,9 +68,8 @@ Not built, and not planned:
### The image ### The image
The image is published at `git.eeqj.de/sneak/smallwebwaf`. You can also build it No image is published. Build it from a clone. `make docker` runs the tests and
from a clone. `make docker` runs the tests and the linter, then builds the the linter, then builds the image, tagged `smallwebwaf`.
image, tagged `smallwebwaf`.
```sh ```sh
git clone https://git.eeqj.de/sneak/smallwebwaf.git git clone https://git.eeqj.de/sneak/smallwebwaf.git
@@ -257,14 +256,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_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_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_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. | | `SWW_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
| `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_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_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 `SWW_COUNTRY_LIMIT_PERCENT`. |
| `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; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). | | `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. | | `SWW_RULES_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. |
@@ -414,7 +413,8 @@ 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_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25 SWW_ASN_LIMIT_PERCENT: AS14061:50,AS16276: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,34 +506,31 @@ of the request reaches the app.
`discriminator` takes the checks: `discriminator` takes the checks:
3. The tag lists. 3. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`. 4. The bans.
5. The bans. 5. The lookup of the client's AS number and country.
6. The lookup of the client's AS number and country. 6. The country lists.
7. The country list. 7. The reputation sources.
8. The reputation sources. 8. The client's limit percentage, from its AS number, its country or a
9. The tag refusals of `SWW_TAG_DENY`. reputation hit.
10. The client's limit percentage, from its tags, its AS number, its country or 9. The rate limits.
a reputation hit. 10. The trap paths.
11. The rate limits. 11. The rule files.
12. The trap paths. 12. The header limits, and then the Core Rule Set.
13. The rule files. 13. A path under `/_sww/` is answered `404`, so that the app never sees that
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:
17. The request is passed to the app, streaming. 14. The request is passed to the app, streaming.
18. After the answer, `rproxy` sends `discriminator` a report with the status 15. 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 14 is answered `431`, and bans nobody request over the header limits of step 12 is answered `431`, and bans nobody
either. `rproxy` itself answers `405` to a method it does not pass on, and the 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.
@@ -552,21 +549,6 @@ 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
@@ -683,15 +665,6 @@ 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`.
+68 -122
View File
@@ -119,14 +119,12 @@ to them.
## What happens to a request ## What happens to a request
`rproxy` runs steps 1, 2 and 17, and sends the report of step 18. `rproxy` runs steps 1, 2 and 14, and sends the report of step 15.
`discriminator` runs steps 3 to 16, and does the counting of step 18. The first `discriminator` runs steps 3 to 13, and does the counting of step 15. The first
step that produces a final answer ends the checks. Step 18 runs for each request step that produces a final answer ends the checks. Step 15 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. A step that names a heuristic in parentheses runs that heuristic. The its rules.
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.
@@ -143,67 +141,55 @@ request log's `heuristic` field gives that name. `refusal_limit` and
- 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 tag lists (`tag_lists`; see "Request tags"). Each list that holds the 3. The static lists. A client in `SWW_ALLOW_NETS` skips steps 4 to 12, the byte
client adds its tag. A client in `SWW_ALLOW_NETS` gets no tag. limits and the error burst. A client in `SWW_DENY_NETS` is refused; a client
4. The static lists. A client in `SWW_ALLOW_NETS` skips steps 5 to 15, the byte in both lists is let through.
limits and the error burst. A client in `SWW_DENY_NETS` is refused 4. Bans. A client under a ban is refused (see "Bans").
(`deny_nets`); a client in both lists is let through. 5. The lookup, unless `SWW_LOOKUP_SOURCE` is `off` (see "Lookup of AS number and
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:
- the country list; - a 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.
7. The country list (`countries`; see "Country list"). 6. The country lists (see "Country lists").
8. The reputation sources (see "Reputation"), in this order: 7. The reputation sources (see "Reputation"). A client on a fetched blocklist
- A client on a fetched blocklist gets `SWW_BLOCKLIST_ACTION` gets `SWW_BLOCKLIST_ACTION`. A client on the CrowdSec decision list is
(`blocklists`). banned. A client listed by a DNS blocklist, or scored at least
- A client on the CrowdSec decision list is banned (`crowdsec`). `SWW_ABUSEIPDB_MIN_SCORE` by AbuseIPDB, gets `SWW_REPUTATION_ACTION`.
- A client listed by a DNS blocklist gets `SWW_REPUTATION_ACTION` (`dnsbl`). 8. The client's limit percentage (see "Biased thresholds").
- A client scored at least `SWW_ABUSEIPDB_MIN_SCORE` by AbuseIPDB gets 9. Rate limiting (see "Request rate limits, per client"). A request over a limit
`SWW_REPUTATION_ACTION` (`abuseipdb`). times the percentage is refused and bans the client.
9. The tag refusals (`tag_deny`; see "Request tags"). A request carrying a tag 10. The trap paths (see "Attack detection"). A request for one is refused and
in `SWW_TAG_DENY` is refused. bans the client.
10. The client's limit percentage (see "Biased thresholds"). 11. The rule files (see "Rule files"). Matching stops at the first rule that
11. Rate limiting (`rate_limits`; see "Request rate limits, per client"). A refuses.
request over a limit times the percentage is refused and bans the client. 12. The header limits (see "Answering in time"), and then the Core Rule Set (see
12. The trap paths (`trap_paths`; see "Attack detection"). A request for one is "Attack detection"). A Core Rule Set match is refused with `403` while
refused and bans the client. `SWW_WAF_MODE` is `block`, and only logged and alerted while it is `detect`.
13. The rule files (`rule_files`; see "Rule files"). Matching stops at the first 13. A path under `/_sww/` is answered `404`, so that the app never sees that
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.
17. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing 14. `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.
18. After the visitor's answer has ended, `rproxy` sends the report (see "The 15. 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 (`byte_limits`). The response in progress is not cut off. is banned. 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 (`refusal_limit`; the refusals the error burst counts at the decision too (see "Attack
see "Attack detection"). 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
@@ -293,8 +279,7 @@ 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 list; - the country lists.
- `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
@@ -401,7 +386,7 @@ Conventions:
### Static lists ### Static lists
- `SWW_ALLOW_NETS` skips the checks step 4 names, for monitoring or the owner's - `SWW_ALLOW_NETS` skips the checks step 3 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
@@ -451,7 +436,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 18). it ends (see step 15).
### Lookup of AS number and country ### Lookup of AS number and country
@@ -480,12 +465,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 6). It also bounds each first answer, when a setting needs it (see step 5). 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 list and the biased thresholds treat as described there. Nothing the country lists 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
@@ -505,12 +490,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 list ### Country lists
`SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` is empty by default: the defaults judge a Both are empty by default: the defaults judge a client by what it does, not by
client by what it does, not by where it comes from. A `country=` tag in where it comes from.
`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
@@ -519,84 +504,45 @@ client by what it does, not by where it comes from. A `country=` tag in
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.
- A code in this list whose `country=` tag is in `SWW_TAG_DENY` stops the start - Both may be set. `SWW_DENIED_COUNTRIES` then adds nothing, since the exclusive
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 by AS number, country and tag. The biased thresholds lower the limits of clients in listed AS numbers and
The lists are empty by default, for the same reason as the country list. The countries. The lists are empty by default, for the same reason as the country
request log and the metrics show which AS numbers and countries a service's lists. The request log and the metrics show which AS numbers and countries a
abuse comes from. service's abuse comes from.
- `SWW_TAG_LIMIT_PERCENT` gives a tag a percentage, such as `asn=14061:50` (see - Each list gives AS numbers or countries a percentage, such as `AS14061:50`.
"Request tags"). A lowered limit is rounded down. Their clients get that percentage of every request and byte limit
- `SWW_UNKNOWN_LIMIT_PERCENT` at `0` is a zero allowance: the client's first (`SWW_ASN_LIMIT_PERCENT`, `SWW_COUNTRY_LIMIT_PERCENT`). A lowered limit is
request breaks a limit and bans it. rounded down. `0` is a zero allowance: the client's first request breaks a
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 `asn=` tag's percentage and its entry in number they list, they replace its percentage from `SWW_ASN_LIMIT_PERCENT` and
`SWW_ASN_LIMIT_PERCENT_URL`; for a country, its `country=` tag's percentage. `SWW_ASN_LIMIT_PERCENT_URL`; for a country, the one from
`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"). Each entry counts like fetched and kept like a blocklist (see "Reputation"). Where it and
an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`. Where both give a percentage, the `SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies.
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, its tags', or a reputation hit's `limit:<percent>`. A number's, its country's, or a reputation hit's `limit:<percent>`. A client
client without a country gets `SWW_UNKNOWN_LIMIT_PERCENT` in place of a without a country gets `SWW_UNKNOWN_LIMIT_PERCENT` in place of a country's.
country's. The percentage is 100 if none applies. Clients given a lower The percentage is 100 if none applies. Clients given a lower percentage are
percentage are banned after fewer requests, first for a while and then, for banned after fewer requests, first for a while and then, for repeated abuse,
repeated abuse, permanently. The log line of each counted request, and the permanently. The log line of each counted request, and the notes of each ban,
notes of each ban, say which setting lowered the limit. 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 `SWW_TAG_DENY` or the country list. operator's choice, through the country lists.
### Attack detection ### Attack detection
@@ -951,7 +897,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
percentage for its `asn=` tag in `SWW_TAG_LIMIT_PERCENT` is the response. `SWW_ASN_LIMIT_PERCENT` for its AS number 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;
@@ -1229,7 +1175,7 @@ the metrics.
### The answer ### The answer
`discriminator` runs steps 3 to 16 of "What happens to a request", in that `discriminator` runs steps 3 to 13 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
@@ -1268,13 +1214,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 6 says so, and for at most `SWW_LOOKUP_TIMEOUT`, by default 800 when step 5 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 tag lists, the CrowdSec decision list and the - fetching the blocklists, the CrowdSec decision list and the file
file `SWW_ASN_LIMIT_PERCENT_URL` names. `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,6 +9,10 @@
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
+2 -34
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`, for a reputation source with the - `denied`: refused for `SWW_DENY_NETS`, or for a reputation source with the
action `deny`, or for a tag in `SWW_TAG_DENY`; action `deny`;
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a - `banned`: refused under a ban, or for a `ban` rule, a trap path or a
CrowdSec decision, each of which bans the client; CrowdSec decision, each of which bans the client;
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`: - `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
@@ -108,36 +108,6 @@ 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.
@@ -169,8 +139,6 @@ 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`.
+17 -52
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 4 in [`SPEC.md`](../SPEC.md)). (see step 3 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
4 in [`SPEC.md`](../SPEC.md)). 3 in [`SPEC.md`](../SPEC.md)).
## Request rate limits, per client ## Request rate limits, per client
@@ -130,11 +130,9 @@ 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:
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`; - the two country lists;
- `SWW_ADD_LOOKUP_HEADERS`; - `SWW_ADD_LOOKUP_HEADERS`;
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming an `asn=` or `country=` - the four biased threshold lists and `SWW_ASN_LIMIT_PERCENT_URL`;
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
@@ -146,61 +144,28 @@ 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 list ## Country lists
- `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 whose countries whose clients get through, such as `us,de`. A country on both lists
`country=` tag is in `SWW_TAG_DENY` stops the start. 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_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a - `SWW_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:`
percentage of the byte limits alone, for the AS numbers or countries listed, and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
such as `AS14061:50` or `cn:25`. It takes the place of the percentage listed twice is refused.
`SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or - `SWW_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
country listed twice is refused. `cn:25,ru:50`.
- `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`. Each entry counts like an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`. `AS64496:50`. A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
## Attack detection ## Attack detection
+57 -396
View File
@@ -93,30 +93,29 @@ type Config struct {
LookupDBPath string LookupDBPath string
LookupTimeout time.Duration LookupTimeout time.Duration
AddLookupHeaders bool AddLookupHeaders bool
// ExclusivelyAllowedCountries, when not empty, are the only countries // DeniedCountries are the countries whose clients are refused
// whose clients are let through (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES), // (SWW_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not
// two-letter codes in capitals, as GeoJS gives them. // empty, are the only countries whose clients are let through
// (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES). Both hold two-letter codes in
// capitals, as GeoJS gives them.
DeniedCountries []string
ExclusivelyAllowedCountries []string ExclusivelyAllowedCountries []string
// TagLists are the address lists that each add a tag to the requests of // The biased thresholds. ASNLimitPercent and CountryLimitPercent give
// the clients they hold (SWW_TAG_LISTS). TagDeny are the tags whose // the clients of the AS numbers and the countries they list that
// requests are refused (SWW_TAG_DENY). TagLimitPercent gives the clients // percentage of every rate limit and byte limit
// with a tag that percentage of every rate limit and byte limit, from 1 // (SWW_ASN_LIMIT_PERCENT and SWW_COUNTRY_LIMIT_PERCENT).
// to 99 (SWW_TAG_LIMIT_PERCENT). All hold tags as requests carry them: in // ASNBytesPercent and CountryBytesPercent give those they list a
// lower case, a country code in capitals. // percentage of the byte limits in place of that one
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 like asn= tags in TagLimitPercent (SWW_ASN_LIMIT_PERCENT_URL), // count as those of ASNLimitPercent do (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
@@ -165,8 +164,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, // breaks a rate limit or matches a ban rule, SWW_DENY_NETS and the
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY. // country lists.
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
@@ -368,7 +367,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_TAG_DENY too") errOnBothLists = errors.New("is in SWW_DENIED_COUNTRIES 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")
@@ -398,21 +397,18 @@ 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")
)
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(
@@ -425,21 +421,16 @@ 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( errNotNumberOrOff = errors.New("is not a whole number above zero, such as 60, or off")
"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( errNotPercent = errors.New("is not a percentage, a whole number from 0 to 100")
"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, " +
@@ -448,17 +439,11 @@ 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( errNotZone = errors.New("is not a DNS zone such as dnsbl.dronebl.org")
"is not a DNS zone such as dnsbl.dronebl.org", errZoneTooLong = errors.New("is longer than 189 characters, too long for the " +
) "names IPv6 clients are asked about by")
errZoneTooLong = errors.New( errNotResolver = errors.New("is not an IP address with an optional port, " +
"is longer than 189 characters, too long for the " + "such as 192.0.2.53 or [2001:db8::53]:5353")
"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")
@@ -466,25 +451,6 @@ 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
@@ -494,9 +460,7 @@ 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( func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
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"),
@@ -519,11 +483,11 @@ func FromEnvironment(
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", ""),
TagLists: env.tagLists("SWW_TAG_LISTS"), ASNLimitPercent: env.percents("SWW_ASN_LIMIT_PERCENT", ParseASN),
TagDeny: env.tags("SWW_TAG_DENY"), CountryLimitPercent: env.percents("SWW_COUNTRY_LIMIT_PERCENT", parseCountry),
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"),
@@ -603,8 +567,6 @@ func FromEnvironment(
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
@@ -696,10 +658,7 @@ func (e *environment) lookup(name string) (string, bool) {
} }
if set { if set {
e.check( e.check(name, fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice))
name,
fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice),
)
return value, set return value, set
} }
@@ -882,31 +841,6 @@ 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))
@@ -982,10 +916,7 @@ func (e *environment) zones(name string) []string {
logged[i] = MaskZoneKey(zone) logged[i] = MaskZoneKey(zone)
} }
e.settings = append( e.settings = append(e.settings, slog.String(name, strings.Join(logged, ",")))
e.settings,
slog.String(name, strings.Join(logged, ",")),
)
return zones return zones
} }
@@ -1044,17 +975,15 @@ func (e *environment) checkLookupDBPath(cfg *Config) {
} }
} }
// checkCountriesAndLookups refuses a country in // checkCountriesAndLookups refuses a country on both country lists, and,
// 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 list, SWW_ADD_LOOKUP_HEADERS, the tag settings that name // up: the country lists, SWW_ADD_LOOKUP_HEADERS, the biased thresholds,
// 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.TagDeny, CountryTagPrefix+country) { if slices.Contains(cfg.DeniedCountries, 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))
} }
@@ -1068,10 +997,11 @@ 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_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, isLookupTag)}, {"SWW_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0},
{"SWW_TAG_LIMIT_PERCENT", tagLimitNeedsLookups(cfg)}, {"SWW_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0},
{"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},
@@ -1117,90 +1047,6 @@ 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 {
@@ -1212,9 +1058,7 @@ 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( func (e *environment) durationNotOff(name, defaultValue string) time.Duration {
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)
@@ -1413,10 +1257,7 @@ 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( func (e *environment) appName(name, instanceName string, sending bool) string {
name, instanceName string,
sending bool,
) string {
value, set := e.lookup(name) value, set := e.lookup(name)
if !set { if !set {
value = instanceName value = instanceName
@@ -1439,10 +1280,7 @@ func (e *environment) appName(
// 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( func (e *environment) checkInstanceNameForNtfy(instanceName string, ntfySet bool) {
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",
@@ -1766,10 +1604,7 @@ 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 = append(named, anomaly.NamedNetblock{Name: name, Netblock: netblock})
named,
anomaly.NamedNetblock{Name: name, Netblock: netblock},
)
} }
return named, nil return named, nil
@@ -1917,161 +1752,6 @@ 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
@@ -2163,11 +1843,8 @@ 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( return nil, fmt.Errorf("%q %w; the request's host is the field host",
"%q %w; the request's host is the field host", item, errHeaderTakenOut)
item,
errHeaderTakenOut,
)
case "transfer-encoding": case "transfer-encoding":
return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut) return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut)
} }
@@ -2204,11 +1881,9 @@ 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.Hostname() != "" && upstream.User == nil && upstream.Opaque == "" &&
upstream.Opaque == "" &&
(upstream.Path == "" || upstream.Path == "/") && (upstream.Path == "" || upstream.Path == "/") &&
upstream.RawQuery == "" && upstream.RawQuery == "" && upstream.Fragment == ""
upstream.Fragment == ""
if !onlySchemeAndHost { if !onlySchemeAndHost {
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL) return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
} }
@@ -2232,11 +1907,7 @@ 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{ schemes := []string{remotelog.SchemeUDP, remotelog.SchemeTCP, remotelog.SchemeTLS}
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) &&
@@ -2299,9 +1970,7 @@ 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.User == nil && u.Opaque == "" && u.Fragment == ""
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
@@ -2368,12 +2037,9 @@ 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( listedBefore := slices.ContainsFunc(zones[:i], func(earlier string) bool {
zones[:i], return strings.EqualFold(MaskZoneKey(earlier), shown)
func(earlier string) bool { })
return strings.EqualFold(MaskZoneKey(earlier), shown)
},
)
switch { switch {
case len(zone) > maxZoneLength: case len(zone) > maxZoneLength:
@@ -2460,13 +2126,8 @@ 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) || if !found || !IsHeaderName(name) || strings.ContainsAny(headerValue, "\r\n\x00") {
strings.ContainsAny(headerValue, "\r\n\x00") { return nil, "", fmt.Errorf("item %d %w", i+1, errNotWebhookHeader)
return nil, "", fmt.Errorf(
"item %d %w",
i+1,
errNotWebhookHeader,
)
} }
headers.Add(name, strings.TrimSpace(headerValue)) headers.Add(name, strings.TrimSpace(headerValue))
+53 -251
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"
tagLists = "SWW_TAG_LISTS" asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
tagDeny = "SWW_TAG_DENY" countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
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"
@@ -208,9 +208,6 @@ 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 (
@@ -277,13 +274,9 @@ 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,
@@ -334,6 +327,7 @@ 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",
@@ -348,7 +342,7 @@ func TestValuesAsSet(t *testing.T) {
metricsToken: token, metricsToken: token,
metricsTopN: "10", metricsTopN: "10",
rulesDir: "/srv/waf-rules", rulesDir: "/srv/waf-rules",
rulesEnabled: disabled, rulesEnabled: "false",
}) })
wantSettings(t, cfg, config.Config{ wantSettings(t, cfg, config.Config{
@@ -376,6 +370,7 @@ 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")
} }
@@ -1102,15 +1097,15 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
} }
} }
func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) { func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
tagDeny: "country=cn,country=ru", deniedCountries: "cn,ru",
allowedCountries: "de,RU", allowedCountries: "de,RU",
}.lookupEnv) }.lookupEnv)
if err == nil { if err == nil {
t.Fatal("ru in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY was accepted") t.Fatal("ru on both country lists was accepted")
} }
if !strings.HasPrefix(err.Error(), allowedCountries+": ") || if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
@@ -1181,11 +1176,12 @@ 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",
@@ -1208,13 +1204,12 @@ 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, a tag the // SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
// lookup does not give, an anomaly threshold per AS number that is off, // threshold per AS number that is off, any other anomaly threshold, or
// any other anomaly threshold, or a blocklist. // a blocklist.
env := environment{ env := environment{
lookupSource: off, allowedCountries: "", asnBytesPercent: "", lookupSource: off, deniedCountries: "", allowedCountries: "",
tagLists: "torexit=https://example.com/tor.txt,vpn=https://example.com/vpn.txt", asnLimitPercent: "", countryLimitPercent: "", asnBytesPercent: "",
tagDeny: torExitTag, tagLimitPercent: "vpn:50",
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "", countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
blocklistURLs: dropURL, blocklistURLs: dropURL,
} }
@@ -1360,194 +1355,34 @@ 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.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 || if len(cfg.ASNLimitPercent) != 0 || len(cfg.CountryLimitPercent) != 0 ||
len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
cfg.UnknownLimitPercent != 100 { cfg.UnknownLimitPercent != 100 {
t.Errorf("biased thresholds %v, %v and %d by default, "+ t.Errorf("biased thresholds %v, %v, %v, %v and %d by default, "+
"want two empty lists and 100", "want four empty lists and 100", cfg.ASNLimitPercent, cfg.CountryLimitPercent,
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}},
} { } {
@@ -1571,16 +1406,16 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
) )
for _, tc := range []struct{ name, value, want string }{ for _, tc := range []struct{ name, value, want string }{
{asnBytesPercent, "14061:50", `"14061"` + notASN}, {asnLimitPercent, "14061:50", `"14061"` + notASN},
{asnBytesPercent, "AS4294967296:50", `"AS4294967296"` + notASN}, {asnLimitPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
{asnBytesPercent, "AS14061", `"AS14061"` + notItem}, {asnLimitPercent, "AS14061", `"AS14061"` + notItem},
{asnBytesPercent, "AS14061:101", `"101"` + notPercent}, {asnLimitPercent, "AS14061:101", `"101"` + notPercent},
{asnBytesPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`}, {asnLimitPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
{ {
countryBytesPercent, "nk:25", countryLimitPercent, "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`,
}, },
{countryBytesPercent, "cn:25,CN:50", `"CN" is listed twice`}, {countryLimitPercent, "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},
@@ -2168,37 +2003,23 @@ 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"},
{allowedCountries, "nk"}, {deniedCountries, "nk"},
{allowedCountries, "kp,,ir"}, {deniedCountries, "kp,,ir"},
{allowedCountries, "prk"}, {deniedCountries, "prk"},
{allowedCountries, "408"}, {deniedCountries, "408"},
{allowedCountries, "k"}, {deniedCountries, "k"},
{allowedCountries, "eu"}, {deniedCountries, "eu"},
{allowedCountries, "un"}, {deniedCountries, "un"},
{allowedCountries, "su"}, {deniedCountries, "su"},
{allowedCountries, "ac"}, {allowedCountries, "ac"},
{allowedCountries, "uk"}, {allowedCountries, "uk"},
{allowedCountries, "zz"}, {allowedCountries, "zz"},
{allowedCountries, "de,germany"}, {allowedCountries, "de,germany"},
{asnBytesPercent, "AS14061:50,,AS16276:50"}, {asnBytesPercent, "ASX:50"}, {asnLimitPercent, "AS14061:50,,AS16276:50"}, {asnLimitPercent, "ASX:50"},
{asnBytesPercent, "AS14061:"}, {asnBytesPercent, "AS14061 :50"}, {asnLimitPercent, "AS14061:"}, {asnLimitPercent, "AS14061 :50"},
{asnBytesPercent, "AS14061:1.5"}, {asnBytesPercent, "AS-1:50"}, {asnLimitPercent, "AS14061:1.5"}, {asnLimitPercent, "AS-1:50"},
{countryBytesPercent, "cn"}, {countryBytesPercent, "cn:"}, {countryLimitPercent, "cn"}, {countryLimitPercent, "cn:"},
{countryBytesPercent, "cn:25:50"}, {countryBytesPercent, "china:25"}, {countryLimitPercent, "cn:25:50"}, {countryLimitPercent, "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%"},
@@ -2210,25 +2031,6 @@ 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) {
@@ -2483,11 +2285,11 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
lookupSource: defaultLookupSource, lookupSource: defaultLookupSource,
lookupDBPath: "", lookupDBPath: "",
lookupTimeout: "800ms", lookupTimeout: "800ms",
addLookupHeaders: disabled, addLookupHeaders: "false",
deniedCountries: "",
allowedCountries: "", allowedCountries: "",
tagLists: "", asnLimitPercent: "",
tagDeny: "", countryLimitPercent: "",
tagLimitPercent: "",
asnBytesPercent: "", asnBytesPercent: "",
countryBytesPercent: "", countryBytesPercent: "",
unknownLimitPercent: "100", unknownLimitPercent: "100",
-58
View File
@@ -1,58 +0,0 @@
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,
},
}
}
+2 -14
View File
@@ -91,18 +91,6 @@ 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()
@@ -335,7 +323,7 @@ func newDiscriminatorInBubble(
) (*discriminator.Server, *output) { ) (*discriminator.Server, *output) {
t.Helper() t.Helper()
settings := map[string]string{tagDeny: "country=KP"} settings := map[string]string{deniedCountries: "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)
@@ -542,7 +530,7 @@ func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
return discriminator.New(params) return discriminator.New(params)
}, held, time.Now, map[string]string{ }, held, time.Now, map[string]string{
tagDeny: "country=KP", wafMode: block, deniedCountries: "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",
+79 -25
View File
@@ -1,10 +1,7 @@
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 (
"context" "net/http"
"net/netip" "net/netip"
"time" "time"
@@ -21,15 +18,11 @@ 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}
} }
// activeBan refuses a client a ban covers: a ban on a netblock the client is // banned reports whether a ban on a netblock the client is in covers the
// in, in force at now. It notes for the log line when that ban ends. A request // request at now, and notes for the log line when that ban ends. A
// that makes the ban permanent, or in observe mode would have, raises the alert // request that makes the ban permanent, or in observe mode would have,
// for it. Its figures are the ban's netblock and when it ends. // raises the alert for it.
type activeBan struct{} func (rq *request) banned(now time.Time) bool {
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
@@ -45,24 +38,85 @@ func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
rq.alertBan(ban) rq.alertBan(ban)
} }
if !banned { return banned
return nil }
// limitBroken counts the request for the rate limits at now, notes the
// client's counts for the log line, and reports whether the request takes
// the client over a rate limit, as its limit percentage lowers it, which
// breaks it.
func (rq *request) limitBroken(now time.Time) bool {
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
rq.limitPercent.percent)
rq.line.Counts = counts
if over {
rq.banForLimit(now, hit, rq.h.config.BanResponse)
} }
return &verdict{ return over
action: requestlog.ActionBanned, }
figures: map[string]any{
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires, // 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)
} }
} }
// limitFigures are the figures of a heuristic that broke hit, a limit: its // countRefusal counts the request for the error burst once it has been
// window, the count and the limit. // answered, if discriminator refused it after a rule file match, a trap path or a
func limitFigures(hit ratelimit.Hit) map[string]any { // Core Rule Set match, and in observe mode if enforce mode would have: more than
return map[string]any{ // SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute
figureWindow: hit.Window, figureCount: hit.Count, figureLimit: hit.Limit, // break a limit. A client in SWW_ALLOW_NETS, which the checks skip, is
// not counted, and nothing is while the threshold is off.
func (rq *request) countRefusal() {
cfg := rq.h.config
if cfg.ErrorBurstThreshold == 0 {
return
} }
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
+4 -20
View File
@@ -211,7 +211,7 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
banScopeV4Prefix: "24", banScopeV4Prefix: "24",
tagDeny: denyKP, deniedCountries: "kp",
}) })
// fromDE's ban covers otherClient, which is refused unasked about. // fromDE's ban covers otherClient, which is refused unasked about.
@@ -228,22 +228,6 @@ 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()
@@ -266,7 +250,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
denyNets: denied, denyNets: denied,
tagDeny: denyKP, deniedCountries: "kp",
} }
if tc.setting != "" { if tc.setting != "" {
@@ -276,7 +260,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.ActionDenied) s.get(fromKP, tc.status, requestlog.ActionCountryDenied)
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)
@@ -291,7 +275,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",
tagDeny: denyKP, deniedCountries: "kp",
}) })
start := clk.Now() start := clk.Now()
+26 -36
View File
@@ -17,31 +17,30 @@ type percentage struct {
} }
// biasedThresholdsSet reports whether a biased threshold can lower a // biasedThresholdsSet reports whether a biased threshold can lower a
// client's limits by its lookup: SWW_ASN_BYTES_PERCENT or // client's limits: one of its lists is not empty,
// SWW_COUNTRY_BYTES_PERCENT is not empty, SWW_UNKNOWN_LIMIT_PERCENT is // SWW_UNKNOWN_LIMIT_PERCENT is below 100, or SWW_ASN_LIMIT_PERCENT_URL
// below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is // is set. The client's lookup is then needed before its request goes on.
// then needed before its request goes on.
func biasedThresholdsSet(cfg *config.Config) bool { func biasedThresholdsSet(cfg *config.Config) bool {
return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 || return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 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 tags, its AS number and country // limits and for the byte limits, by its AS number and country as looked
// as looked up, each "" when unknown, and the blocklists, DNSBL zones and // up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
// AbuseIPDB that list it. Each is the lowest of those the settings give // that list it. Each is the lowest of those the settings give it, the
// it, the first of them in the order below when several are lowest: the // first of them in the order below when several are lowest: the
// percentage SWW_TAG_LIMIT_PERCENT gives its asn= tag, the one the file // percentage SWW_ASN_LIMIT_PERCENT gives its AS number, the one the file
// SWW_ASN_LIMIT_PERCENT_URL names gives its AS number, the one // SWW_ASN_LIMIT_PERCENT_URL names gives it, the one
// SWW_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its // SWW_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
// other tags, for a client without a country, unless its request skipped // country, unless its request skipped the wait for GeoJS,
// the wait for GeoJS, SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist // SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the
// lists, the percentage of SWW_BLOCKLIST_ACTION while it is limit, and // percentage of SWW_BLOCKLIST_ACTION while it is limit, and for a client
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is // a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the
// a hit, the percentage of SWW_REPUTATION_ACTION while it is limit. For // percentage of SWW_REPUTATION_ACTION while it is limit. For the byte
// the byte limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT // limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT take the
// take the place of the first three for an AS number or a country they // place of the first three for an AS number or a country they list.
// list.
func (rq *request) limitPercentages() (percentage, percentage) { func (rq *request) limitPercentages() (percentage, percentage) {
cfg := rq.h.config cfg := rq.h.config
asn, country := rq.line.ASN, rq.line.Country asn, country := rq.line.ASN, rq.line.Country
@@ -66,18 +65,10 @@ func (rq *request) limitPercentages() (percentage, percentage) {
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"} reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
} }
const tagSetting = "SWW_TAG_LIMIT_PERCENT" asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWW_ASN_LIMIT_PERCENT"),
fetched)
asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched) countryRequests := given(cfg.CountryLimitPercent, country,
countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting) "SWW_COUNTRY_LIMIT_PERCENT")
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 {
@@ -88,13 +79,12 @@ 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, otherTags, unknown, blocklisted, return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
reputationListed), lowest(asnBytes, countryBytes, unknown, blocklisted, 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, a country or a tag, or whole when it does not list code. // code, an AS number or a country, or whole when it does not list code.
func given(percents map[string]int64, code, setting string) percentage { func given(percents map[string]int64, code, setting string) percentage {
percent, listed := percents[code] percent, listed := percents[code]
if !listed { if !listed {
+31 -41
View File
@@ -22,7 +22,8 @@ import (
// The biased thresholds. // The biased thresholds.
const ( const (
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT" asnLimitPercent = "SWW_ASN_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"
@@ -34,11 +35,6 @@ 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
@@ -58,8 +54,8 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
setting, value, from string setting, value, from string
}{ }{
{tagLimitPercent, asnTagDEHalf, fromDE}, {asnLimitPercent, asnDEHalf, fromDE},
{tagLimitPercent, countryTagDEHalf, fromDE}, {countryLimitPercent, countryDEHalf, fromDE},
{unknownLimitPercent, "50", unplaced}, {unknownLimitPercent, "50", unplaced},
} { } {
t.Run(tc.setting, func(t *testing.T) { t.Run(tc.setting, func(t *testing.T) {
@@ -100,8 +96,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
setting, value, from string setting, value, from string
}{ }{
{tagLimitPercent, asnTagDEHalf, fromDE}, {asnLimitPercent, asnDEHalf, fromDE},
{tagLimitPercent, "Country=de:50", fromDE}, {countryLimitPercent, "DE:50", fromDE},
{unknownLimitPercent, "50", unplaced}, {unknownLimitPercent, "50", unplaced},
{asnBytesPercent, "as64496:50", fromDE}, {asnBytesPercent, "as64496:50", fromDE},
{countryBytesPercent, countryDEHalf, fromDE}, {countryBytesPercent, countryDEHalf, fromDE},
@@ -155,13 +151,13 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
}, },
{ {
"raising the byte limits back", "raising the byte limits back",
map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"}, map[string]string{asnLimitPercent: asnDEHalf, asnBytesPercent: asnDE + ":100"},
"50 from " + tagLimitPercent, none, "", "50 from " + asnLimitPercent, none, "",
}, },
{ {
"raising the byte limits back, by country", "raising the byte limits back, by country",
map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"}, map[string]string{countryLimitPercent: countryDEHalf, countryBytesPercent: "de:100"},
"50 from " + tagLimitPercent, none, "", "50 from " + countryLimitPercent, none, "",
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -188,19 +184,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{unknownLimitPercent: "0"}) s, _, _ := startWithLookups(t, map[string]string{asnLimitPercent: asnDE + ":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(unplaced, http.StatusForbidden, requestlog.ActionRateLimited) line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
if line.fields["limit_percent"] != float64(0) || if line.fields["limit_percent"] != float64(0) ||
line.fields["limit_percent_setting"] != unknownLimitPercent { line.fields["limit_percent_setting"] != asnLimitPercent {
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"],
unknownLimitPercent) asnLimitPercent)
} }
s.get(unplaced, http.StatusForbidden, requestlog.ActionBanned) s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
} }
func TestLowestPercentageApplies(t *testing.T) { func TestLowestPercentageApplies(t *testing.T) {
@@ -215,29 +211,29 @@ func TestLowestPercentageApplies(t *testing.T) {
}{ }{
{ {
"the country's", "the country's",
map[string]string{tagLimitPercent: asnTagDEHalf + "," + countryTagDEQuarter}, map[string]string{asnLimitPercent: asnDEHalf, countryLimitPercent: "de:25"},
fromDE, "25 from " + tagLimitPercent, fromDE, "25 from " + countryLimitPercent,
}, },
{ {
"the AS number's", "the AS number's",
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEHalf}, map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: countryDEHalf},
fromDE, "25 from " + tagLimitPercent, fromDE, "25 from " + asnLimitPercent,
}, },
{ {
"the AS number's, the first of two alike", "the AS number's, the first of two alike",
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEQuarter}, map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: "de:25"},
fromDE, "25 from " + tagLimitPercent, fromDE, "25 from " + asnLimitPercent,
}, },
{ {
"that for a client without a country", "that for a client without a country",
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"}, map[string]string{asnLimitPercent: "AS64500: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{tagLimitPercent: "asn=64500:25"}, map[string]string{asnLimitPercent: "AS64500:25"},
noCountry, "25 from " + tagLimitPercent, noCountry, "25 from " + asnLimitPercent,
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -308,12 +304,8 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
setting, value string setting, value string
waits bool waits bool
}{ }{
{tagLimitPercent, asnTagDEHalf, true}, {asnLimitPercent, asnDEHalf, true},
{tagLimitPercent, countryTagDEHalf, true}, {countryLimitPercent, countryDEHalf, 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},
@@ -328,9 +320,7 @@ 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{ map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value})
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"))
@@ -373,15 +363,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", tagLimitPercent: asnTagDEQuarter}, map[string]string{rateLimitPerMinute: "12", asnLimitPercent: asnDEQuarter},
3, http.StatusForbidden, requestlog.ActionRateLimited, 3, http.StatusForbidden, requestlog.ActionRateLimited,
"requests per minute over the limit of 3", "25 from " + tagLimitPercent, "requests per minute over the limit of 3", "25 from " + asnLimitPercent,
}, },
{ {
// 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, tagLimitPercent: asnTagDEQuarter, bytesLimitPerMinute: twoUploads, asnLimitPercent: asnDEQuarter,
asnBytesPercent: asnDEHalf, asnBytesPercent: asnDEHalf,
}, },
0, http.StatusOK, requestlog.ActionForward, 0, http.StatusOK, requestlog.ActionForward,
@@ -497,7 +487,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
} }
// percentText gives a limit percentage and the setting that gave it as // percentText gives a limit percentage and the setting that gave it as
// text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are // text, such as "50 from SWW_ASN_LIMIT_PERCENT", or none when both are
// left out. // left out.
func percentText(percent *int64, setting string) string { func percentText(percent *int64, setting string) string {
switch { switch {
-41
View File
@@ -1,41 +0,0 @@
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
@@ -1,43 +0,0 @@
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,21 +98,6 @@ 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()
+10 -22
View File
@@ -1,10 +1,6 @@
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"
@@ -13,22 +9,18 @@ import (
"sneak.berlin/go/smallwebwaf/internal/waf" "sneak.berlin/go/smallwebwaf/internal/waf"
) )
// coreRuleSet inspects the request with the Core Rule Set, unless // checkCoreRuleSet 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 adds the tag exploitsignature, // SWW_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
// raises the waf_block alert, and in block mode refuses the request with 403, // and in block mode refuses the request, which is an offence its client's
// which is an offence its client's history counts. Its figures are the score // history counts, and so returns ActionWAFBlocked. It returns "" for a
// and the threshold. // request it does not refuse.
type coreRuleSet struct{} func (rq *request) checkCoreRuleSet() string {
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 nil return ""
} }
start := time.Now() start := time.Now()
@@ -48,22 +40,18 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
threshold := cfg.WAFAnomalyThreshold threshold := cfg.WAFAnomalyThreshold
if threshold == 0 || result.Score < threshold { if threshold == 0 || result.Score < threshold {
return nil return ""
} }
rq.addTag(config.ExploitSignatureTag)
rq.alertWAFBlock(result) rq.alertWAFBlock(result)
if cfg.WAFMode == config.WAFModeDetect { if cfg.WAFMode == config.WAFModeDetect {
return nil return ""
} }
rq.wafBlocked = true rq.wafBlocked = true
return &verdict{ return requestlog.ActionWAFBlocked
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
@@ -61,41 +61,6 @@ 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()
@@ -145,16 +110,6 @@ 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()
+9 -23
View File
@@ -1,35 +1,21 @@
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"
) )
// countries refuses a request when SWW_EXCLUSIVELY_ALLOWED_COUNTRIES is set and // countryDenied reports whether the country lists refuse the request, by
// does not hold the client's country as it was looked up. A client without a // the client's country as it was looked up. A client without a country,
// country, or whose country cannot be found, is refused. Its figures are the // or whose country cannot be found, is refused only by
// country and the setting that refused it. // SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
type countries struct{} func (rq *request) countryDenied() bool {
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 len(allowed) == 0 || slices.Contains(allowed, country) { if slices.Contains(denied, country) {
return nil return true
} }
return &verdict{ return len(allowed) > 0 && !slices.Contains(allowed, country)
action: requestlog.ActionCountryDenied,
figures: map[string]any{
"country": country, "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
},
}
} }
+6 -23
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},
}, },
{ {
"with a country in SWW_TAG_DENY", "both", map[string]string{deniedCountries: "kp", allowedCountries: "de,fr"},
map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"},
[]string{fromKP, unplaced}, []string{fromKP, unplaced},
}, },
} { } {
@@ -90,23 +90,6 @@ 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()
@@ -117,9 +100,9 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
}) })
geojsURL, _ := startGeoJS(t) geojsURL, _ := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{ addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
lookupTimeout: "1h", lookupTimeout: "1h",
allowedCountries: "de", deniedCountries: "kp",
}) })
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body")) req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
@@ -202,7 +185,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 tag setting needs it", map[string]string{tagDeny: denyKP}}, {"a country list is set", map[string]string{deniedCountries: "kp"}},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
-51
View File
@@ -1,51 +0,0 @@
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,22 +95,6 @@ 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
@@ -1,29 +0,0 @@
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
}
+54 -20
View File
@@ -2,7 +2,6 @@ package discriminator
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -53,6 +52,13 @@ 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.
@@ -142,20 +148,48 @@ 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: the heuristic crowdsec, header_limits, blocklists or rate_limits // its client: it is over the header limits, a blocklist lists its client while
// will refuse it, as a function in that heuristic's file says. In observe mode, // SWW_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is
// which refuses nothing, none is. rproxy lets a request through when // over a whole rate limit. In observe mode, which refuses nothing, none is.
// discriminator answers too late. // rproxy lets a request through when 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
} }
_, decided := rq.crowdSecDecisionInForce(now) lists := rq.h.lists
_, decided := lists.CrowdSecDecision(rq.client, now)
return decided || overHeaderLimits(rq.headerCounts()) || return decided || rq.overHeaderLimits() ||
rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) || (rq.h.config.BlocklistAction == deny && len(lists.ListedBy(rq.client)) > 0) ||
rq.overAWholeRateLimit(now) (rq.countedByRateLimits() &&
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now))
}
// overHeaderLimits reports whether discriminator refuses the request for its
// headers: it has more than maxHeaderFields fields, each value of a header sent
// twice being one, or more than maxCookies cookies, each part of a Cookie header
// between semicolons being one unless it is empty or only spaces and tabs. The
// Core Rule Set runs each of its rules on each cookie, so that thousands of small
// cookies keep it busy for over a second. It trims only spaces and control
// characters, of which a header value can hold only tabs, so a part of Unicode
// spaces is a cookie to it.
func (rq *request) overHeaderLimits() bool {
fields, cookies := 0, 0
for _, values := range rq.in.Header {
fields += len(values)
}
for _, value := range rq.in.Header.Values("Cookie") {
for part := range strings.SplitSeq(value, ";") {
if strings.Trim(part, " \t") != "" {
cookies++
}
}
}
return fields > maxHeaderFields || cookies > maxCookies
} }
// answerQuestion runs the checks on the visitor's request a question // answerQuestion runs the checks on the visitor's request a question
@@ -186,18 +220,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.runAfterAnswer(r.Context()) defer rq.countRefusal()
rq.allow() rq.allow()
return return
} }
// The last deferred runs first: runAfterAnswer before addToHistory, so that // The last deferred runs first: countRefusal before addToHistory, so that a
// a broken refusal limit is in the client's history. // broken error burst is in the client's history.
defer rq.addToHistory() defer rq.addToHistory()
defer rq.countAnomalies() defer rq.countAnomalies()
defer rq.runAfterAnswer(r.Context()) defer rq.countRefusal()
if refused != nil { if refused != nil {
rq.deny(*refused) rq.deny(*refused)
@@ -379,7 +413,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(r.Context(), held, report) h.finishWith(held, report)
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -442,11 +476,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 runs the heuristics of onReport, and adds the // For an allowed request it first counts the request's bytes, and adds it to its
// request to its client's history, the anomaly counters and the metrics, with // client's history, the anomaly counters and the metrics, with the status and
// the status and bytes the report gives. A denied request's counting was // bytes the report gives. A denied request's counting was finished at its
// finished at its decision. ctx is the report's own context. // decision.
func (h *handler) finishWith(ctx context.Context, held *decision, report Report) { func (h *handler) finishWith(held *decision, report Report) {
rq := &request{ rq := &request{
decision: *held, decision: *held,
h: h, h: h,
@@ -463,7 +497,7 @@ func (h *handler) finishWith(ctx context.Context, held *decision, report Report)
if rq.allowed { if rq.allowed {
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal
rq.runBanningHeuristics(ctx, onReport()) rq.countBytes()
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", tagDeny: denyKP, bytesLimitPerMinute: byteLimit, lookupTimeout: "1h", deniedCountries: "kp", 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", tagDeny: denyKP, addLookupHeaders: tc.setting, lookupTimeout: "1h", deniedCountries: "kp",
}) })
got := s.ask(fromDE, "/") got := s.ask(fromDE, "/")
-43
View File
@@ -1,43 +0,0 @@
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},
}
}
@@ -106,25 +106,6 @@ 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()
@@ -191,7 +172,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
lookupTimeout: "1h", lookupTimeout: "1h",
errorBurstThreshold: "2", errorBurstThreshold: "2",
rulesDir: writeRules(t, testRules), rulesDir: writeRules(t, testRules),
tagLimitPercent: countryTagDEHalf, countryLimitPercent: countryDEHalf,
}) })
// 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.
-71
View File
@@ -1,71 +0,0 @@
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
@@ -1,139 +0,0 @@
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",
tagDeny: denyKP, deniedCountries: "kp",
}) })
start := clk.Now() start := clk.Now()
+1 -32
View File
@@ -4,10 +4,8 @@ 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"
) )
@@ -21,8 +19,7 @@ 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 and as the // database or through GeoJS, and notes them for the log line, unless
// 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
@@ -50,34 +47,6 @@ 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
+3 -3
View File
@@ -94,9 +94,9 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)} clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)}
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
map[string]string{ map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
lookupTimeout: "1h", lookupTimeout: "1h",
tagDeny: denyKP, deniedCountries: "kp",
}) })
requestFromDE := func() *http.Request { requestFromDE := func() *http.Request {
+9 -9
View File
@@ -195,10 +195,10 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
_, _ = io.WriteString(w, "hello") _, _ = io.WriteString(w, "hello")
}) })
env := map[string]string{ env := map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
metricsToken: token, metricsToken: token,
metricsTopN: "2", metricsTopN: "2",
allowedCountries: "de,fr", deniedCountries: "kp",
} }
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)
@@ -322,13 +322,13 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {}) app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, _, server := startProxyWithClock(t, app.URL, geojs.URL, time.Now, addr, _, server := startProxyWithClock(t, app.URL, geojs.URL, time.Now,
map[string]string{ map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
metricsToken: token, metricsToken: token,
tagDeny: denyKP, deniedCountries: "kp",
}) })
// GeoJS fails, so the client has no country= tag, and SWW_TAG_DENY does // GeoJS fails, so the client counts as coming from an unknown country,
// not refuse it. // which SWW_DENIED_COUNTRIES does not refuse.
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)
+2 -14
View File
@@ -14,18 +14,6 @@ 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()
@@ -50,7 +38,7 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
denyNets: denied, denyNets: denied,
tagDeny: denyKP, deniedCountries: "kp",
} }
if tc.setting != "" { if tc.setting != "" {
@@ -71,7 +59,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.ActionDenied}, {fromKP, requestlog.ActionCountryDenied},
{fromDE, requestlog.ActionRateLimited}, {fromDE, requestlog.ActionRateLimited},
} { } {
if !tc.observe { if !tc.observe {
+5 -7
View File
@@ -155,12 +155,11 @@ 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 list, the headers, the tag settings that name an asn= // The country lists, the headers and the biased thresholds act on
// or country= tag and the biased thresholds act on the answer before // the answer before the request goes on.
// the request goes on. Wait: len(params.Config.DeniedCountries) > 0 ||
Wait: len(params.Config.ExclusivelyAllowedCountries) > 0 || len(params.Config.ExclusivelyAllowedCountries) > 0 ||
params.Config.AddLookupHeaders || params.Config.TagsNeedLookup() || params.Config.AddLookupHeaders || biasedThresholdsSet(params.Config),
biasedThresholdsSet(params.Config),
Answered: h.addLookup, Answered: h.addLookup,
Now: params.Now, Now: params.Now,
ProcessLog: params.ProcessLog, ProcessLog: params.ProcessLog,
@@ -184,7 +183,6 @@ 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,
}) })
+1 -3
View File
@@ -69,9 +69,7 @@ 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"
tagDeny = "SWW_TAG_DENY" deniedCountries = "SWW_DENIED_COUNTRIES"
// 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
@@ -1,65 +0,0 @@
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)
}
+4 -15
View File
@@ -72,17 +72,6 @@ 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()
@@ -136,7 +125,7 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
rateLimitExemptPaths: "/assets/,/favicon.ico", rateLimitExemptPaths: "/assets/,/favicon.ico",
denyNets: denied, denyNets: denied,
tagDeny: denyKP, deniedCountries: "kp",
}) })
// The answers are kept before the requests, so that none waits for // The answers are kept before the requests, so that none waits for
@@ -162,12 +151,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 SWW_TAG_DENY still refuse a path under a // A ban, SWW_DENY_NETS and the country lists still refuse a path
// prefix. // under a prefix.
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied) s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
s.request(fromKP, "/assets/app.js", s.request(fromKP, "/assets/app.js",
http.StatusForbidden, requestlog.ActionDenied) http.StatusForbidden, requestlog.ActionCountryDenied)
} }
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) { func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
-54
View File
@@ -1,54 +0,0 @@
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)}
}
+77 -3
View File
@@ -1,17 +1,91 @@
package discriminator package discriminator
// What the reputation heuristics share: blocklists, crowdsec, dnsbl and
// abuseipdb.
import ( import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/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.
+11 -101
View File
@@ -118,61 +118,11 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
} }
} }
func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T) { func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(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, allowedCountries: "de", allowNets: fromDE, blocklistURLs: dropURL, deniedCountries: "kp", allowNets: fromDE,
}) })
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}}) loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
@@ -211,18 +161,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, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes}, {limitHalf, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
{limitQuarter, asnTagDEHalf, "25 from " + blocklistAction, minuteBytes}, {limitQuarter, asnDEHalf, "25 from " + blocklistAction, minuteBytes},
// The AS number's, the first of two alike. // The AS number's, the first of two alike.
{limitQuarter, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes}, {limitQuarter, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
{actionLog, "asn=64511:25", none, ""}, {actionLog, asnDE + ":100", 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, tagLimitPercent: tc.asnPercent, blocklistAction: tc.action, asnLimitPercent: tc.asnPercent,
}) })
loadLists(t, server, map[string][]string{dropURL: {fromDE}}) loadLists(t, server, map[string][]string{dropURL: {fromDE}})
@@ -246,7 +196,7 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
const ( const (
fromURL = "25 from " + asnLimitPercentURL fromURL = "25 from " + asnLimitPercentURL
fromSetting = "25 from " + tagLimitPercent fromSetting = "25 from " + asnLimitPercent
) )
for _, tc := range []struct { for _, tc := range []struct {
@@ -260,16 +210,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{tagLimitPercent: asnTagDEHalf}, asnDEQuarter, fromURL, fromURL, map[string]string{asnLimitPercent: asnDEHalf}, asnDEQuarter, fromURL, fromURL,
}, },
{ {
"the setting's, lower than the file's", "the setting's, lower than the file's",
map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEHalf, map[string]string{asnLimitPercent: asnDEQuarter}, asnDEHalf,
fromSetting, fromSetting, fromSetting, fromSetting,
}, },
{ {
"the setting's, the first of two alike", "the setting's, the first of two alike",
map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEQuarter, map[string]string{asnLimitPercent: asnDEQuarter}, 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},
@@ -482,46 +432,6 @@ 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()
+85 -32
View File
@@ -138,15 +138,6 @@ 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.
@@ -169,20 +160,16 @@ 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 heuristics of checkClient decide: a request they // the request through. The checks of checkClient decide: a request they refuse
// refuse is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core // is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core Rule Set,
// Rule Set, or 431 for the header limits. In observe mode nothing is refused, // or 431 for the header limits. In observe mode nothing is refused, and the log
// and the log line names what enforce mode would have done. ctx is the // line names what enforce mode would have done. ctx is the request's own context.
// request's own context.
func (rq *request) check(ctx context.Context) *refusal { func (rq *request) check(ctx context.Context) *refusal {
v := rq.checkClient(ctx) action := 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
@@ -196,21 +183,35 @@ func (rq *request) check(ctx context.Context) *refusal {
} }
} }
// checkClient runs the heuristics of beforeLookup on the request, then looks // checkClient runs the checks on the request's client, and returns the
// up the client's AS number and country, then runs those of afterLookup. It // action of the first that refuses the request, or "" when none does. A
// returns the first verdict, or nil when none gives one. A client in // client in SWW_ALLOW_NETS skips them, and is not looked up. For any
// SWW_ALLOW_NETS skips them all, and is not looked up. ctx is the request's // other client, SWW_DENY_NETS comes first, then a ban on its netblock,
// own context. // so that a client either refuses is not looked up, then the lookup of
func (rq *request) checkClient(ctx context.Context) *verdict { // its AS number and country, then the country lists, then the blocklists,
if isInside(rq.client, rq.h.config.AllowNets) { // then the CrowdSec decision list, which bans the client it lists, then
return nil // the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of
// them refuses is not counted for the rate limits. Then come the
// rate limits, unless the client is in SWW_RATE_LIMIT_EXEMPT_NETS or the
// request's path is exempt under SWW_RATE_LIMIT_EXEMPT_PATHS, so that
// every other request is counted, each of them by the client's limit
// percentages, then SWW_TRAP_PATHS, then the rule files, then the header
// limits, and last the Core Rule Set. A request exempt from the rate limits
// is exempt from the byte limits too. ctx is the request's own context.
func (rq *request) checkClient(ctx context.Context) string {
cfg := rq.h.config
if isInside(rq.client, cfg.AllowNets) {
return ""
} }
now := rq.h.now() now := rq.h.now()
v := rq.runHeuristics(ctx, beforeLookup(), now) if isInside(rq.client, cfg.DenyNets) {
if v != nil { return requestlog.ActionDenied
return v }
if rq.banned(now) {
return requestlog.ActionBanned
} }
rq.lookUp(ctx, now) rq.lookUp(ctx, now)
@@ -219,7 +220,59 @@ func (rq *request) checkClient(ctx context.Context) *verdict {
// 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.
return rq.runHeuristics(ctx, afterLookup(), rq.h.now()) now = rq.h.now()
if rq.countryDenied() {
return requestlog.ActionCountryDenied
}
if rq.blocklistDenied() {
return requestlog.ActionDenied
}
if rq.crowdSecBanned(now) {
return requestlog.ActionBanned
}
if rq.dnsblDenied(ctx) || rq.abuseIPDBDenied(ctx) {
return requestlog.ActionDenied
}
rq.counted = rq.countedByRateLimits()
if rq.counted {
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
}
if rq.counted && rq.limitBroken(now) {
return requestlog.ActionRateLimited
}
if rq.trapPath(now) {
return requestlog.ActionBanned
}
action := rq.checkRules(now)
if action != "" {
return action
}
if rq.overHeaderLimits() {
return requestlog.ActionTooLarge
}
return rq.checkCoreRuleSet()
}
// countedByRateLimits reports whether the rate limits count the request: its
// client is not in SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
// under SWW_RATE_LIMIT_EXEMPT_PATHS.
func (rq *request) countedByRateLimits() bool {
cfg := rq.h.config
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
} }
// pathExempt reports whether r is exempt under prefixes, // pathExempt reports whether r is exempt under prefixes,
-43
View File
@@ -1,7 +1,6 @@
package discriminator_test package discriminator_test
import ( import (
"encoding/json"
"io" "io"
"math" "math"
"net/http" "net/http"
@@ -273,48 +272,6 @@ 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
@@ -1,59 +0,0 @@
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,35 +93,6 @@ 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
@@ -0,0 +1,64 @@
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 ""
}
}
+11 -19
View File
@@ -32,12 +32,13 @@ 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,
tagDeny: denyKP, deniedCountries: "kp",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
}) })
// Neither SWW_DENY_NETS, SWW_TAG_DENY nor the limit of one request a // Neither SWW_DENY_NETS, the country lists nor the limit of one
// minute refuses the client, and its country is not looked up. // request a minute refuses the client, and its country is not looked
// 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},
@@ -82,9 +83,9 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
}) })
geojsURL, asked := startGeoJS(t) geojsURL, asked := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{ addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
denyNets: "203.0.113.0/24", denyNets: "203.0.113.0/24",
tagDeny: denyKP, deniedCountries: "kp",
}) })
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body")) req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
@@ -103,15 +104,6 @@ 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()
@@ -141,19 +133,19 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitExemptNets: listedAddr + "," + fromKP, rateLimitExemptNets: listedAddr + "," + fromKP,
tagDeny: denyKP, deniedCountries: "kp",
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. // request from unlistedAddr is within the limit of one a minute. The
// SWW_TAG_DENY still refuses an exempt client. // country lists still refuse 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.ActionDenied}, {fromKP, http.StatusForbidden, requestlog.ActionCountryDenied},
}) })
} }
-30
View File
@@ -1,30 +0,0 @@
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
@@ -1,121 +0,0 @@
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
@@ -1,24 +0,0 @@
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
@@ -1,100 +0,0 @@
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
@@ -1,39 +0,0 @@
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,16 +83,6 @@ 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()
+4 -14
View File
@@ -133,11 +133,8 @@ func (f *File) Watch(ctx context.Context) {
} }
if err != nil { if err != nil {
f.params.ProcessLog.Error( f.params.ProcessLog.Error("cannot watch the lookup database for replacements",
"cannot watch the lookup database for replacements", "error", err.Error())
"error",
err.Error(),
)
return return
} }
@@ -197,10 +194,7 @@ 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{ Detail: map[string]any{"file": f.params.Path, "error": err.Error()},
"file": f.params.Path,
"error": err.Error(),
},
}) })
f.params.ProcessLog.Error(kept, "error", err.Error()) f.params.ProcessLog.Error(kept, "error", err.Error())
@@ -218,11 +212,7 @@ 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( f.params.ProcessLog.Info("read the lookup database", "file", f.params.Path)
"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 list refused; nil // refused are, by country, the requests the country lists 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 SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.", "Requests the country lists 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 list as well. // country, the refusals by the country lists 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 {
+16 -39
View File
@@ -1,8 +1,7 @@
// 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, the address lists of SWW_TAG_LISTS, and // SWW_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
// the decision list of the CrowdSec engine SWW_CROWDSEC_LAPI_URL names. It // engine SWW_CROWDSEC_LAPI_URL names. It keeps the last good copy of
// 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
@@ -72,8 +71,6 @@ 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).
@@ -115,11 +112,11 @@ type list struct {
failures int failures int
} }
// entries are what the lines of a copy say: for a blocklist or a tag list, the // entries are what the lines of a copy say: for a blocklist, the netblocks
// netblocks it names, with the lengths among them, for the file of AS:percent // it names, with the lengths among them, for the file of AS:percent lines,
// lines, the percentage it gives each AS number, and for the CrowdSec decision // the percentage it gives each AS number, and for the CrowdSec decision
// list, the decision on each netblock that ends last, with the lengths among // list, the decision on each netblock that ends last, with the lengths
// them. // among them.
type entries struct { type entries struct {
netblocks map[netip.Prefix]bool netblocks map[netip.Prefix]bool
lengths []int lengths []int
@@ -155,18 +152,14 @@ 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 the // SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then
// tag lists', then the CrowdSec decision list's. // the CrowdSec decision list's.
func (l *Lists) URLs() []string { func (l *Lists) URLs() []string {
urls := slices.Clone(l.params.BlocklistURLs) urls := slices.Clone(l.params.BlocklistURLs)
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)
} }
@@ -191,22 +184,6 @@ 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) {
@@ -504,10 +481,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 or a tag list, of the file of AS:percent lines, or of the // of a blocklist, of the file of AS:percent lines, or of the CrowdSec
// CrowdSec decision list. In all but the decision list, anything after a ; or // decision list. In the first two, anything after a ; or a # on a line is
// a # on a line is left out, and so is a line left blank. Any other line that // left out, and so is a line left blank. Any other line that does not read
// does not read is an error naming it by its number. // is an error naming it by its number.
func (l *Lists) parse( func (l *Lists) parse(
listURL string, lines []string, fetched time.Time, listURL string, lines []string, fetched time.Time,
) (entries, error) { ) (entries, error) {
@@ -521,8 +498,8 @@ func (l *Lists) parse(
} }
} }
// parseNetblocks reads the lines of a blocklist or a tag list, each an address // parseNetblocks reads a blocklist's lines, each an address or a netblock
// or a netblock as the settings take them. // 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{}}
@@ -574,7 +551,7 @@ func parseNetblock(text string) (netip.Prefix, bool) {
} }
// parsePercents reads the lines of the file of AS:percent lines, each an // parsePercents reads the lines of the file of AS:percent lines, each an
// AS number, : and a percentage, such as AS64496:50. An // AS number, : and a percentage, as SWW_ASN_LIMIT_PERCENT takes them. An
// AS number listed more than once gets the lowest of its percentages. // AS number listed more than once gets the lowest of its percentages.
func parsePercents(lines []string) (entries, error) { func parsePercents(lines []string) (entries, error) {
found := entries{percents: map[string]int64{}} found := entries{percents: map[string]int64{}}
+6 -38
View File
@@ -19,36 +19,28 @@ 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, or that carries a tag in SWW_TAG_DENY. // SWW_REPUTATION_ACTION is deny.
ActionDenied = "denied" ActionDenied = "denied"
// ActionCountryDenied is a request refused for its client's country.
// ActionCountryDenied is a request refused 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/.
@@ -139,16 +131,6 @@ 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
@@ -180,8 +162,6 @@ 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,
@@ -265,11 +245,7 @@ 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( return float64(d.Microseconds()) / float64(time.Millisecond/time.Microsecond)
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:
@@ -277,25 +253,17 @@ 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( func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
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( return slog.String(slog.TimeKey, FormatTime(attr.Value.Time()))
slog.TimeKey,
FormatTime(attr.Value.Time()),
)
} }
return attr return attr
}, },
}) })
return slog.New(handler). return slog.New(handler).With("type", "process", "instance", instanceName)
With("type", "process", "instance", instanceName)
} }
+9 -9
View File
@@ -611,14 +611,14 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
writeLookupDatabase(t, path, "DE") writeLookupDatabase(t, path, "DE")
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
metricsToken: token, metricsToken: token,
instanceName: instance, instanceName: instance,
lookupSource: "file", lookupSource: "file",
lookupDBPath: path, lookupDBPath: path,
"SWW_TAG_DENY": "country=KP", "SWW_DENIED_COUNTRIES": "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_TAG_DENY": "", "SWW_DENIED_COUNTRIES": "",
"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",
+5
View File
@@ -20,8 +20,13 @@ 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,8 +16,13 @@ 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")" .
} }
-54
View File
@@ -1,54 +0,0 @@
#!/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 "$@"