diff --git a/Dockerfile b/Dockerfile index 5267b36..fa861de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -167,6 +167,10 @@ RUN groupadd --system --gid 65532 smallwebwaf \ # smallwebwaf user at each start. RUN mkdir /var/lib/smallwebwaf +# The default rule file, in SWWAF_RULES_DIR by default, where an app's +# Dockerfile can copy rule files of its own beside it. +COPY share/rules.d/00-default.rules /etc/smallwebwaf/rules.d/00-default.rules + # runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv # looks too. COPY --chmod=755 share/smallwebwaf.run /etc/service/smallwebwaf/run diff --git a/README.md b/README.md index 67ed797..71243d2 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,16 @@ JSON state files with your edits taken in while it runs and the paths the rate limits do not count, which come next in the build order, `observe` mode and the rest of the request log's fields, which come a little later, and the metrics endpoint and the header size and the idle time as settings, which come last in -it. `smallwebwaf` passes each request to the app and the app's answer back, -unchanged, within its timeouts and size limits, works out each client's address, -bans a client that sends too many requests, not counting those for the paths you -choose, refuses a client that comes from a country you refuse or from a network -you refuse, lets the networks you choose through, keeps its bans, each client's -counters and history, and GeoJS's answers in JSON files across restarts, takes -in your edits of those files while it runs, writes a JSON log line for every +it. So are the rule files, the first part of the stage after it, with the bans +for a clear sign of attack. `smallwebwaf` passes each request to the app and the +app's answer back, unchanged, within its timeouts and size limits, works out +each client's address, bans a client that sends too many requests, not counting +those for the paths you choose, refuses a client that comes from a country you +refuse or from a network you refuse, lets the networks you choose through, +checks each request against the rule files and bans a client whose request is a +clear sign of attack, keeps its bans, each client's counters and history, and +GeoJS's answers in JSON files across restarts, takes in your edits of those +files and of the rule files while it runs, writes a JSON log line for every request, serves Prometheus metrics to a scraper that holds the metrics token, and in `observe` mode passes on the requests it would refuse, logging what it would have done with them. It comes as the image the app's own image is built @@ -54,7 +57,8 @@ works. To work on the code, `make build` builds the binary alone, with Go installed, and `make run` builds and runs it, listening on port 8080 in front of an app at `SWWAF_UPSTREAM_URL`, by default `http://127.0.0.1:8081`, with its state files -in `bin/state` unless `SWWAF_STATE_DIR` is set. +in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of +`share/rules.d` unless `SWWAF_RULES_DIR` is set. ## What it does so far @@ -86,13 +90,13 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set. `SWWAF_BAN_RESPONSE`, `403` by default, before anything reaches the app, and bans the client. A request whose path starts with one of `SWWAF_RATE_LIMIT_EXEMPT_PATHS`, as that setting below describes, is neither - counted nor refused by the rate limits; the static lists, bans and the country - lists still apply to it. A client is one IPv4 address, or one IPv6 /64, since - one abuser usually holds a whole /64. Each window is counted in two fixed - buckets, the earlier one weighted by how much of it the window still covers. - At most 20,000 clients are kept, the least recently seen dropped first, with - their history, and a restart gives no client a fresh allowance (see "State - files" below). + counted nor refused by the rate limits; the static lists, bans, the country + lists and the rule files still apply to it. A client is one IPv4 address, or + one IPv6 /64, since one abuser usually holds a whole /64. Each window is + counted in two fixed buckets, the earlier one weighted by how much of it the + window still covers. At most 20,000 clients are kept, the least recently seen + dropped first, with their history, and a restart gives no client a fresh + allowance (see "State files" below). - Bans a client that breaks a rate limit, as "Bans" in [`SPEC.md`](SPEC.md) describes: the first ban lasts an hour, and a limit broken again within a day of a ban ending bans for three times as long as that ban, so 1, 3, 9, 27 and @@ -106,11 +110,26 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set. window and the requests counted in it, the request that broke it, the client's country when it was looked up, the netblock's requests since it was first seen, how many of them the ban has refused, and how many bans the netblock had - before. At most `SWWAF_MAX_BANS` bans are kept, past, active and permanent; - past that, the earliest ban of the netblock that has gone longest without a - request is dropped first. `bans.json` shows the bans and their notes, a - restart lifts none, and you add or lift a ban by editing it (see "State files" - below). + before, for a broken limit, for a clear sign of attack and without a cause. At + most `SWWAF_MAX_BANS` bans are kept, past, active and permanent; past that, + the earliest ban of the netblock that has gone longest without a request is + dropped first. `bans.json` shows the bans and their notes, a restart lifts + none, and you add or lift a ban by editing it (see "State files" below). +- Checks each request against the rules of the rule files (see "Rule files" + below) after the rate limits, and before its body is read. A `log` rule that + matches is noted in the log line; a `block` rule refuses the request with + `403`, and bans no one; a `ban` rule refuses it with `SWWAF_BAN_RESPONSE` and + bans the client's netblock for a clear sign of attack. Matching stops at the + first rule that refuses. A client in `SWWAF_ALLOW_NETS` is not checked. +- Bans a client for a clear sign of attack, as "Bans" in [`SPEC.md`](SPEC.md) + describes: the first such ban lasts `SWWAF_ATTACK_BAN_DURATION`, seven days by + default, and any request from the netblock while it lasts makes it permanent. + Once it has run out, the netblock is served like any other, but its next clear + sign of attack bans it permanently at once. Such a ban covers the same + netblock as a ban for a broken rate limit, does not set the client's counters + back to zero, and does not make the netblock's next ban for a broken limit + longer. Its notes give the id and the target of the rule that matched in place + of the limit. - Refuses a request from a country you refuse with `SWWAF_BAN_RESPONSE`, as soon as the client's country is known and before its body is read; such a request is not counted for the rate limits. While one of the country lists below is @@ -121,25 +140,26 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set. in `SWWAF_ALLOW_NETS`, and `SWWAF_DENIED_COUNTRIES` does not refuse it. - Checks the client's own address against the static lists, the three netblock settings below, before anything else, its country included. A client in - `SWWAF_ALLOW_NETS` skips bans, the country lists and the rate limits, and is - not looked up; the timeouts and size limits still apply. A client in - `SWWAF_DENY_NETS` is refused with `SWWAF_BAN_RESPONSE` before its body is - read, and the request is not counted for the rate limits; an address in - `SWWAF_ALLOW_NETS` too is let through. A client in + `SWWAF_ALLOW_NETS` skips bans, the country lists, the rate limits and the rule + files, and is not looked up; the timeouts and size limits still apply. A + client in `SWWAF_DENY_NETS` is refused with `SWWAF_BAN_RESPONSE` before its + body is read, and the request is not counted for the rate limits; an address + in `SWWAF_ALLOW_NETS` too is let through. A client in `SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor refused by the rate - limits; the country lists and bans still apply to it. + limits; the country lists, the rule files and bans still apply to it. - In `observe` mode, with `SWWAF_MODE=observe`, refuses none of the requests - that `SWWAF_DENY_NETS`, a ban, the country lists or a rate limit would refuse: - it passes them to the app, and their log lines name what `enforce` mode would - have done (see `would_action` in "Request log" below). The checks run, and - requests are counted, as in `enforce` mode, but a broken rate limit makes no - ban and does not set the client's counters back to zero, so each request over - the limit is logged as one that would be refused. The bans in `bans.json` are - kept, and refuse requests again when `smallwebwaf` next runs in `enforce` - mode, as long as they last. The timeouts and size limits still apply, since - they protect `smallwebwaf` and the app themselves, and a request for the - metrics without the token is still answered `401`. It is for trying a - configuration before enforcing it. + that `SWWAF_DENY_NETS`, a ban, the country lists, a rate limit or a rule would + refuse: it passes them to the app, and their log lines name what `enforce` + mode would have done (see `would_action` in "Request log" below). The checks + run, and requests are counted, as in `enforce` mode, with three differences: + neither a broken rate limit nor a `ban` rule makes a ban; a broken rate limit + does not set the client's counters back to zero, so each request over the + limit is logged as one that would be refused; and a request under a ban does + not make it permanent. The bans in `bans.json` are kept, and refuse requests + again when `smallwebwaf` next runs in `enforce` mode, as long as they last. + The timeouts and size limits still apply, since they protect `smallwebwaf` and + the app themselves, and a request for the metrics without the token is still + answered `401`. It is for trying a configuration before enforcing it. - Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any check and without asking the app, for the image's health check. - Answers `GET /_smallwebwaf/metrics` with its metrics (see "Metrics" below) for @@ -194,8 +214,8 @@ it, and the effective settings are logged at start. - `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body. - `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body. - `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip bans, the - country lists and the rate limits, such as your monitoring or your own - networks. + country lists, the rate limits and the rule files, such as your monitoring or + your own networks. - `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients the rate limits do not apply to, such as a machine that talks to the app all day. - `SWWAF_DENY_NETS` (default empty): netblocks whose clients are always refused. @@ -225,16 +245,20 @@ it, and the effective settings are logged at start. country cannot be found is refused too, so that new clients are not let in whenever GeoJS stops answering. - `SWWAF_BAN_RESPONSE` (default `403`): how a refused client is answered, one - that is banned, breaks a rate limit, is in `SWWAF_DENY_NETS` or comes from a - refused country: `403`, `429`, or `close` to close the connection without an - answer. Behind traefik, `close` does not leave the client unanswered: traefik - answers `502`, as it does whenever its backend drops a connection. + that is banned, breaks a rate limit, matches a `ban` rule, is in + `SWWAF_DENY_NETS` or comes from a refused country: `403`, `429`, or `close` to + close the connection without an answer. Behind traefik, `close` does not leave + the client unanswered: traefik answers `502`, as it does whenever its backend + drops a connection. A `block` rule always answers `403`. - `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken rate limit. - `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): a rate limit broken again - within this time after a ban ended bans for three times as long as that ban. -- `SWWAF_MAX_BAN_DURATION` (default `7d`): a ban that would be longer is - permanent instead. + within this time after a ban ended, other than one for a clear sign of attack, + bans for three times as long as that ban. +- `SWWAF_MAX_BAN_DURATION` (default `7d`): a ban for a broken rate limit that + would be longer is permanent instead. +- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of + attack. - `SWWAF_MAX_BANS` (default `5000`): the most bans kept, past, active and permanent. - `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): the length of the netblock around @@ -260,6 +284,10 @@ it, and the effective settings are logged at start. `********` in its place. - `SWWAF_METRICS_TOP_N` (default `50`): how many countries get series of their own in the metrics by country; the others are counted as `other`. +- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the + rule files. A directory that does not exist stops the start. +- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file, and checks + no request against one. Durations are in Go's syntax, with `d` for days (`90s`, `15m`, `7d`). Sizes are bytes, with an optional `K`, `M` or `G`, which are powers of 1024 (`1K` is 1024 @@ -322,18 +350,19 @@ A field that does not apply to a request is left out of its line, apart from - `request_bytes` and `response_bytes` count body bytes. - `action` is `forward` for a request passed to the app, `denied` for one refused because its client is in `SWWAF_DENY_NETS`, `banned` for one refused - because a ban covers its client, `country_denied` for one refused for its - client's country, `rate_limited` for one that broke a rate limit and banned - its client, `too_large` for a request or response over its size limit, - `timed_out` for one that ran out of time, `upstream_error` when the app could - not be reached or its answer broke off, and `admin` for one `smallwebwaf` - answered at its own endpoint. + because a ban covers its client or because it matched a `ban` rule, which bans + its client, `country_denied` for one refused for its client's country, + `rate_limited` for one that broke a rate limit and banned its client, + `rule_blocked` for one a `block` rule refused, `too_large` for a request or + response over its size limit, `timed_out` for one that ran out of time, + `upstream_error` when the app could not be reached or its answer broke off, + and `admin` for one `smallwebwaf` answered at its own endpoint. - `would_action` is there in `observe` mode for a request that - `SWWAF_DENY_NETS`, a ban, the country lists or a rate limit would have refused - in `enforce` mode, and names the action that refusal would have had: `denied`, - `banned`, `country_denied` or `rate_limited`. `action` then names what was - done: `forward` for a request passed to the app, and another action, such as - `too_large`, for one a size or time limit refused. + `SWWAF_DENY_NETS`, a ban, the country lists, a rate limit or a rule would have + refused in `enforce` mode, and names the action that refusal would have had: + `denied`, `banned`, `country_denied`, `rate_limited` or `rule_blocked`. + `action` then names what was done: `forward` for a request passed to the app, + and another action, such as `too_large`, for one a size or time limit refused. - `counts` gives the client's requests in the minute, the hour and the day as the rate limits count them, this request included: in each window, those in the bucket under way and a share of those in the bucket before, so a count can @@ -344,6 +373,8 @@ A field that does not apply to a request is left out of its line, apart from `SWWAF_RATE_LIMIT_EXEMPT_PATHS` exempts, and one that `SWWAF_DENY_NETS`, a ban or the country lists refuse, or would refuse in `observe` mode. The byte totals come with the byte limits. +- `rule_ids` is there for a request that matched rules of the rule files, and + lists their ids in the order they matched, up to the one that refused it. - `limit_hit` is there for a request that broke a rate limit, and names the window whose limit it went over: `minute`, `hour` or `day`, the shortest if it went over several. `offence` is then `limit`. @@ -381,7 +412,8 @@ all, which it answers itself, mostly with `400`. entries by client address, with times in UTC. - `bans.json`: every ban with its notes, indented to be read; a permanent ban's - `expires` is `null`. + `expires` is `null`, and a ban `smallwebwaf` made has the `cause` `limit` for + a broken rate limit or `attack` for a clear sign of attack. - `clients.json`: each client's two buckets in the minute, the hour and the day, and its history: when it was first and last seen, its country as last looked up and when, its requests, how many were forwarded and how many refused (one @@ -392,12 +424,13 @@ entries by client address, with times in UTC. - `lookups.json`: GeoJS's answers, one to a line, with when GeoJS gave each and when it was last used. -`bans.json` is written `SWWAF_STATE_WRITE_DELAY` after a ban is made, with every -ban made in between, and every file every `SWWAF_STATE_COUNTER_INTERVAL` and -when `smallwebwaf` stops. Each write goes to a temporary file in the same -directory, which then replaces the file, so a crash leaves the old file or the -new one, whole. A write that fails is logged, and tried again at the next write. -A hard kill loses what changed since the last write. +`bans.json` is written `SWWAF_STATE_WRITE_DELAY` after a ban is made or made +permanent, with every such change in between, and every file every +`SWWAF_STATE_COUNTER_INTERVAL` and when `smallwebwaf` stops. Each write goes to +a temporary file in the same directory, which then replaces the file, so a crash +leaves the old file or the new one, whole. A write that fails is logged, and +tried again at the next write. A hard kill loses what changed since the last +write. At start the files are read back: each client keeps its counts, so a restart gives it no fresh allowance, and each ban keeps refusing every client in its @@ -412,7 +445,8 @@ without a field it needs, named with the entry's place in the file: a ban's `netblock`, `start` or `expires`, which is `null` for a permanent ban; a client's `client`, or the `start` of a window in which it has requests; an answer's `client`, `country`, which is `""` for a client GeoJS cannot place, or -`answered`. The AS number and AS name come with their lookup. +`answered`. So does a ban whose `cause` is neither `limit` nor `attack`. The AS +number and AS name come with their lookup. While it runs, `smallwebwaf` watches `SWWAF_STATE_DIR` and takes in your edit of a state file as soon as you save it: what the file then holds replaces what @@ -421,17 +455,19 @@ yours by comparing the file with what it last read or wrote, and before it writes a file it takes in any edit made since, so your edit is not overwritten; a change `smallwebwaf` made after you opened the file, such as a new ban, is lost when you save over it. An edit that would stop the start, because it does -not parse, has another `version` or leaves out a field an entry needs, does not -stop the running `smallwebwaf`: it keeps what it holds, and at the file's next -write renames your file to `.bad`, such as `bans.json.bad`, writes the -file again from memory, and logs the file and where the error is. It waits for -that write because an editor's file can be read before the editor has finished -writing it. Mend the `.bad` file and move it back. A file you remove is written -again at its next write. +not parse, has another `version`, leaves out a field an entry needs or gives a +ban another `cause`, does not stop the running `smallwebwaf`: it keeps what it +holds, and at the file's next write renames your file to `.bad`, such as +`bans.json.bad`, writes the file again from memory, and logs the file and where +the error is. It waits for that write because an editor's file can be read +before the editor has finished writing it. Mend the `.bad` file and move it +back. A file you remove is written again at its next write. To ban a netblock, add an entry to `bans.json` with its `netblock`, its `start` -and its `expires`, `null` for a ban that never ends; its `notes` may be left -out. This `bans.json` bans `203.0.113.0/24` for good: +and its `expires`, `null` for a ban that never ends; its `cause` and its `notes` +may be left out. A ban whose `cause` is `attack` becomes permanent at the first +request it refuses; one without a cause does not. This `bans.json` bans +`203.0.113.0/24` for good: ```json { @@ -449,6 +485,77 @@ out. This `bans.json` bans `203.0.113.0/24` for good: To lift a ban, delete its entry. `smallwebwaf` then forgets the ban, so it does not make the netblock's next ban longer. +## Rule files + +`smallwebwaf` reads every `*.rules` file in `SWWAF_RULES_DIR`, +`/etc/smallwebwaf/rules.d` by default, in the order of their names, and checks +each request against their rules in that order, as "Rule files" in +[`SPEC.md`](SPEC.md) describes. A file whose name starts with `.`, such as an +editor's lock file `.#50-app.rules`, is not a rule file, as a shell's `*.rules` +would not match it. A rule is a line of four fields separated by spaces or tabs: +an id, a target, an action and a regex, which runs to the end of the line. Blank +lines and lines that start with `#` are ignored. + +``` +# id target action regex +env-file path ban (?i)^/\.env(\.[a-z]+)?$ +scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b +``` + +- The id is letters, digits, `-` and `_`, and no two rules share one. The + request log, the metrics and a ban's notes name the rule by it. +- The target is what the regex is matched against: `path` or `query`, as the + client sent it, before any decoding; `uri`, the path and the query together, + both as sent and once percent-decoded, so that an encoded probe does not slip + past; `method`; `host`; `user_agent`; `referer`; or `header:`, any one + request header but `Host` and `Transfer-Encoding`, which Go's HTTP server + takes out of every request; the request's host is the target `host`. A header + sent more than once is matched with its values joined by `, `, and one not + sent as empty text. No body is read. +- The action is `log`, `block` or `ban` (see "What it does so far" above). Keep + `ban` for requests no real visitor sends, and anchor a path at the site root + with `^/`: a file of the same name deeper in a site can be ordinary content, + such as a file in a repository on a code forge. +- The regex is in Go's syntax, RE2, which has no backreferences or lookaround, + and takes time linear in the text it reads. It matches anywhere in the target + unless anchored with `^` and `$`; `(?i)` at its front makes it ignore case. + +A line that is not a rule, a rule for the `Host` or the `Transfer-Encoding` +header, a regex that does not compile or an id used twice stops the start with a +message naming the file and the line, and so does a `SWWAF_RULES_DIR` that does +not exist. An empty directory is no error, and the log says that it holds no +rules. While it runs, `smallwebwaf` watches the directory, and reads the rule +files again once the directory has had no change for 2 seconds after one is +edited, added or removed, so that a file saved in place, appended to or copied +in with `scp` is read only once whole, unless its writing stops for longer. It +also reads them 2 seconds after it starts watching, so that an edit saved while +it started is not missed. If they then hold one of those errors, the rules stay +as they were, the earlier version of the edited file included, the log names the +file and the line, and the files are read again after the next change. + +The image ships one rule file, `share/rules.d/00-default.rules` here: rules that +ban probes no real visitor sends, for secrets, version control directories, +backups, logs and web shells at the site root, and the user agents of common +scanners; one that blocks `../` twice in a row in the path or the query; and one +that only notes a request without a user agent. An app's Dockerfile adds rules +of its own in a file beside it, named to sort after it, such as this +`50-gitea.rules` for an app that serves no WordPress: + +``` +wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/) +``` + +```dockerfile +COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules +``` + +A directory mounted over `/etc/smallwebwaf/rules.d` replaces the default file, +and single files mounted into it add to it. Docker does not show a single +mounted file being replaced, which is how many editors save, so rules to be +edited while `smallwebwaf` runs belong in a mounted directory, with a copy of +`00-default.rules` if its rules are to stay. To run without rules, mount an +empty directory or set `SWWAF_RULES_ENABLED=false`. + ## Metrics `GET /_smallwebwaf/metrics` answers with the metrics in the Prometheus text @@ -466,8 +573,11 @@ other request. No metric carries a client's address. - `smallwebwaf_rate_limit_hits_total` by `window`, `smallwebwaf_size_and_time_limit_hits_total` by `limit`, the setting whose limit was passed, `smallwebwaf_offences_total` by `kind`, and - `smallwebwaf_bans_made_total` by `cause`; `smallwebwaf_active_bans` and - `smallwebwaf_permanent_bans`. + `smallwebwaf_bans_made_total` by `cause`, `limit` or `attack`; + `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`. +- `smallwebwaf_rule_matches_total`: the requests that matched each rule, by + `rule_id` and `action`, the rule's own; and `smallwebwaf_rules_loaded`: the + rules read from the rule files. - `smallwebwaf_country_requests_total`, `smallwebwaf_country_request_bytes_total`, `smallwebwaf_country_response_bytes_total`, and @@ -494,7 +604,7 @@ other request. No metric carries a client's address. The requests Go's HTTP server ends before `smallwebwaf` sees them (see "Request log") are not counted. The metrics of the features still to come, such as the -rule files, come with them. +Core Rule Set, come with them. ## Why @@ -768,25 +878,29 @@ addresses are never sent to GeoJS. ## How the code is laid out - `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`. -- `internal/smallwebwaf`: the process: it reads the settings and the state - files, listens, serves requests until `SIGTERM` or `SIGINT`, and stops, - writing the state files. Run as `smallwebwaf healthcheck`, it is the image's - health check instead. +- `internal/smallwebwaf`: the process: it reads the settings, the rule files and + the state files, listens, serves requests until `SIGTERM` or `SIGINT`, and + stops, writing the state files. Run as `smallwebwaf healthcheck`, it is the + image's health check instead. - `internal/config`: reads the settings, the one place they are read. - `internal/proxy`: what happens to each request: it works out the client, runs the checks, passes the request to the app and the answer back with the standard library's `httputil.ReverseProxy` within the timeouts and size limits, and writes the request's log line. Its `check` method is where a request is refused before anything reaches the app: for `SWWAF_DENY_NETS`, for - a ban, for the country lists, for a rate limit, which bans the client, and for - an announced body over the size limit; in `observe` mode, only for the size - limit, with what it would have refused for noted in the log line. A request - under `/_smallwebwaf/` that `check` lets through is answered by `answerAdmin` + a ban, for the country lists, for a rate limit, which bans the client, for a + `block` or `ban` rule, the latter banning the client, and for an announced + body over the size limit; in `observe` mode, only for the size limit, with + what it would have refused for noted in the log line. A request under + `/_smallwebwaf/` that `check` lets through is answered by `answerAdmin` instead of reaching the app. - `internal/metrics`: the metrics, counted as the other parts tell it what happened, and served in the Prometheus text format. - `internal/bans`: the ban ledger: each netblock's bans with their notes, how - long a new ban lasts, and which ban is dropped when `SWWAF_MAX_BANS` are held. + long a new ban lasts, when a ban for a clear sign of attack becomes permanent, + and which ban is dropped when `SWWAF_MAX_BANS` are held. +- `internal/rules`: reads the rule files at start and again as they change, and + tells which of their rules a request matches. - `internal/lookup`: looks up each client's country through GeoJS, and keeps the answers. - `internal/ratelimit`: the table of clients: counts each client's requests, @@ -797,7 +911,8 @@ addresses are never sent to GeoJS. process's own messages. - `Dockerfile`: the lint and test phases, then the image, whose last stage installs Ubuntu's packages, nixpkgs, `runsvinit` and `smallwebwaf`, with - `share/smallwebwaf.run` as runit's `run` script for `smallwebwaf`. + `share/smallwebwaf.run` as runit's `run` script for `smallwebwaf` and + `share/rules.d/00-default.rules` as its default rule file. - `deploy/example-app`: an app built on the image, which `script/example-app` checks. @@ -805,8 +920,8 @@ Besides the Go standard library, `github.com/hashicorp/golang-lru/v2` keeps the table of clients to 20,000, the GeoJS answers to 100,000 and the banned netblocks to `SWWAF_MAX_BANS`, dropping the least recently seen, and `github.com/prometheus/client_golang` keeps the metrics and serves them, and -`github.com/fsnotify/fsnotify` tells `smallwebwaf` when a state file is saved. -The country codes are the list in `internal/config/config.go`. +`github.com/fsnotify/fsnotify` tells `smallwebwaf` when a state file or a rule +file is saved. The country codes are the list in `internal/config/config.go`. ## Entrypoints @@ -836,16 +951,17 @@ so that they run in minimal containers. - `script/build`: builds `bin/smallwebwaf` on the host, with Go installed, for working on the code by hand; `make build` runs it. - `script/run`: builds `bin/smallwebwaf` with `script/build` and runs it, with - its state files in `bin/state` unless `SWWAF_STATE_DIR` is set; `make run` - runs it. + its state files in `bin/state` unless `SWWAF_STATE_DIR` is set, and the rule + files of `share/rules.d` unless `SWWAF_RULES_DIR` is set; `make run` runs it. - `script/example-app`: builds the image and, on it, the example app in `deploy/example-app`, runs it with a volume for the state files, and checks that the health check passes, that a request reaches the app through - `smallwebwaf`, that a second request in a minute bans the client, that - `sv stop` and `docker stop` stop it in order, and that a new container on the - same volume still refuses the banned client; then removes the containers, the - volume and both images. It needs network access, for nixpkgs' binary cache, - and `script/check` does not run it; `make example-app` does. + `smallwebwaf`, that a second request in a minute bans the client, that a probe + for `/.env` bans another client, whose next request makes the ban permanent, + that `sv stop` and `docker stop` stop it in order, and that a new container on + the same volume still refuses the banned client; then removes the containers, + the volume and both images. It needs network access, for nixpkgs' binary + cache, and `script/check` does not run it; `make example-app` does. ## TODO diff --git a/internal/bans/bans.go b/internal/bans/bans.go index 19b7c19..5bccf15 100644 --- a/internal/bans/bans.go +++ b/internal/bans/bans.go @@ -1,7 +1,8 @@ // Package bans is the ban ledger: the bans smallwebwaf makes on the -// netblocks of clients that break a rate limit, with their notes, as the -// "Bans" section of SPEC.md describes. The bans are kept in memory, and -// written to bans.json and read from it by the state package. +// netblocks of clients that break a rate limit or show a clear sign of +// attack, with their notes, as the "Bans" section of SPEC.md describes. +// The bans are kept in memory, and written to bans.json and read from it +// by the state package. package bans import ( @@ -14,6 +15,15 @@ import ( "github.com/hashicorp/golang-lru/v2/simplelru" ) +// The causes of the bans smallwebwaf makes. A ban an admin adds to +// bans.json may have no cause. +const ( + // CauseLimit is a ban for a broken limit. + CauseLimit = "limit" + // CauseAttack is a ban for a clear sign of attack. + CauseAttack = "attack" +) + // repeatFactor is how many times as long as the netblock's last ban a ban // for a limit broken again within the repeat window lasts. const repeatFactor = 3 @@ -21,32 +31,37 @@ const repeatFactor = 3 // maxTextBytes is how much of each text in a ban's notes is kept. const maxTextBytes = 256 -// Rules are how long a ban for a broken limit lasts, and how many bans -// are held. +// Rules are how long a ban lasts, and how many bans are held. type Rules struct { - // LimitBanDuration is how long a first ban lasts. + // LimitBanDuration is how long a first ban for a broken limit lasts. LimitBanDuration time.Duration // LimitBanRepeatWindow is how soon after the end of the netblock's - // ban that ended last a broken limit counts as a repeat, which bans - // for repeatFactor times as long as that ban. + // ban that ended last, other than one for a clear sign of attack, a + // broken limit counts as a repeat, which bans for repeatFactor times as + // long as that ban. LimitBanRepeatWindow time.Duration - // MaxBanDuration is the longest ban; a ban that would be longer is - // permanent instead. + // MaxBanDuration is the longest ban for a broken limit; one that would + // be longer is permanent instead. MaxBanDuration time.Duration + // AttackBanDuration is how long a first ban for a clear sign of attack + // lasts. + AttackBanDuration time.Duration // MaxBans is the most bans held, at least one. Past it, the earliest // ban of the netblock that has gone longest without a request is // dropped. MaxBans int } -// Ban is a ban on a netblock for a broken limit, the only kind of ban -// smallwebwaf makes so far. +// Ban is a ban on a netblock. type Ban struct { Netblock netip.Prefix Start time.Time // Expires is when the ban ends, zero for a permanent ban. Expires time.Time - Notes Notes + // Cause is CauseLimit or CauseAttack, or "" for a ban an admin added + // without one. + Cause string + Notes Notes } // Permanent reports whether the ban never runs out. @@ -66,23 +81,39 @@ func (b Ban) ActiveAt(now time.Time) bool { type Notes struct { // Country is the client's country, when it was looked up. Country string `json:"country"` - // Limit, Window and Count are the limit that was broken, its window, - // "minute", "hour" or "day", and the count reached: the client's - // requests in the window, the one that broke the limit included. - // These are the requests that counted toward the ban, and the window - // is the time over which they came. - Limit int64 `json:"limit"` - Window string `json:"window"` - Count float64 `json:"count"` - // Request is the request that broke the limit. + // Limit, Window and Count are, for a ban for a broken limit, the limit + // that was broken, its window, "minute", "hour" or "day", and the + // count reached: the client's requests in the window, the one that + // broke the limit included. These are the requests that counted + // toward the ban, and the window is the time over which they came. + Limit int64 `json:"limit,omitempty"` + Window string `json:"window,omitempty"` + Count float64 `json:"count,omitempty"` + // RuleID and Target are, for a ban for a clear sign of attack, the id + // of the rule file rule that matched, and its target. + RuleID string `json:"rule_id,omitempty"` + Target string `json:"target,omitempty"` + // Request is the request that broke the limit, or that was the clear + // sign of attack. Request Request `json:"request"` // Requests is how many requests the netblock has sent since it was // first seen, and Refused how many of them the ban has refused so // far. Both go up with each request the ban refuses. Requests int64 `json:"requests"` Refused int64 `json:"refused"` - // EarlierBans is how many bans the netblock had before this one. - EarlierBans int `json:"earlier_bans"` + // EarlierBans is how many bans the netblock had before this one, by + // cause. + EarlierBans EarlierBans `json:"earlier_bans"` +} + +// EarlierBans counts a netblock's bans before a ban, by cause. +// +//nolint:tagliatelle // the state files use snake_case, as the request log does +type EarlierBans struct { + Limit int `json:"limit"` + Attack int `json:"attack"` + // WithoutCause counts the bans an admin added without a cause. + WithoutCause int `json:"without_cause"` } // Request is a request in a ban's notes. Each text is cut to 256 bytes. @@ -112,8 +143,8 @@ type Ledger struct { netblocks *simplelru.LRU[netip.Prefix, *[]Ban] // held is how many bans netblocks holds, at most rules.MaxBans. held int - // made is how many bans BanForLimit has made since the start. - made int + // made is how many bans the ledger has made since the start, by cause. + made map[string]int // v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6 // netblocks that have been banned. Check looks for a ban at each of // them, so that a ban read from bans.json refuses every client in its @@ -135,18 +166,21 @@ func New(rules Rules) *Ledger { rules: rules, changed: make(chan struct{}, 1), netblocks: netblocks, + made: map[string]int{}, } } -// Changed receives a value after a ban is made, so that bans.json can be -// written. Several bans made before it is read leave one value. +// Changed receives a value after a ban is made or made permanent, so that +// bans.json can be written. Several changes before it is read leave one +// value. func (l *Ledger) Changed() <-chan struct{} { return l.changed } // Check is called for a request from client, at now. It reports whether // a ban on a netblock client is in is active, and returns that ban, with -// the request counted among those it refused. +// the request counted among those it refused. A ban for a clear sign of +// attack is made permanent by the request: the netblock is malicious. func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) { l.mu.Lock() defer l.mu.Unlock() @@ -159,6 +193,12 @@ func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) { ban.Notes.Requests++ ban.Notes.Refused++ + if ban.Cause == CauseAttack && !ban.Permanent() { + ban.Expires = time.Time{} + + l.markChanged() + } + return *ban, true } @@ -192,54 +232,22 @@ func activeBan(bans []Ban, now time.Time) *Ban { // BanForLimit bans netblock at now for a broken limit, with notes, and // returns the ban. A first ban lasts LimitBanDuration. A ban made within -// LimitBanRepeatWindow after the netblock's ban that ended last lasts -// repeatFactor times as long as that one. A ban that would be longer -// than MaxBanDuration is permanent instead. If a ban on netblock is still -// active, as when two of its requests break a limit at once, that ban is -// returned and no other is made. The ledger fills in the notes' Refused -// and EarlierBans itself. +// LimitBanRepeatWindow after the netblock's ban that ended last, other +// than one for a clear sign of attack, lasts repeatFactor times as long as +// that one. A ban that would be longer than MaxBanDuration is permanent +// instead. If a ban on netblock is still active, as when two of its +// requests break a limit at once, that ban is returned and no other is +// made. The ledger fills in the notes' Refused and EarlierBans itself. func (l *Ledger) BanForLimit(netblock netip.Prefix, now time.Time, notes Notes) Ban { - l.mu.Lock() - defer l.mu.Unlock() + return l.ban(netblock, now, CauseLimit, notes) +} - var last *Ban - - bans, found := l.netblocks.Get(netblock) - if found { - active := activeBan(*bans, now) - if active != nil { - return *active - } - - // No ban is active, so each has an end. A ban an admin adds to - // bans.json can start after another and end before it, so the - // ban that ended last is looked for among them all. - ended := slices.MaxFunc(*bans, func(a, b Ban) int { - return a.Expires.Compare(b.Expires) - }) - last = &ended - - // The netblock's first ban held counts the bans it had before that - // one, since dropped to make room, and each ban held adds one. - notes.EarlierBans = (*bans)[0].Notes.EarlierBans + len(*bans) - } - - notes.Request = notes.Request.cut() - ban := Ban{ - Netblock: netblock, - Start: now, - Expires: l.expiry(last, now), - Notes: notes, - } - l.add(ban) - l.made++ - - select { - case l.changed <- struct{}{}: - default: // a value is waiting already - } - - return ban +// BanForAttack bans netblock at now for a clear sign of attack, with +// notes, and returns the ban, as BanForLimit does. A first ban lasts +// AttackBanDuration; once the netblock has had one, the next is +// permanent. +func (l *Ledger) BanForAttack(netblock netip.Prefix, now time.Time, notes Notes) Ban { + return l.ban(netblock, now, CauseAttack, notes) } // Bans returns the bans held on netblock, oldest first. It is not a @@ -256,13 +264,13 @@ func (l *Ledger) Bans(netblock netip.Prefix) []Ban { return slices.Clone(*bans) } -// Made returns how many bans the ledger has made since the start; bans -// read from bans.json are not among them. -func (l *Ledger) Made() int { +// Made returns how many bans for cause the ledger has made since the +// start; bans read from bans.json are not among them. +func (l *Ledger) Made(cause string) int { l.mu.Lock() defer l.mu.Unlock() - return l.made + return l.made[cause] } // Count returns how many of the bans held are active at now, and how many @@ -332,6 +340,73 @@ func (l *Ledger) Load(bans []Ban) { } } +// ban bans netblock at now for cause, with notes, as BanForLimit and +// BanForAttack describe, and returns the ban. +func (l *Ledger) ban( + netblock netip.Prefix, now time.Time, cause string, notes Notes, +) Ban { + l.mu.Lock() + defer l.mu.Unlock() + + // held are the netblock's bans, none of them active. + var held []Ban + + bans, found := l.netblocks.Get(netblock) + if found { + active := activeBan(*bans, now) + if active != nil { + return *active + } + + held = *bans + notes.EarlierBans = earlierBans(held) + } + + notes.Request = notes.Request.cut() + ban := Ban{Netblock: netblock, Start: now, Cause: cause, Notes: notes} + + if cause == CauseAttack { + ban.Expires = l.attackExpiry(held, now) + } else { + ban.Expires = l.limitExpiry(held, now) + } + + l.add(ban) + l.made[cause]++ + l.markChanged() + + return ban +} + +// earlierBans returns how many bans a netblock with the bans held, oldest +// first, has had, by cause: the first ban held counts the bans the +// netblock had before that one, since dropped to make room, and each ban +// held adds one. +func earlierBans(held []Ban) EarlierBans { + earlier := held[0].Notes.EarlierBans + + for _, ban := range held { + switch ban.Cause { + case CauseLimit: + earlier.Limit++ + case CauseAttack: + earlier.Attack++ + default: + earlier.WithoutCause++ + } + } + + return earlier +} + +// markChanged has Changed receive a value, unless one is waiting already. +func (l *Ledger) markChanged() { + select { + case l.changed <- struct{}{}: + default: // a value is waiting already + } +} + // active returns the ban active at now on a netblock client is in, or // nil. func (l *Ledger) active(client netip.Addr, now time.Time) *Ban { @@ -383,12 +458,23 @@ func (l *Ledger) add(ban Ban) { } } -// expiry returns when a ban for a broken limit made at now ends, or zero -// when it is permanent. last is the netblock's ban that ended last, or nil -// when it has none. -func (l *Ledger) expiry(last *Ban, now time.Time) time.Time { +// limitExpiry returns when a ban for a broken limit made at now ends, or +// zero when it is permanent. held are the netblock's bans, none of them +// active, of which the one that ended last, other than a ban for a clear +// sign of attack, can make the new ban longer. A ban an admin adds to +// bans.json can start after another and end before it, so that one is +// looked for among them all. +func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time { length := l.rules.LimitBanDuration + var last *Ban + + for i, ban := range held { + if ban.Cause != CauseAttack && (last == nil || ban.Expires.After(last.Expires)) { + last = &held[i] + } + } + if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow { lastLength := last.Expires.Sub(last.Start) // This is repeatFactor * lastLength > MaxBanDuration, written so @@ -407,6 +493,20 @@ func (l *Ledger) expiry(last *Ban, now time.Time) time.Time { return now.Add(length) } +// attackExpiry returns when a ban for a clear sign of attack made at now +// ends. held are the netblock's bans, none of them active: if one of them +// is for a clear sign of attack too, the new ban is permanent, and its +// end zero; otherwise it ends AttackBanDuration later. +func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time { + for _, ban := range held { + if ban.Cause == CauseAttack { + return time.Time{} + } + } + + return now.Add(l.rules.AttackBanDuration) +} + // dropOne drops the earliest ban of the netblock that has gone longest // without a request, and the netblock with it if that was its only ban. func (l *Ledger) dropOne() { diff --git a/internal/bans/bans_test.go b/internal/bans/bans_test.go index 0bf38f1..87fe845 100644 --- a/internal/bans/bans_test.go +++ b/internal/bans/bans_test.go @@ -24,8 +24,9 @@ func TestRepeatsTripleUntilPermanent(t *testing.T) { ban := ledger.BanForLimit(netblock, now, bans.Notes{}) length := time.Duration(hours) * time.Hour - if !ban.Expires.Equal(now.Add(length)) || ban.Notes.EarlierBans != i { - t.Fatalf("ban %d lasts %s with %d earlier bans, want %d hours and %d", + if !ban.Expires.Equal(now.Add(length)) || + ban.Notes.EarlierBans != (bans.EarlierBans{Limit: i}) { + t.Fatalf("ban %d lasts %s with earlier bans %+v, want %d hours and %d for a limit", i+1, ban.Expires.Sub(now), ban.Notes.EarlierBans, hours, i) } @@ -66,8 +67,9 @@ func TestRepeatWindowRunsOut(t *testing.T) { first := ledger.BanForLimit(netblock, midnight(), bans.Notes{}) second := ledger.BanForLimit(netblock, first.Expires.Add(tc.gap), bans.Notes{}) - if second.Expires.Sub(second.Start) != tc.want || second.Notes.EarlierBans != 1 { - t.Errorf("second ban lasts %s with %d earlier bans, want %s and 1", + if second.Expires.Sub(second.Start) != tc.want || + second.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { + t.Errorf("second ban lasts %s with earlier bans %+v, want %s and 1 for a limit", second.Expires.Sub(second.Start), second.Notes.EarlierBans, tc.want) } }) @@ -235,9 +237,102 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) { second := ledger.BanForLimit(netblock, first.Expires, bans.Notes{}) held := ledger.Bans(netblock) - if len(held) != 1 || held[0] != second || held[0].Notes.EarlierBans != 1 { - t.Errorf("the ledger holds %+v, want only the second ban, with 1 earlier ban", - held) + if len(held) != 1 || held[0] != second || + held[0].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { + t.Errorf("the ledger holds %+v, want only the second ban, "+ + "with 1 earlier ban for a limit", held) + } +} + +func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) { + t.Parallel() + + ledger := bans.New(defaultRules()) + netblock := netip.MustParsePrefix("203.0.113.9/32") + notes := bans.Notes{RuleID: "env-file", Target: "path"} + + ban := ledger.BanForAttack(netblock, midnight(), notes) + if !ban.Expires.Equal(midnight().Add(7*day)) || ban.Cause != bans.CauseAttack || + ban.Notes.RuleID != "env-file" || ledger.Made(bans.CauseAttack) != 1 || + ledger.Made(bans.CauseLimit) != 0 { + t.Fatalf("the ban is %+v, with %d made for an attack and %d for a limit, "+ + "want one for an attack, of seven days", ban, + ledger.Made(bans.CauseAttack), ledger.Made(bans.CauseLimit)) + } + + wantChanged(t, ledger, true) + + // In observe mode the ban refuses nothing, and stays as it is. + got, _ := ledger.Find(netblock.Addr(), midnight().Add(time.Hour)) + if got.Permanent() { + t.Fatal("a request found under the ban made it permanent") + } + + // A request it refuses makes it permanent, and bans.json due. + got, _ = ledger.Check(netblock.Addr(), midnight().Add(time.Hour)) + if !got.Permanent() || !ledger.Bans(netblock)[0].Permanent() { + t.Fatalf("after a request during the ban, it is %+v, want it permanent", got) + } + + wantChanged(t, ledger, true) + + _, banned := ledger.Check(netblock.Addr(), midnight().Add(100*365*day)) + if !banned { + t.Error("the permanent ban ended") + } +} + +func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) { + t.Parallel() + + ledger := bans.New(defaultRules()) + netblock := netip.MustParsePrefix("203.0.113.9/32") + + // A ban for a broken limit before does not count. + first := ledger.BanForLimit(netblock, midnight(), bans.Notes{}) + second := ledger.BanForAttack(netblock, first.Expires, bans.Notes{}) + + if second.Expires.Sub(second.Start) != 7*day { + t.Fatalf("the first ban for an attack lasts %s, want 7 days", + second.Expires.Sub(second.Start)) + } + + // Once that has run out without a request, the netblock is served, and + // its next clear sign of attack bans it for good. + _, banned := ledger.Check(netblock.Addr(), second.Expires) + if banned { + t.Fatal("the ban did not end") + } + + // Its notes show the earlier ban for an attack that makes it permanent, + // beside the one for a limit. + third := ledger.BanForAttack(netblock, second.Expires.Add(30*day), bans.Notes{}) + if !third.Permanent() || + third.Notes.EarlierBans != (bans.EarlierBans{Limit: 1, Attack: 1}) { + t.Errorf("the next ban for an attack is %+v, want a permanent one, "+ + "with 1 earlier ban for a limit and 1 for an attack", third) + } +} + +func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) { + t.Parallel() + + ledger := bans.New(defaultRules()) + netblock := netip.MustParsePrefix("203.0.113.9/32") + + // Three times the seven days would be permanent; a limit broken as the + // ban for an attack ends bans for an hour, as a first broken limit does. + attack := ledger.BanForAttack(netblock, midnight(), bans.Notes{}) + limit := ledger.BanForLimit(netblock, attack.Expires, bans.Notes{}) + + if limit.Expires.Sub(limit.Start) != time.Hour || limit.Cause != bans.CauseLimit { + t.Errorf("the ban for a limit is %+v, want one of an hour", limit) + } + + // And a request during the ban for a limit leaves it as it is. + got, _ := ledger.Check(netblock.Addr(), limit.Start) + if got.Permanent() { + t.Error("a request during a ban for a limit made it permanent") } } @@ -269,6 +364,7 @@ func defaultRules() bans.Rules { LimitBanDuration: time.Hour, LimitBanRepeatWindow: day, MaxBanDuration: 7 * day, + AttackBanDuration: 7 * day, MaxBans: 5000, } } diff --git a/internal/bans/snapshot_test.go b/internal/bans/snapshot_test.go index 3a1a651..7db1c44 100644 --- a/internal/bans/snapshot_test.go +++ b/internal/bans/snapshot_test.go @@ -82,8 +82,9 @@ func TestLoadedBansCarryOn(t *testing.T) { } again := after.BanForLimit(netblock, ban.Expires, bans.Notes{}) - if again.Expires.Sub(again.Start) != 3*time.Hour || again.Notes.EarlierBans != 1 { - t.Errorf("the next ban lasts %s with %d earlier bans, want 3h and 1", + if again.Expires.Sub(again.Start) != 3*time.Hour || + again.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { + t.Errorf("the next ban lasts %s with earlier bans %+v, want 3h and 1 for a limit", again.Expires.Sub(again.Start), again.Notes.EarlierBans) } } @@ -178,7 +179,8 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) { Netblock: netblock, Start: midnight(), Expires: midnight().Add(9 * time.Hour), - Notes: bans.Notes{EarlierBans: 2}, + Cause: bans.CauseLimit, + Notes: bans.Notes{EarlierBans: bans.EarlierBans{Limit: 2}}, } admins := bans.Ban{ Netblock: netblock, @@ -191,10 +193,13 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) { // Once both have ended, a limit broken within the repeat window bans // for three times the 9 hours, and the notes count the two bans - // before the 9-hour one, it, and the admin's. + // before the 9-hour one and it, for a limit, and the admin's, without + // a cause. ban := ledger.BanForLimit(netblock, nineHours.Expires.Add(time.Hour), bans.Notes{}) - if ban.Expires.Sub(ban.Start) != 27*time.Hour || ban.Notes.EarlierBans != 4 { - t.Errorf("the next ban lasts %s with %d earlier bans, want 27h and 4", + if ban.Expires.Sub(ban.Start) != 27*time.Hour || + ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 3, WithoutCause: 1}) { + t.Errorf("the next ban lasts %s with earlier bans %+v, "+ + "want 27h, 3 for a limit and 1 without a cause", ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans) } } diff --git a/internal/config/config.go b/internal/config/config.go index 0b94680..cae58f9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,8 +34,8 @@ type Config struct { InstanceName string // Observe is true in observe mode, when SWWAF_MODE is observe rather // than enforce: a request that SWWAF_DENY_NETS, a ban, the country - // lists or a rate limit would refuse is passed to the app instead, and - // no ban is made. + // lists, a rate limit or a rule would refuse is passed to the app + // instead, and no ban is made. Observe bool // TrustedProxies are the netblocks whose X-Forwarded-For is // believed (SWWAF_TRUSTED_PROXIES). @@ -94,7 +94,8 @@ type Config struct { // BanResponse is the status a refused client is answered with, 403 // or 429, or 0 to close the connection without an answer // (SWWAF_BAN_RESPONSE). It answers a banned client, a request that - // breaks a rate limit, SWWAF_DENY_NETS and the country lists. + // breaks a rate limit or matches a ban rule, SWWAF_DENY_NETS and the + // country lists. BanResponse int // LimitBanDuration is the ban for a first broken rate limit // (SWWAF_LIMIT_BAN_DURATION). A limit broken again within @@ -105,6 +106,9 @@ type Config struct { LimitBanDuration time.Duration LimitBanRepeatWindow time.Duration MaxBanDuration time.Duration + // AttackBanDuration is the ban for a first clear sign of attack + // (SWWAF_ATTACK_BAN_DURATION). It cannot be off. + AttackBanDuration time.Duration // MaxBans is the most bans held (SWWAF_MAX_BANS). MaxBans int // BanScopeV4Prefix is the length of the netblock around an IPv4 @@ -127,6 +131,10 @@ type Config struct { // metrics (SWWAF_METRICS_TOP_N). MetricsToken string MetricsTopN int + // RulesDir is the directory of the rule files (SWWAF_RULES_DIR), read + // unless RulesEnabled is false (SWWAF_RULES_ENABLED). + RulesDir string + RulesEnabled bool // settings are the values read, as given or by default, for the // log line at start. @@ -187,6 +195,7 @@ var ( errNotMode = errors.New("is not enforce or observe") errNotPathPrefix = errors.New( "is not a path prefix starting with /, such as /assets/") + errNotBoolean = errors.New("is not true or false") ) // FromEnvironment reads the settings with lookupEnv, normally @@ -224,6 +233,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) { LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"), LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"), MaxBanDuration: env.durationNotOff("SWWAF_MAX_BAN_DURATION", "7d"), + AttackBanDuration: env.durationNotOff("SWWAF_ATTACK_BAN_DURATION", "7d"), MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"), BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"), StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/smallwebwaf"), @@ -233,6 +243,8 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) { "accept,accept-language,accept-encoding,content-type,origin,range"), MetricsToken: env.token("SWWAF_METRICS_TOKEN"), MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"), + RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"), + RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"), } for _, country := range cfg.ExclusivelyAllowedCountries { @@ -316,6 +328,16 @@ func (e *environment) observe(name, defaultValue string) bool { return mode == "observe" } +// boolean reads a setting that is true or false. +func (e *environment) boolean(name, defaultValue string) bool { + value := e.value(name, defaultValue) + if value != "true" && value != "false" { + e.check(name, fmt.Errorf("%q %w", value, errNotBoolean)) + } + + return value == "true" +} + // netblocks reads a setting that is a list of netblocks. func (e *environment) netblocks(name, defaultValue string) []netip.Prefix { netblocks, err := parseNetblocks(e.value(name, defaultValue)) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cfb1a6e..a9c326a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -42,6 +42,7 @@ const ( limitBanDuration = "SWWAF_LIMIT_BAN_DURATION" limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW" maxBanDuration = "SWWAF_MAX_BAN_DURATION" + attackBanDuration = "SWWAF_ATTACK_BAN_DURATION" maxBans = "SWWAF_MAX_BANS" banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX" stateDir = "SWWAF_STATE_DIR" @@ -51,6 +52,8 @@ const ( metricsTopN = "SWWAF_METRICS_TOP_N" instanceName = "SWWAF_INSTANCE_NAME" logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS" + rulesDir = "SWWAF_RULES_DIR" + rulesEnabled = "SWWAF_RULES_ENABLED" ) // defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS. @@ -108,6 +111,7 @@ func TestDefaults(t *testing.T) { LimitBanDuration: time.Hour, LimitBanRepeatWindow: 24 * time.Hour, MaxBanDuration: 7 * 24 * time.Hour, + AttackBanDuration: 7 * 24 * time.Hour, MaxBans: 5000, BanScopeV4Prefix: 32, StateDir: "/var/lib/smallwebwaf", @@ -115,6 +119,8 @@ func TestDefaults(t *testing.T) { StateCounterInterval: 15 * time.Minute, MetricsToken: "", MetricsTopN: 50, + RulesDir: "/etc/smallwebwaf/rules.d", + RulesEnabled: true, }) if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" { @@ -168,13 +174,13 @@ func TestValuesAsSet(t *testing.T) { rateLimitPerMinute: "60", rateLimitPerHour: "600", rateLimitPerDay: "6000", - rateLimitExemptPaths: "/assets/, /favicon.ico", deniedCountries: "cn, RU,kp,Xk", allowedCountries: "de", banResponse: "429", limitBanDuration: "15m", limitBanRepeatWindow: "2d", maxBanDuration: "30d", + attackBanDuration: "1d", maxBans: "100", banScopeV4Prefix: "24", stateDir: "/srv/waf-state", @@ -182,6 +188,8 @@ func TestValuesAsSet(t *testing.T) { stateCounterInterval: "1h", metricsToken: token, metricsTopN: "10", + rulesDir: "/srv/waf-rules", + rulesEnabled: "false", }) wantSettings(t, cfg, config.Config{ @@ -202,6 +210,7 @@ func TestValuesAsSet(t *testing.T) { LimitBanDuration: 15 * time.Minute, LimitBanRepeatWindow: 48 * time.Hour, MaxBanDuration: 30 * 24 * time.Hour, + AttackBanDuration: 24 * time.Hour, MaxBans: 100, BanScopeV4Prefix: 24, StateDir: "/srv/waf-state", @@ -209,6 +218,8 @@ func TestValuesAsSet(t *testing.T) { StateCounterInterval: time.Hour, MetricsToken: token, MetricsTopN: 10, + RulesDir: "/srv/waf-rules", + RulesEnabled: false, }) if cfg.UpstreamURL.String() != "https://app.internal:8443/" { @@ -221,6 +232,12 @@ func TestValuesAsSet(t *testing.T) { wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24") wantCountries(t, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK") wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE") +} + +func TestRateLimitExemptPathsAsSet(t *testing.T) { + t.Parallel() + + cfg := fromEnvironment(t, environment{rateLimitExemptPaths: "/assets/, /favicon.ico"}) if !slices.Equal(cfg.RateLimitExemptPaths, []string{"/assets/", "/favicon.ico"}) { t.Errorf("%s gave %v, want /assets/ and /favicon.ico", @@ -356,7 +373,7 @@ func TestTrustedProxiesSetButEmptyTrustNothing(t *testing.T) { func TestInvalidValueStopsTheStart(t *testing.T) { t.Parallel() - for _, tc := range []struct{ name, value string }{ + wantStartStopped(t, []struct{ name, value string }{ {listenAddr, "8080"}, {listenAddr, ":http"}, {listenAddr, ":65536"}, {upstreamURL, "127.0.0.1:8081"}, {upstreamURL, "ftp://127.0.0.1:8081"}, @@ -406,21 +423,37 @@ func TestInvalidValueStopsTheStart(t *testing.T) { {allowedCountries, "uk"}, {allowedCountries, "zz"}, {allowedCountries, "de,germany"}, - {banResponse, "404"}, {banResponse, "drop"}, {banResponse, ""}, - {limitBanDuration, off}, {limitBanDuration, "0s"}, {limitBanDuration, "1"}, - {limitBanRepeatWindow, off}, {limitBanRepeatWindow, "-1h"}, - {maxBanDuration, off}, {maxBanDuration, "1w"}, - {maxBans, off}, {maxBans, "0"}, {maxBans, "5K"}, - {banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"}, - {stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"}, - {stateWriteDelay, off}, {stateWriteDelay, "0s"}, - {stateCounterInterval, off}, {stateCounterInterval, "15"}, {metricsTopN, off}, {metricsTopN, "0"}, {metricsTopN, "-1"}, {logRequestHeaders, "accept,,origin"}, {logRequestHeaders, "accept;origin"}, {logRequestHeaders, "accept language"}, {logRequestHeaders, "x-foo:"}, {logRequestHeaders, "host"}, {logRequestHeaders, "accept,Host"}, {logRequestHeaders, "transfer-encoding"}, {logRequestHeaders, "TRANSFER-ENCODING"}, - } { + {rulesEnabled, "yes"}, {rulesEnabled, "True"}, + }) +} + +func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) { + t.Parallel() + + wantStartStopped(t, []struct{ name, value string }{ + {banResponse, "404"}, {banResponse, "drop"}, {banResponse, ""}, + {limitBanDuration, off}, {limitBanDuration, "0s"}, {limitBanDuration, "1"}, + {limitBanRepeatWindow, off}, {limitBanRepeatWindow, "-1h"}, + {maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off}, + {maxBans, off}, {maxBans, "0"}, {maxBans, "5K"}, + {banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"}, + {stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"}, + {stateWriteDelay, off}, {stateWriteDelay, "0s"}, + {stateCounterInterval, off}, {stateCounterInterval, "15"}, + }) +} + +// wantStartStopped checks that each setting, set to its value, stops the +// start with an error that names the setting. +func wantStartStopped(t *testing.T, invalid []struct{ name, value string }) { + t.Helper() + + for _, tc := range invalid { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() @@ -536,6 +569,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) { limitBanDuration: "1h", limitBanRepeatWindow: "24h", maxBanDuration: "7d", + attackBanDuration: "7d", maxBans: "5000", banScopeV4Prefix: "32", stateDir: "/var/lib/smallwebwaf", @@ -545,6 +579,8 @@ func TestLogsEachSettingWithItsValue(t *testing.T) { metricsTopN: "50", instanceName: hostname, logRequestHeaders: defaultLogRequestHeaders, + rulesDir: "/etc/smallwebwaf/rules.d", + rulesEnabled: "true", } if !maps.Equal(line.Settings, want) { t.Errorf("logged settings\n%v\nwant\n%v", line.Settings, want) @@ -574,8 +610,8 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) { wantBanSettings(t, got, want) } -// wantBanSettings checks the settings for bans, the state files and the -// metrics. +// wantBanSettings checks the settings for bans, the state files, the +// metrics and the rule files. func wantBanSettings(t *testing.T, got *config.Config, want config.Config) { t.Helper() @@ -583,11 +619,17 @@ func wantBanSettings(t *testing.T, got *config.Config, want config.Config) { got.LimitBanDuration != want.LimitBanDuration || got.LimitBanRepeatWindow != want.LimitBanRepeatWindow || got.MaxBanDuration != want.MaxBanDuration || + got.AttackBanDuration != want.AttackBanDuration || got.MaxBans != want.MaxBans || got.BanScopeV4Prefix != want.BanScopeV4Prefix { t.Errorf("ban settings\n%+v\nwant\n%+v", got, want) } + if got.RulesDir != want.RulesDir || got.RulesEnabled != want.RulesEnabled { + t.Errorf("rule files in %q, on: %t, want %q, %t", + got.RulesDir, got.RulesEnabled, want.RulesDir, want.RulesEnabled) + } + if got.StateDir != want.StateDir || got.StateWriteDelay != want.StateWriteDelay || got.StateCounterInterval != want.StateCounterInterval { diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index f08a714..52f969a 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -14,6 +14,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/requestlog" + "sneak.berlin/go/smallwebwaf/internal/rules" ) // Metrics are smallwebwaf's metrics. They are safe for concurrent use. @@ -30,7 +31,9 @@ type Metrics struct { rateLimitHits *prometheus.CounterVec sizeAndTimeLimitHits *prometheus.CounterVec offences *prometheus.CounterVec - countries *countries + // ruleMatches are made by AddRules. + ruleMatches *prometheus.CounterVec + countries *countries // GeoJSRequests are the requests to GeoJS, and GeoJSFailures those // that failed. GeoJSUnanswered are the requests whose client counted @@ -135,19 +138,22 @@ func New(topN int) *Metrics { // AddBansAndClients adds the metrics read from the ledger and the table // of clients as the metrics are asked for: the bans made since the start, -// the bans active and permanent at now, and the clients in the table. +// by cause, the bans active and permanent at now, and the clients in the +// table. func (m *Metrics) AddBansAndClients( ledger *bans.Ledger, limiter *ratelimit.Limiter, now func() time.Time, ) { - m.registry.MustRegister( - // Every ban smallwebwaf makes so far is for a broken limit. - prometheus.NewCounterFunc(prometheus.CounterOpts{ + for _, cause := range []string{bans.CauseLimit, bans.CauseAttack} { + m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ Name: "smallwebwaf_bans_made_total", Help: "Bans made, by cause.", - ConstLabels: prometheus.Labels{"cause": "limit"}, + ConstLabels: prometheus.Labels{"cause": cause}, }, func() float64 { - return float64(ledger.Made()) - }), + return float64(ledger.Made(cause)) + })) + } + + m.registry.MustRegister( prometheus.NewGaugeFunc(prometheus.GaugeOpts{ Name: "smallwebwaf_active_bans", Help: "Bans active now, the permanent ones included.", @@ -173,6 +179,24 @@ func (m *Metrics) AddBansAndClients( ) } +// AddRules adds the metrics of the rule files: the requests that matched +// each rule, which RuleMatched counts, and the rules loaded from +// ruleFiles, read as the metrics are asked for. It is called once, before +// RuleMatched. +func (m *Metrics) AddRules(ruleFiles *rules.Files) { + m.ruleMatches = counterVec("smallwebwaf_rule_matches_total", + "Requests that matched a rule of the rule files, by its id and action.", + []string{"rule_id", "action"}) + + m.registry.MustRegister(m.ruleMatches, + prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "smallwebwaf_rules_loaded", + Help: "Rules loaded from the rule files.", + }, func() float64 { + return float64(ruleFiles.Len()) + })) +} + // ServeHTTP answers with the metrics in the Prometheus text format. func (m *Metrics) ServeHTTP(w http.ResponseWriter, r *http.Request) { m.handler.ServeHTTP(w, r) @@ -219,6 +243,12 @@ func (m *Metrics) RequestEnded( } } +// RuleMatched counts a request that matched the rule id, whose action is +// action. +func (m *Metrics) RuleMatched(id, action string) { + m.ruleMatches.WithLabelValues(id, action).Inc() +} + // StateFileWritten counts a write of the state file name, of size bytes, // that ended with err. func (m *Metrics) StateFileWritten(name string, size int, err error) { diff --git a/internal/proxy/bans.go b/internal/proxy/bans.go index 96abb66..e9b3c86 100644 --- a/internal/proxy/bans.go +++ b/internal/proxy/bans.go @@ -6,6 +6,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/requestlog" + "sneak.berlin/go/smallwebwaf/internal/rules" ) // banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged @@ -54,20 +55,12 @@ func (rq *request) limitBroken(now time.Time) bool { netblock := rq.netblock() ban := rq.h.ledger.BanForLimit(netblock, now, bans.Notes{ - Country: rq.line.Country, - Limit: hit.Limit, - Window: hit.Window, - Count: hit.Requests, - Request: bans.Request{ - Time: now, - Method: rq.in.Method, - Host: rq.in.Host, - Path: rq.in.URL.RequestURI(), - Status: rq.h.config.BanResponse, - UserAgent: rq.in.UserAgent(), - }, - // The histories count this request only once it has ended. - Requests: rq.h.limiter.Requests(netblock) + 1, + Country: rq.line.Country, + Limit: hit.Limit, + Window: hit.Window, + Count: hit.Requests, + Request: rq.noted(now), + Requests: rq.netblockRequests(netblock), }) rq.h.limiter.Reset(group) rq.line.BanExpires = banExpires(ban) @@ -75,6 +68,40 @@ func (rq *request) limitBroken(now time.Time) bool { return true } +// banForAttack bans the client's netblock at now for a clear sign of +// attack, the match of rule, a ban rule. +func (rq *request) banForAttack(now time.Time, rule rules.Rule) { + netblock := rq.netblock() + ban := rq.h.ledger.BanForAttack(netblock, now, bans.Notes{ + Country: rq.line.Country, + RuleID: rule.ID, + Target: rule.Target, + Request: rq.noted(now), + Requests: rq.netblockRequests(netblock), + }) + rq.line.BanExpires = banExpires(ban) +} + +// noted is the request, refused at now with SWWAF_BAN_RESPONSE, as the +// notes of the ban it makes keep it. +func (rq *request) noted(now time.Time) bans.Request { + return bans.Request{ + Time: now, + Method: rq.in.Method, + Host: rq.in.Host, + Path: rq.in.URL.RequestURI(), + Status: rq.h.config.BanResponse, + UserAgent: rq.in.UserAgent(), + } +} + +// netblockRequests is how many requests netblock has sent since it was +// first seen, this one included: the histories count it only once it has +// ended. +func (rq *request) netblockRequests(netblock netip.Prefix) int64 { + return rq.h.limiter.Requests(netblock) + 1 +} + // netblock is the netblock a ban on the client covers: its IPv4 address, // widened to SWWAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup // counts it in. diff --git a/internal/proxy/bans_test.go b/internal/proxy/bans_test.go index 5683aa2..889a2ce 100644 --- a/internal/proxy/bans_test.go +++ b/internal/proxy/bans_test.go @@ -278,6 +278,7 @@ func TestBanNotes(t *testing.T) { Netblock: netblock, Start: start, Expires: start.Add(time.Hour), + Cause: bans.CauseLimit, Notes: bans.Notes{ Country: "DE", Limit: 1, @@ -295,7 +296,7 @@ func TestBanNotes(t *testing.T) { // refused under the ban. Requests: 4, Refused: 2, - EarlierBans: 0, + EarlierBans: bans.EarlierBans{}, }, } @@ -312,8 +313,8 @@ func TestBanNotes(t *testing.T) { s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited) got = ledger.Bans(netblock) - if len(got) != 2 || got[1].Notes.EarlierBans != 1 { - t.Errorf("bans %+v, want two, the second with one earlier ban", got) + if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { + t.Errorf("bans %+v, want two, the second with one earlier ban for a limit", got) } } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index a651f4d..3d5e3b3 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -17,6 +17,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/metrics" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/requestlog" + "sneak.berlin/go/smallwebwaf/internal/rules" ) // How smallwebwaf keeps connections to the app open between requests. @@ -51,6 +52,9 @@ type Params struct { // limits, bans are made and run out, and GeoJS's answers are kept, // normally time.Now in UTC, the time the state files give. Now func() time.Time + // Rules are the rule files' rules, which each request is checked + // against. + Rules *rules.Files } // Server is the server smallwebwaf runs, with the parts of the proxy @@ -90,6 +94,7 @@ func New(params Params) *Server { LimitBanDuration: params.Config.LimitBanDuration, LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow, MaxBanDuration: params.Config.MaxBanDuration, + AttackBanDuration: params.Config.AttackBanDuration, MaxBans: params.Config.MaxBans, }), geojs: lookup.New(lookup.Params{ @@ -98,8 +103,10 @@ func New(params Params) *Server { ProcessLog: params.ProcessLog, Metrics: m, }), + rules: params.Rules, } m.AddBansAndClients(h.ledger, h.limiter, params.Now) + m.AddRules(params.Rules) return &Server{ Server: &http.Server{ @@ -134,6 +141,7 @@ type handler struct { limiter *ratelimit.Limiter ledger *bans.Ledger geojs *lookup.GeoJS + rules *rules.Files } // newTransport returns what carries requests to the app. It never goes diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 4a3980d..11fca3b 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -17,6 +17,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/proxy" "sneak.berlin/go/smallwebwaf/internal/requestlog" + "sneak.berlin/go/smallwebwaf/internal/rules" ) const ( @@ -74,6 +75,8 @@ const ( banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX" instanceName = "SWWAF_INSTANCE_NAME" logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS" + attackBanDuration = "SWWAF_ATTACK_BAN_DURATION" + rulesDir = "SWWAF_RULES_DIR" ) // output collects what smallwebwaf writes on stdout. @@ -212,14 +215,15 @@ func startProxyWithGeoJS( } // startProxyWithClock is startProxyWithGeoJS with requests counted and -// bans made by the time now tells, and returns the server as well. +// bans made by the time now tells, and returns the server as well. Unless +// env sets SWWAF_RULES_DIR, it is an empty directory, of no rules. func startProxyWithClock( t *testing.T, appURL, geojsURL string, now func() time.Time, env map[string]string, ) (string, *output, *proxy.Server) { t.Helper() - settings := map[string]string{"SWWAF_UPSTREAM_URL": appURL} + settings := map[string]string{"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir()} maps.Copy(settings, env) cfg, err := config.FromEnvironment(func(name string) (string, bool) { @@ -232,12 +236,22 @@ func startProxyWithClock( } out := &output{} + processLog := requestlog.NewProcessLogger(out) + + ruleFiles, err := rules.Load(rules.Params{ + Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog, + }) + if err != nil { + t.Fatalf("rule files: %v", err) + } + server := proxy.New(proxy.Params{ Config: cfg, RequestLog: out, - ProcessLog: requestlog.NewProcessLogger(out), + ProcessLog: processLog, GeoJSURL: geojsURL, Now: now, + Rules: ruleFiles, }) listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") diff --git a/internal/proxy/request.go b/internal/proxy/request.go index fded4b7..91b6642 100644 --- a/internal/proxy/request.go +++ b/internal/proxy/request.go @@ -159,20 +159,23 @@ func requestHeaders(r *http.Request, names []string) map[string]string { // check is the one place where a request can be refused once its client // is known, before its body is read or anything reaches the app. It // returns nil to let the request through. The checks of checkClient come -// first, answered with SWWAF_BAN_RESPONSE, and then the size limit, so -// that a request the rate limits count is counted even when it is -// refused for its size. In observe mode a request checkClient refuses -// goes on to the size limit like any other. ctx is the request's own -// context. +// first, answered with SWWAF_BAN_RESPONSE, or 403 for a block rule, and +// then the size limit, so that a request the rate limits count is counted +// even when it is refused for its size. In observe mode a request +// checkClient refuses goes on to the size limit like any other. ctx is +// the request's own context. func (rq *request) check(ctx context.Context) *refusal { action := rq.checkClient(ctx) - if action != "" { - if !rq.h.config.Observe { - return rq.banResponse(action) - } + switch { + case action == "": + case rq.h.config.Observe: // The log line names what enforce mode would have done. rq.line.WouldAction = action + case action == requestlog.ActionRuleBlocked: + return &refusal{status: http.StatusForbidden, action: action} + default: + return rq.banResponse(action) } maxBytes := rq.h.config.RequestMaxBytes @@ -195,8 +198,8 @@ func (rq *request) check(ctx context.Context) *refusal { // request any of them refuses is not counted for the rate limits. Then // come the rate limits, unless the client is in // SWWAF_RATE_LIMIT_EXEMPT_NETS or the request's path is exempt under -// SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that every other request is counted. -// ctx is the request's own context. +// SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that every other request is counted, +// and last the rule files. 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) { @@ -223,7 +226,7 @@ func (rq *request) checkClient(ctx context.Context) string { return requestlog.ActionRateLimited } - return "" + return rq.checkRules(now) } // pathExempt reports whether the rate limits leave out a request for u diff --git a/internal/proxy/rulefiles_test.go b/internal/proxy/rulefiles_test.go new file mode 100644 index 0000000..9bff779 --- /dev/null +++ b/internal/proxy/rulefiles_test.go @@ -0,0 +1,232 @@ +package proxy_test + +import ( + "net/http" + "net/netip" + "os" + "path/filepath" + "slices" + "testing" + "time" + + "sneak.berlin/go/smallwebwaf/internal/bans" + "sneak.berlin/go/smallwebwaf/internal/requestlog" +) + +// testRules are the rules most tests here load: a block rule for +// /blocked and a ban rule for /.env. +const testRules = ` +blocked path block ^/blocked$ +probe path ban ^/\.env$ +` + +func TestEachRuleAction(t *testing.T) { + t.Parallel() + + s, clk, server := startWithClock(t, "", map[string]string{ + rulesDir: writeRules(t, "noted path log ^/\n"+testRules), + banResponse: "429", + }) + start := clk.Now() + + // A log rule notes its match, and lets the request through. + line := s.get(client, http.StatusOK, requestlog.ActionForward) + wantRuleIDs(t, line, "noted") + + // A block rule refuses with 403, whatever SWWAF_BAN_RESPONSE is, and + // bans no one. + line = s.request(client, "/blocked", http.StatusForbidden, + requestlog.ActionRuleBlocked) + wantRuleIDs(t, line, "noted", "blocked") + s.get(client, http.StatusOK, requestlog.ActionForward) + + // A ban rule refuses with SWWAF_BAN_RESPONSE, and bans the client for + // seven days, the default. + line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned) + wantRuleIDs(t, line, "noted", "probe") + + if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) { + t.Errorf("log line has ban_expires %q, want seven days on", line.BanExpires) + } + + netblock := netip.MustParsePrefix(client + "/32") + want := bans.Ban{ + Netblock: netblock, + Start: start, + Expires: start.Add(7 * 24 * time.Hour), + Cause: bans.CauseAttack, + Notes: bans.Notes{ + RuleID: "probe", + Target: "path", + Request: bans.Request{ + Time: start, + Method: http.MethodGet, + Host: appHost, + Path: "/.env", + Status: http.StatusTooManyRequests, + UserAgent: userAgent, + }, + // The four requests up to and including the probe. + Requests: 4, + }, + } + + got := server.Ledger.Bans(netblock) + if len(got) != 1 || got[0] != want { + t.Fatalf("bans\n%+v\nwant\n%+v", got, want) + } + + // The next request is refused under the ban, without being checked + // against the rules, and makes the ban permanent. + clk.advance(time.Hour) + + line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned) + wantRuleIDs(t, line) + + if line.BanExpires != permanent { + t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires) + } + + clk.advance(365 * 24 * time.Hour) + s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned) +} + +func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) { + t.Parallel() + + s, clk, _ := startWithClock(t, "", map[string]string{ + rulesDir: writeRules(t, testRules), + attackBanDuration: "1h", + }) + + // The first probe bans for SWWAF_ATTACK_BAN_DURATION. + line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) + if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) { + t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires) + } + + // Once that ban has run out without a request, the client is served, + // and its next probe bans it for good. + clk.advance(time.Hour) + s.get(client, http.StatusOK, requestlog.ActionForward) + + line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) + if line.BanExpires != permanent { + t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires) + } +} + +func TestRulesComeAfterTheOtherChecks(t *testing.T) { + t.Parallel() + + const ( + allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS + exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS + ) + + s, _, server := startWithClock(t, "", map[string]string{ + rulesDir: writeRules(t, testRules), + allowNets: allowed, + rateLimitExemptNets: exempt, + rateLimitPerMinute: "1", + }) + + // A client in SWWAF_ALLOW_NETS is not checked. + line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward) + wantRuleIDs(t, line) + + // A probe over the rate limit breaks the limit before any rule sees + // it. + s.get(client, http.StatusOK, requestlog.ActionForward) + line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionRateLimited) + wantRuleIDs(t, line) + + limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32")) + if len(limitBan) != 1 || limitBan[0].Cause != bans.CauseLimit { + t.Errorf("bans %+v, want one for a broken limit", limitBan) + } + + // A client the rate limits do not apply to is still checked. + s.get(exempt, http.StatusOK, requestlog.ActionForward) + s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionBanned) +} + +func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) { + t.Parallel() + + s, _, server := startWithClock(t, "", map[string]string{ + rulesDir: writeRules(t, testRules), + mode: observe, + }) + + line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward) + wantWouldAction(t, line, requestlog.ActionRuleBlocked) + wantRuleIDs(t, line, "blocked") + + line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward) + wantWouldAction(t, line, requestlog.ActionBanned) + wantRuleIDs(t, line, "probe") + + if line.BanExpires != "" { + t.Errorf("log line has ban_expires %q, want none", line.BanExpires) + } + + // No ban was made. + line = s.get(client, http.StatusOK, requestlog.ActionForward) + wantWouldAction(t, line, "") + + if got := server.Ledger.Snapshot(); len(got) != 0 { + t.Errorf("bans %+v, want none", got) + } +} + +func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) { + t.Parallel() + + const scraper = "192.0.2.200" + + s, _, _ := startWithClock(t, "", map[string]string{ + rulesDir: writeRules(t, testRules), + metricsToken: token, + }) + + s.request(client, "/blocked", http.StatusForbidden, requestlog.ActionRuleBlocked) + s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) + s.get(client, http.StatusForbidden, requestlog.ActionBanned) + + metrics := s.scrape(scraper) + wantMetric(t, metrics, + `smallwebwaf_rule_matches_total{action="block",rule_id="blocked"}`, 1) + wantMetric(t, metrics, + `smallwebwaf_rule_matches_total{action="ban",rule_id="probe"}`, 1) + wantMetric(t, metrics, "smallwebwaf_rules_loaded", 2) + wantMetric(t, metrics, + `smallwebwaf_requests_total{action="rule_blocked",status_class="4xx"}`, 1) + wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack"}`, 1) + wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit"}`, 0) + wantMetric(t, metrics, "smallwebwaf_permanent_bans", 1) +} + +// writeRules writes content as a rule file into a new directory, and +// returns the directory. +func writeRules(t *testing.T, content string) string { + t.Helper() + + dir := t.TempDir() + + err := os.WriteFile(filepath.Join(dir, "test.rules"), []byte(content), 0o600) + if err != nil { + t.Fatalf("write the rule file: %v", err) + } + + return dir +} + +// wantRuleIDs checks the request log line's rule_ids. +func wantRuleIDs(t *testing.T, line logLine, want ...string) { + t.Helper() + + if !slices.Equal(line.RuleIDs, want) { + t.Errorf("log line has rule_ids %v, want %v", line.RuleIDs, want) + } +} diff --git a/internal/proxy/rules.go b/internal/proxy/rules.go new file mode 100644 index 0000000..f91765e --- /dev/null +++ b/internal/proxy/rules.go @@ -0,0 +1,40 @@ +package proxy + +import ( + "time" + + "sneak.berlin/go/smallwebwaf/internal/requestlog" + "sneak.berlin/go/smallwebwaf/internal/rules" +) + +// checkRules checks the request against the rules of the rule files at +// now, notes the ids of those it matches in the log line, and returns the +// action of the rule that refuses it, ActionRuleBlocked for a block rule +// and ActionBanned for a ban rule, or "" when none does. In enforce mode +// a ban rule bans the client's netblock for a clear sign of attack. +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: + return requestlog.ActionRuleBlocked + case rules.ActionBan: + if !rq.h.config.Observe { + rq.banForAttack(now, last) + } + + return requestlog.ActionBanned + default: + return "" + } +} diff --git a/internal/requestlog/requestlog.go b/internal/requestlog/requestlog.go index a36003a..f7330c2 100644 --- a/internal/requestlog/requestlog.go +++ b/internal/requestlog/requestlog.go @@ -28,8 +28,12 @@ const ( // ActionRateLimited is a request refused because it took its client // over a rate limit, which bans the client. ActionRateLimited = "rate_limited" - // ActionBanned is a request refused because a ban covers its client. + // ActionBanned is a request refused because a ban covers its client, + // or because it matched a ban rule, which bans the client. ActionBanned = "banned" + // ActionRuleBlocked is a request refused because it matched a block + // rule. + ActionRuleBlocked = "rule_blocked" // ActionDenied is a request refused because its client is in // SWWAF_DENY_NETS. ActionDenied = "denied" @@ -107,11 +111,14 @@ type Line struct { Action string `json:"action"` // WouldAction is, in observe mode, the action enforce mode would have // taken with a request it would have refused: ActionDenied, - // ActionBanned, ActionCountryDenied or ActionRateLimited. + // ActionBanned, ActionCountryDenied, ActionRateLimited or + // ActionRuleBlocked. WouldAction string `json:"would_action,omitempty"` // Counts are the client's requests as the rate limits counted them // with this one, for a request they counted. Counts ratelimit.Counts `json:"counts,omitzero"` + // RuleIDs are the ids of the rule file rules the request matched. + RuleIDs []string `json:"rule_ids,omitempty"` // LimitHit is the window whose rate limit the request went over: // minute, hour or day. LimitHit string `json:"limit_hit,omitempty"` diff --git a/internal/rules/rules.go b/internal/rules/rules.go new file mode 100644 index 0000000..d8bd989 --- /dev/null +++ b/internal/rules/rules.go @@ -0,0 +1,459 @@ +// Package rules reads the rule files: the plain text files in +// SWWAF_RULES_DIR, one rule to a line, that each request is checked +// against, as the "Rule files" section of SPEC.md describes. They are read +// at start, and again once the directory has had no change for a short +// time after one is edited, added or removed. +package rules + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "sync/atomic" + "time" + + "github.com/fsnotify/fsnotify" +) + +// The actions a rule takes when it matches. +const ( + // ActionLog notes the match in the request log, and does nothing else. + ActionLog = "log" + // ActionBlock refuses the request with 403. + ActionBlock = "block" + // ActionBan refuses the request and bans the client's netblock: the + // request is a clear sign of attack. + ActionBan = "ban" +) + +// extension ends the name of every rule file. +const extension = ".rules" + +// quietTime is how long SWWAF_RULES_DIR must go without a change before +// the rule files are read again, so that a file still being written, such +// as one saved in place, appended to or copied in with scp, is read only +// once whole. +const quietTime = 2 * time.Second + +// headerTarget starts the target that is one request header, +// header:. +const headerTarget = "header:" + +// escapeLength is the length of a percent escape, such as %2e. +const escapeLength = 3 + +var ( + // ruleLine is a rule: four fields separated by spaces or tabs, of + // which the fourth, the regex, runs to the end of the line. + ruleLine = regexp.MustCompile(`^([^ \t]+)[ \t]+([^ \t]+)[ \t]+([^ \t]+)[ \t]+(.+)$`) + // idChars are the characters of a rule's id. + idChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) +) + +var ( + errNotRule = errors.New( + "is not a rule: an id, a target, an action and a regex, " + + "separated by spaces or tabs") + errNotID = errors.New("is not an id of letters, digits, - and _") + errNotTarget = errors.New( + "is not path, query, uri, method, host, user_agent, referer or header:") + errHeaderTakenOut = errors.New( + "names a header that Go's HTTP server takes out of every request, " + + "so a rule never sees it") + errNotAction = errors.New("is not log, block or ban") + errNotRegex = errors.New("does not compile") + errUsedTwice = errors.New("is already the id of the rule at") +) + +// Rule is one rule of a rule file. +type Rule struct { + // ID names the rule in the request log, the metrics and ban notes. + ID string + // Target is what the regex is matched against, such as path or + // header:Accept. + Target string + // Action is ActionLog, ActionBlock or ActionBan. + Action string + + regex *regexp.Regexp +} + +// Params are what Load needs. +type Params struct { + // Dir is the directory of the rule files (SWWAF_RULES_DIR). + Dir string + // Enabled is SWWAF_RULES_ENABLED: while it is false, no file is read + // and no rule loaded. + Enabled bool + // ProcessLog receives how many rules were read, and the error in a + // rule file edited while smallwebwaf runs. + ProcessLog *slog.Logger +} + +// Files are the rule files of a running smallwebwaf, and the rules read +// from them. They are safe for concurrent use. +type Files struct { + params Params + // rules are the rules loaded, in the order of their files' names, and + // then of their lines. + rules atomic.Pointer[[]Rule] +} + +// Load reads the rules of every *.rules file in Dir, in the order of the +// files' names, unless Enabled is false. A Dir that cannot be read is an +// error, and so is a line that is not a rule, a rule for the Host or the +// Transfer-Encoding header, which Go's HTTP server takes out of every +// request, a regex that does not compile and an id used twice, each named +// with its file and line. +func Load(params Params) (*Files, error) { + f := &Files{params: params} + f.rules.Store(&[]Rule{}) + + if !params.Enabled { + return f, nil + } + + rules, err := read(params.Dir) + if err != nil { + return nil, err + } + + f.rules.Store(&rules) + f.logRead(len(rules)) + + return f, nil +} + +// Match checks r against the rules, in order, and returns those it +// matches, up to the first whose action refuses it, block or ban, which +// is then the last one returned. +func (f *Files) Match(r *http.Request) []Rule { + var matched []Rule + + for _, rule := range *f.rules.Load() { + if !rule.matches(r) { + continue + } + + matched = append(matched, rule) + if rule.Action != ActionLog { + break + } + } + + return matched +} + +// Len returns how many rules are loaded. +func (f *Files) Len() int { + return len(*f.rules.Load()) +} + +// Watch watches Dir until ctx is done, and reads the rule files again +// once Dir has had no change for quietTime, after one is edited, added or +// removed, and after Watch starts watching. If they then hold an error, +// the rules stay as they were, the error is logged with its file and +// line, and the files are read again after the next change. If Dir cannot +// be watched, that is logged, and the rules stay as they were loaded. +// While Enabled is false, Watch returns at once. +func (f *Files) Watch(ctx context.Context) { + if !f.params.Enabled { + return + } + + watcher, err := fsnotify.NewWatcher() + if err == nil { + defer func() { + _ = watcher.Close() + }() + + err = watcher.Add(f.params.Dir) + } + + if err != nil { + f.params.ProcessLog.Error("cannot watch the rule files for edits", + "error", err.Error()) + + return + } + + f.params.ProcessLog.Info("watching the rule files for edits", + "directory", f.params.Dir) + + f.readAfterChanges(ctx, watcher.Events, watcher.Errors) +} + +// readAfterChanges reads the rule files again once quietTime has passed +// without a change from events, until ctx is done, and logs the errors +// from errs. The wait starts at once, as if for a change, so that an edit +// saved after Load read the files, and before Dir was watched, is taken +// in too. +func (f *Files) readAfterChanges( + ctx context.Context, events <-chan fsnotify.Event, errs <-chan error, +) { + quiet := time.NewTimer(quietTime) + defer quiet.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-events: + quiet.Reset(quietTime) + case <-quiet.C: + f.readAgain() + case err := <-errs: + f.params.ProcessLog.Warn("watching the rule files failed", + "error", err.Error()) + } + } +} + +// readAgain reads the rule files again, in place of the rules loaded, or +// logs the error that keeps the rules as they were. +func (f *Files) readAgain() { + rules, err := read(f.params.Dir) + if err != nil { + f.params.ProcessLog.Error( + "a rule file has an error, and the rules stay as they were", + "error", err.Error()) + + return + } + + f.rules.Store(&rules) + f.logRead(len(rules)) +} + +// logRead logs that the rule files were read, and how many rules they +// hold, which can be none. +func (f *Files) logRead(count int) { + f.params.ProcessLog.Info("read the rule files", + "directory", f.params.Dir, "rules", count) +} + +// read returns the rules of every rule file in dir, in the order of the +// files' names, and then of their lines. A file whose name starts with a +// dot, such as an editor's lock file .#50-app.rules, is not a rule file, +// as a shell's *.rules would not match it. +func read(dir string) ([]Rule, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("SWWAF_RULES_DIR cannot be read: %w", err) + } + + var rules []Rule + + // places are where each id is, as ", line ". + places := map[string]string{} + + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || strings.HasPrefix(name, ".") || filepath.Ext(name) != extension { + continue + } + + rules, err = readFile(filepath.Join(dir, name), rules, places) + if err != nil { + return nil, err + } + } + + return rules, nil +} + +// readFile appends the rules of the rule file at path to rules. places +// are where each id read so far is, and gain those of the file. +func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) { + data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWWAF_RULES_DIR + if err != nil { + return nil, err + } + + number := 0 + + for line := range strings.Lines(string(data)) { + number++ + place := fmt.Sprintf("%s, line %d", path, number) + + text := strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r") + + rule, isRule, err := parse(text) + if err != nil { + return nil, fmt.Errorf("%s: %w", place, err) + } + + if !isRule { + continue + } + + first, used := places[rule.ID] + if used { + return nil, fmt.Errorf("%s: the id %q %w %s", place, rule.ID, errUsedTwice, first) + } + + places[rule.ID] = place + rules = append(rules, rule) + } + + return rules, nil +} + +// parse reads a line of a rule file. It returns false for a blank line +// and for a comment, a line that starts with #. +func parse(line string) (Rule, bool, error) { + line = strings.TrimLeft(line, " \t") + if line == "" || strings.HasPrefix(line, "#") { + return Rule{}, false, nil + } + + fields := ruleLine.FindStringSubmatch(line) + if fields == nil { + return Rule{}, false, errNotRule + } + + rule := Rule{ID: fields[1], Target: fields[2], Action: fields[3]} + + switch { + case !idChars.MatchString(rule.ID): + return Rule{}, false, fmt.Errorf("the id %q %w", rule.ID, errNotID) + case !isTarget(rule.Target): + return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotTarget) + case strings.EqualFold(rule.Target, headerTarget+"Host"): + return Rule{}, false, fmt.Errorf( + "the target %q %w; the request's host is the target host", + rule.Target, errHeaderTakenOut) + case strings.EqualFold(rule.Target, headerTarget+"Transfer-Encoding"): + return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errHeaderTakenOut) + case !slices.Contains([]string{ActionLog, ActionBlock, ActionBan}, rule.Action): + return Rule{}, false, fmt.Errorf("the action %q %w", rule.Action, errNotAction) + } + + regex, err := regexp.Compile(fields[4]) + if err != nil { + return Rule{}, false, fmt.Errorf("the regex %w: %w", errNotRegex, err) + } + + rule.regex = regex + + return rule, true, nil +} + +// isTarget reports whether target is one a rule may have. +func isTarget(target string) bool { + switch target { + case "path", "query", "uri", "method", "host", "user_agent", "referer": + return true + } + + name, isHeader := strings.CutPrefix(target, headerTarget) + + return isHeader && name != "" +} + +// matches reports whether the rule's regex matches its target in r. For +// uri it is matched against the path and query as received, and against +// them once percent-decoded, so that an encoded probe cannot slip past. +func (rule Rule) matches(r *http.Request) bool { + if rule.Target == "uri" { + uri := pathAndQuery(r) + + return rule.regex.MatchString(uri) || rule.regex.MatchString(decodeOnce(uri)) + } + + return rule.regex.MatchString(value(rule.Target, r)) +} + +// value returns what a rule with target, other than uri, is matched +// against in r: the path and the query as the client sent them, before +// any decoding or re-encoding, split at the first ?, and a header's values +// joined by ", ", as HTTP joins those of a header sent more than once. +func value(target string, r *http.Request) string { + switch target { + case "path": + path, _, _ := strings.Cut(pathAndQuery(r), "?") + + return path + case "query": + _, query, _ := strings.Cut(pathAndQuery(r), "?") + + return query + case "method": + return r.Method + case "host": + return r.Host + case "user_agent": + return header(r, "User-Agent") + case "referer": + return header(r, "Referer") + default: + return header(r, strings.TrimPrefix(target, headerTarget)) + } +} + +// pathAndQuery returns the target of r's request line, r.RequestURI, as +// the client sent it, less any scheme and host: a target with a scheme +// gives what follows the scheme and its :, and the host when // follows. +// So http://host/path, as a client sends it to a proxy, gives /path, and +// so does http:/path, which Go reads as a target with a scheme and no +// host. r.URL is not used: when the path holds a character it escapes, +// such as \ or a non-ASCII byte, it decodes the whole path and escapes it +// again, so that \ becomes %5C and %2e a dot. +func pathAndQuery(r *http.Request) string { + if !r.URL.IsAbs() { + return r.RequestURI + } + + _, afterScheme, _ := strings.Cut(r.RequestURI, ":") + + hostAndRest, hasHost := strings.CutPrefix(afterScheme, "//") + if !hasHost { + return afterScheme + } + + start := strings.IndexAny(hostAndRest, "/?") + if start < 0 { + return "" + } + + return hostAndRest[start:] +} + +// header returns the values of r's header name joined by ", ", or "" if +// r has no such header. +func header(r *http.Request, name string) string { + return strings.Join(r.Header.Values(name), ", ") +} + +// decodeOnce returns s with each percent escape, such as %2e, replaced by +// the byte it stands for. A % that is not followed by two hex digits is +// left as it is, so that a malformed escape cannot keep the rest of s +// from being decoded. +func decodeOnce(s string) string { + var decoded strings.Builder + + for i := 0; i < len(s); i++ { + if s[i] == '%' && i+escapeLength <= len(s) { + b, err := hex.DecodeString(s[i+1 : i+escapeLength]) + if err == nil { + decoded.Write(b) + + i += escapeLength - 1 + + continue + } + } + + decoded.WriteByte(s[i]) + } + + return decoded.String() +} diff --git a/internal/rules/rules_test.go b/internal/rules/rules_test.go new file mode 100644 index 0000000..3051309 --- /dev/null +++ b/internal/rules/rules_test.go @@ -0,0 +1,633 @@ +package rules_test + +import ( + "context" + "encoding/json" + "log/slog" + "maps" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strconv" + "testing" + + "sneak.berlin/go/smallwebwaf/internal/rules" +) + +const ( + // What the process log says once Watch watches the directory, after + // each reading of the rule files, and for one that has an error. + watching = "watching the rule files for edits" + read = "read the rule files" + hasError = "a rule file has an error, and the rules stay as they were" + // maxLogLines is how many lines of the process log wait for a test to + // read them. + maxLogLines = 64 + // browser is the user agent of an ordinary visitor. + browser = "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" + // testFile is the rule file of a test that needs only one, and + // firstFile the first of a test's rule files. + testFile = "test.rules" + firstFile = "00-a.rules" + // userAgent is the header that carries the user agent. + userAgent = "User-Agent" +) + +func TestEachTargetMatchesWhatItNames(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + rule string // its target, action and regex + uri string // the request's path and query + header http.Header + want bool + }{ + {"path as received", `path log ^/%2eenv$`, "/%2eenv", nil, true}, + {"path not decoded", `path log ^/\.env$`, "/%2eenv", nil, false}, + {"path without the query", `path log ^/a$`, "/a?b=c", nil, true}, + {"query as received", `query log ^b=%2e$`, "/a?b=%2e", nil, true}, + {"uri as received", `uri log ^/a\?b=%2e$`, "/a?b=%2e", nil, true}, + {"uri decoded", `uri log (\.\./){2}`, "/a?f=%2e%2e%2f%2e%2e%2f", nil, true}, + { + "uri decoded past malformed escapes", `uri log (\.\./){2}&h=%$`, + "/a?g=%zz&f=%2e%2e%2f%2e%2e%2f&h=%", nil, true, + }, + {"uri decoded only once", `uri log ^/a\.b$`, "/a%252eb", nil, false}, + {"method", `method log ^PUT$`, "/", nil, true}, + {"host", `host log ^app\.example$`, "/", nil, true}, + { + "user_agent", `user_agent log ^sqlmap/`, "/", + http.Header{userAgent: {"sqlmap/1.8"}}, true, + }, + { + "user_agent sent twice", `user_agent log ^curl/8, sqlmap/`, "/", + http.Header{userAgent: {"curl/8", "sqlmap/1.8"}}, true, + }, + {"user_agent missing", `user_agent log ^$`, "/", nil, true}, + { + "referer", `referer log ^https://spam\.example/`, "/", + http.Header{"Referer": {"https://spam.example/buy"}}, true, + }, + { + "a header sent twice", `header:x-api-version log ^2, 3$`, "/", + http.Header{"X-Api-Version": {"2", "3"}}, true, + }, + {"a header missing", `header:X-Api-Version log ^$`, "/", nil, true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + files := load(t, ruleFiles{testFile: "a-rule " + tc.rule + "\n"}) + + // Every request is a PUT, which the method rule looks for. + r := httptest.NewRequestWithContext(t.Context(), http.MethodPut, + "http://app.example"+tc.uri, nil) + maps.Copy(r.Header, tc.header) + + got := len(files.Match(r)) == 1 + if got != tc.want { + t.Errorf("%s matches %s: %t, want %t", tc.rule, tc.uri, got, tc.want) + } + }) + } +} + +func TestPathMatchedAsTheClientSentIt(t *testing.T) { + t.Parallel() + + // Each path holds a character Go's URL type would escape again, \ or + // a non-ASCII byte, and each rule is written for the path as sent. + for _, tc := range []struct { + rule string // its target, action and regex + sent string // the path and query the client sent + }{ + {`path log ^/\.\.\\\.\.\\windows\\win\.ini$`, `/..\..\windows\win.ini`}, + {`path log ^/%2e%2e\\%2e%2e\\windows\\win\.ini$`, `/%2e%2e\%2e%2e\windows\win.ini`}, + {`path log ^/café$`, "/café?x=1"}, + {`uri log ^/%2e%2e\\%2e%2e\\boot\.ini\?x=1$`, `/%2e%2e\%2e%2e\boot.ini?x=1`}, + } { + files := load(t, ruleFiles{testFile: "as-sent " + tc.rule + "\n"}) + + // The target in origin form, as traefik sends it, in absolute form, + // as a client sends it to a proxy, and with a scheme but no host, + // which Go reads as absolute form with no host, sending the app + // the path. + for _, target := range []string{ + tc.sent, "http://app.example" + tc.sent, "http:" + tc.sent, "foo:" + tc.sent, + } { + r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil) + wantMatched(t, files, r, "as-sent") + } + } +} + +func TestMatchingStopsAtTheFirstRuleThatRefuses(t *testing.T) { + t.Parallel() + + files := load(t, ruleFiles{testFile: ` +every-path path log ^/ +no-path path log ^$ +first-refusal path block ^/probe +later-ban path ban ^/probe +after path log ^/ +`}) + + // Every log rule that matches is noted, and the block rule ends the + // matching. + wantMatched(t, files, get(t, "/probe"), "every-path", "first-refusal") + wantMatched(t, files, get(t, "/page"), "every-path", "after") + + // A ban rule ends it too. + files = load(t, ruleFiles{testFile: "ban path ban ^/\nlater path block ^/\n"}) + wantMatched(t, files, get(t, "/"), "ban") +} + +func TestFilesReadInNameOrderThenLineOrder(t *testing.T) { + t.Parallel() + + files := load(t, ruleFiles{ + "50-b.rules": "b1 path log ^/\n\n# a comment\n # an indented one\nb2 path log ^/\n", + firstFile: "a1 path log ^/\r\n", + // None is a rule file. + "notes.txt": "notes, not rules\n", + "10-c.rules.bak": "an old copy\n", + "20-d.rules/keep": "a file in a directory\n", + }) + + wantMatched(t, files, get(t, "/"), "a1", "b1", "b2") + + if files.Len() != 3 { + t.Errorf("%d rules loaded, want 3", files.Len()) + } +} + +func TestFileWhoseNameStartsWithADotIsNotARuleFile(t *testing.T) { + t.Parallel() + + dir := writeFiles(t, ruleFiles{firstFile: "probe path block ^/probe\n"}) + + // The lock file Emacs makes beside a file while it is edited: a link to + // nothing, which cannot be read. + err := os.Symlink("user@host.1234:1700000000", filepath.Join(dir, ".#"+firstFile)) + if err != nil { + t.Fatalf("symlink: %v", err) + } + + params, _ := newParams(dir) + + files, err := rules.Load(params) + if err != nil { + t.Fatalf("load: %v", err) + } + + wantMatched(t, files, get(t, "/probe"), "probe") +} + +func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + content string + line int + want string + }{ + { + "too few fields", "env-file path ban\n", 1, + "is not a rule: an id, a target, an action and a regex, " + + "separated by spaces or tabs", + }, + { + "an id of other characters", "# ids\n\nenv.file path ban ^/\n", 3, + `the id "env.file" is not an id of letters, digits, - and _`, + }, + { + "an unknown target", "env-file paths ban ^/\n", 1, + `the target "paths" is not path, query, uri, method, host, ` + + "user_agent, referer or header:", + }, + { + "a header without a name", "env-file header: ban ^/\n", 1, + `the target "header:" is not path, query, uri, method, host, ` + + "user_agent, referer or header:", + }, + { + "the Host header", "host-header header:host block ^$\n", 1, + `the target "header:host" names a header that Go's HTTP server ` + + "takes out of every request, so a rule never sees it; " + + "the request's host is the target host", + }, + { + "the Transfer-Encoding header", + "# bodies sent in chunks\nchunked header:Transfer-Encoding block ^chunked$\n", 2, + `the target "header:Transfer-Encoding" names a header that Go's ` + + "HTTP server takes out of every request, so a rule never sees it", + }, + { + "an unknown action", "env-file path deny ^/\n", 1, + `the action "deny" is not log, block or ban`, + }, + { + "a regex that does not compile", "env-file path ban ^/(\n", 1, + "the regex does not compile: error parsing regexp: " + + "missing closing ): `^/(`", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + dir := writeFiles(t, ruleFiles{"00-default.rules": tc.content}) + path := filepath.Join(dir, "00-default.rules") + + wantRefused(t, dir, path+", line "+strconv.Itoa(tc.line)+": "+tc.want) + }) + } +} + +func TestIDUsedTwiceStopsTheStartNamingBothPlaces(t *testing.T) { + t.Parallel() + + dir := writeFiles(t, ruleFiles{ + "00-a.rules": "probe path log ^/a\n", + "50-b.rules": "other path log ^/b\nprobe path ban ^/c\n", + }) + + wantRefused(t, dir, filepath.Join(dir, "50-b.rules")+`, line 2: the id "probe" `+ + "is already the id of the rule at "+filepath.Join(dir, "00-a.rules")+", line 1") +} + +func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), "rules.d") + + wantRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+ + ": no such file or directory") +} + +func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) { + t.Parallel() + + params, lines := newParams(writeFiles(t, ruleFiles{"00-default.rules": "# none\n"})) + + files, err := rules.Load(params) + if err != nil { + t.Fatalf("load: %v", err) + } + + line := lines.waitFor(t, read) + if files.Len() != 0 || line["rules"] != 0.0 { + t.Errorf("%d rules loaded, and the log says %v, want none", files.Len(), line) + } +} + +func TestRuleFilesOffReadNothing(t *testing.T) { + t.Parallel() + + // SWWAF_RULES_DIR does not exist, which would stop the start. + params, _ := newParams(filepath.Join(t.TempDir(), "rules.d")) + params.Enabled = false + + files, err := rules.Load(params) + if err != nil { + t.Fatalf("load: %v", err) + } + + if files.Len() != 0 || files.Match(get(t, "/")) != nil { + t.Errorf("%d rules loaded with the rule files off", files.Len()) + } + + // It would watch until the test ends. + files.Watch(t.Context()) +} + +func TestEditsTakenInWhileRunning(t *testing.T) { + t.Parallel() + + dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"}) + files, lines := watch(t, dir) + + // matches reports whether path matches a rule. + matches := func(path string) bool { return len(files.Match(get(t, path))) == 1 } + + // A file added. + save(t, dir, "50-b.rules", "second path block ^/second\n") + lines.waitUntil(t, func() bool { return matches("/second") }) + wantMatched(t, files, get(t, "/first"), "first") + + // A file edited. + save(t, dir, firstFile, "first path block ^/edited\n") + lines.waitUntil(t, func() bool { return !matches("/first") }) + wantMatched(t, files, get(t, "/edited"), "first") + + // A file removed. + err := os.Remove(filepath.Join(dir, "50-b.rules")) + if err != nil { + t.Fatalf("remove: %v", err) + } + + lines.waitUntil(t, func() bool { return !matches("/second") }) + wantMatched(t, files, get(t, "/edited"), "first") +} + +func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) { + t.Parallel() + + dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"}) + files, lines := watch(t, dir) + + // The edit's second line has an unknown action, so the rules stay as + // they were, the first line's earlier version included. + save(t, dir, firstFile, "first path block ^/edited\nsecond path bann ^/second\n") + + line := lines.waitFor(t, hasError) + want := filepath.Join(dir, firstFile) + + `, line 2: the action "bann" is not log, block or ban` + + if line["error"] != want || line["level"] != "ERROR" { + t.Errorf("logged %v, want an error %q", line, want) + } + + wantMatched(t, files, get(t, "/first"), "first") + wantMatched(t, files, get(t, "/second")) + + // Once mended, the file is read again. + save(t, dir, firstFile, "first path block ^/edited\nsecond path ban ^/second\n") + lines.waitUntil(t, func() bool { return len(files.Match(get(t, "/second"))) == 1 }) + wantMatched(t, files, get(t, "/edited"), "first") +} + +func TestDefaultFileBansProbesAtTheSiteRootAlone(t *testing.T) { + t.Parallel() + + params, _ := newParams(filepath.Join("..", "..", "share", "rules.d")) + + files, err := rules.Load(params) + if err != nil { + t.Fatalf("load the default file: %v", err) + } + + // Probes sent by a browser, by the rule that refuses them. + for rule, targets := range map[string][]string{ + "env-file": {"/.env", "/.env.production", "/.ENV"}, + "vcs-dir": {"/.git/config", "/.git", "/.svn/entries"}, + "secrets-dir": {"/.aws/credentials", "/.ssh/id_rsa"}, + "secret-file": {"/.htpasswd", "/.DS_Store", "/.git-credentials"}, + "editor-dir": {"/.vscode/sftp.json"}, + "backup-file": { + "/wp-config.php.bak", "/index.php~", "/dump.sql", "/backup.sql.gz", + }, + "log-file": {"/debug.log"}, + "compose-file": {"/docker-compose.yml", "/compose.yaml"}, + "php-shell": {"/shell.php"}, + "path-traversal": { + "/static/../../etc/passwd", "/f?f=%2e%2e%2f%2e%2e%2fetc%2fpasswd", + }, + } { + for _, target := range targets { + wantRefusedBy(t, files, target, browser, rule) + } + } + + // Scanners, by their user agents. + for _, scanner := range []string{ + "sqlmap/1.8.4#stable (https://sqlmap.org)", + "Mozilla/5.0 (compatible; Nuclei - Open-source project)", + } { + wantRefusedBy(t, files, "/", scanner, "scanner-agent") + } + + // Ordinary requests to a code forge for files of those names deeper + // in its paths, and for other files at its root. + for _, target := range []string{ + "/owner/repo/src/branch/main/.env.example", + "/owner/repo/src/branch/main/.env", + "/owner/repo/src/branch/main/.github/workflows/ci.yml", + "/owner/repo/src/branch/main/.vscode/settings.json", + "/owner/repo/src/branch/main/.htaccess", + "/owner/repo/src/branch/main/docker-compose.yml", + "/owner/repo/src/branch/main/db/schema.sql", + "/owner/repo/raw/branch/main/debug.log", + "/owner/repo.git/info/refs?service=git-upload-pack", + "/owner/repo/src/branch/main/docs/../README.md", + "/user/login?redirect_to=%2fowner%2frepo", + "/index.php", + "/.well-known/security.txt", + } { + r := get(t, target) + r.Header.Set(userAgent, browser) + + matched := files.Match(r) + if len(matched) != 0 { + t.Errorf("%s matched %v, want no rule", target, ids(matched)) + } + } + + // A request without a user agent is only noted. + wantMatched(t, files, get(t, "/"), "empty-agent") +} + +// ruleFiles are files to write into a directory of rule files, by name. +type ruleFiles map[string]string + +// writeFiles writes files into a new directory, and returns it. +func writeFiles(t *testing.T, files ruleFiles) string { + t.Helper() + + dir := t.TempDir() + + for name, content := range files { + path := filepath.Join(dir, name) + + err := os.MkdirAll(filepath.Dir(path), 0o700) + if err != nil { + t.Fatalf("mkdir: %v", err) + } + + err = os.WriteFile(path, []byte(content), 0o600) + if err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + return dir +} + +// save writes content to the rule file name in dir as an editor that +// saves by renaming does, so that the file is never seen half written. +func save(t *testing.T, dir, name, content string) { + t.Helper() + + path := filepath.Join(dir, name) + + err := os.WriteFile(path+".tmp", []byte(content), 0o600) + if err != nil { + t.Fatalf("write %s: %v", name, err) + } + + err = os.Rename(path+".tmp", path) + if err != nil { + t.Fatalf("rename: %v", err) + } +} + +// newParams returns Params for the rule files in dir, switched on, with +// the process log in the processLog returned. +func newParams(dir string) (rules.Params, processLog) { + lines := make(processLog, maxLogLines) + + return rules.Params{ + Dir: dir, + Enabled: true, + ProcessLog: slog.New(slog.NewJSONHandler(lines, nil)), + }, lines +} + +// load writes files into a new directory and loads the rules in it. +func load(t *testing.T, files ruleFiles) *rules.Files { + t.Helper() + + params, _ := newParams(writeFiles(t, files)) + params.ProcessLog = slog.New(slog.DiscardHandler) + + loaded, err := rules.Load(params) + if err != nil { + t.Fatalf("load: %v", err) + } + + return loaded +} + +// watch loads the rules in dir, runs their Watch until the test ends, and +// waits until it watches the directory. +func watch(t *testing.T, dir string) (*rules.Files, processLog) { + t.Helper() + + params, lines := newParams(dir) + + files, err := rules.Load(params) + if err != nil { + t.Fatalf("load: %v", err) + } + + ctx, stop := context.WithCancel(t.Context()) + stopped := make(chan struct{}) + + go func() { + files.Watch(ctx) + close(stopped) + }() + + t.Cleanup(func() { + stop() + <-stopped + }) + + lines.waitFor(t, watching) + + return files, lines +} + +// wantRefused checks that loading the rule files in dir fails with the +// error want. +func wantRefused(t *testing.T, dir, want string) { + t.Helper() + + params, _ := newParams(dir) + + _, err := rules.Load(params) + if err == nil || err.Error() != want { + t.Errorf("error %v, want %s", err, want) + } +} + +// get returns a GET request for target, a path and an optional query, as +// smallwebwaf's server reads it, without a user agent. +func get(t *testing.T, target string) *http.Request { + t.Helper() + + return httptest.NewRequestWithContext(t.Context(), http.MethodGet, + "http://app.example"+target, nil) +} + +// wantRefusedBy checks that a GET request for target with the user agent +// sent matches rule alone, and that rule refuses it. +func wantRefusedBy(t *testing.T, files *rules.Files, target, sent, rule string) { + t.Helper() + + r := get(t, target) + r.Header.Set(userAgent, sent) + + matched := files.Match(r) + if len(matched) != 1 || matched[0].ID != rule || matched[0].Action == rules.ActionLog { + t.Errorf("%s from %q matched %v, want %s alone, refusing it", target, + sent, ids(matched), rule) + } +} + +// wantMatched checks the ids of the rules r matches, in order. +func wantMatched(t *testing.T, files *rules.Files, r *http.Request, want ...string) { + t.Helper() + + got := ids(files.Match(r)) + if !slices.Equal(got, want) { + t.Errorf("%s matched %v, want %v", r.URL, got, want) + } +} + +// ids returns the ids of matched. +func ids(matched []rules.Rule) []string { + got := make([]string, 0, len(matched)) + for _, rule := range matched { + got = append(got, rule.ID) + } + + return got +} + +// processLog receives the lines of a process log, each a JSON object, for +// a test to wait for. +type processLog chan string + +// Write receives a line of the process log. +func (l processLog) Write(line []byte) (int, error) { + l <- string(line) + + return len(line), nil +} + +// waitFor returns the next line of the process log whose message is msg, +// passing over the lines before it. It waits as long as that takes, so +// that a slow test process cannot fail the test. +func (l processLog) waitFor(t *testing.T, msg string) map[string]any { + t.Helper() + + for line := range l { + var fields map[string]any + + err := json.Unmarshal([]byte(line), &fields) + if err != nil { + t.Fatalf("process log line %q is not JSON: %v", line, err) + } + + if fields["msg"] == msg { + return fields + } + } + + return nil +} + +// waitUntil waits for the rule files to be read until done reports true, +// as it does once they have been read after the test's last change. They +// can be read before then too, as they are once Watch starts watching. +func (l processLog) waitUntil(t *testing.T, done func() bool) { + t.Helper() + + for !done() { + l.waitFor(t, read) + } +} diff --git a/internal/rules/watch_internal_test.go b/internal/rules/watch_internal_test.go new file mode 100644 index 0000000..8ef8dde --- /dev/null +++ b/internal/rules/watch_internal_test.go @@ -0,0 +1,162 @@ +package rules + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "testing" + "testing/synctest" + "time" + + "github.com/fsnotify/fsnotify" +) + +// The tests below run readAfterChanges in a synctest bubble, where time is +// a clock of the test's own: time.Sleep moves it on at once, and +// synctest.Wait returns once readAfterChanges waits again, so that every +// reading due by then is done. The test sends the changes itself, as the +// watch of a directory cannot run in a bubble. + +func TestFileWrittenInTwoPartsTakenInOnlyWhole(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "50-app.rules") + writeFile(t, path, "first path block ^/first\n") + files := load(t, dir) + changes := run(t, files) + + file, err := os.Create(path) //nolint:gosec // a file the test wrote + if err != nil { + t.Fatalf("create: %v", err) + } + + defer func() { + _ = file.Close() + }() + + // The first part ends in the middle of a ban rule's regex, which, + // read then, would ban every request. + write(t, file, "first path block ^/first\nprobe path ban ^/") + + changes <- fsnotify.Event{Name: path, Op: fsnotify.Write} + + time.Sleep(quietTime - time.Nanosecond) + synctest.Wait() + wantMatched(t, files, "/anything") + + // The second part starts the wait again. + write(t, file, `\.env$`+"\n") + + changes <- fsnotify.Event{Name: path, Op: fsnotify.Write} + + time.Sleep(quietTime - time.Nanosecond) + synctest.Wait() + wantMatched(t, files, "/.env") + + time.Sleep(time.Nanosecond) + synctest.Wait() + wantMatched(t, files, "/.env", "probe") + wantMatched(t, files, "/anything") + }) +} + +func TestEditSavedBeforeTheWatchStartsTakenIn(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "50-app.rules") + writeFile(t, path, "first path block ^/first\n") + files := load(t, dir) + + // Saved after Load read the files, and before the directory was + // watched, so that no change is seen for it. + writeFile(t, path, "first path block ^/edited\n") + run(t, files) + time.Sleep(quietTime) + synctest.Wait() + wantMatched(t, files, "/edited", "first") + }) +} + +// load loads the rules in dir. +func load(t *testing.T, dir string) *Files { + t.Helper() + + files, err := Load(Params{ + Dir: dir, Enabled: true, ProcessLog: slog.New(slog.DiscardHandler), + }) + if err != nil { + t.Fatalf("load: %v", err) + } + + return files +} + +// run runs files' readAfterChanges until the test ends, and returns the +// channel that sends it changes. +func run(t *testing.T, files *Files) chan<- fsnotify.Event { + t.Helper() + + changes := make(chan fsnotify.Event) + ctx, stop := context.WithCancel(t.Context()) + stopped := make(chan struct{}) + + go func() { + files.readAfterChanges(ctx, changes, nil) + close(stopped) + }() + + t.Cleanup(func() { + stop() + <-stopped + }) + + return changes +} + +// writeFile writes content to the file at path. +func writeFile(t *testing.T, path, content string) { + t.Helper() + + err := os.WriteFile(path, []byte(content), 0o600) + if err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +// write writes text to the end of file. +func write(t *testing.T, file *os.File, text string) { + t.Helper() + + _, err := file.WriteString(text) + if err != nil { + t.Fatalf("write: %v", err) + } +} + +// wantMatched checks the ids of the rules that a GET request for path +// matches, in order. +func wantMatched(t *testing.T, files *Files, path string, want ...string) { + t.Helper() + + r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, + "http://app.example"+path, nil) + + matched := files.Match(r) + + got := make([]string, 0, len(matched)) + for _, rule := range matched { + got = append(got, rule.ID) + } + + if !slices.Equal(got, want) { + t.Errorf("%s matched %v, want %v", path, got, want) + } +} diff --git a/internal/smallwebwaf/healthcheck_test.go b/internal/smallwebwaf/healthcheck_test.go index ae88403..56af193 100644 --- a/internal/smallwebwaf/healthcheck_test.go +++ b/internal/smallwebwaf/healthcheck_test.go @@ -28,6 +28,7 @@ func TestHealthCheck(t *testing.T) { listenAddr: localhost + ":0", upstreamURL: app.URL, stateDir: t.TempDir(), + rulesDir: t.TempDir(), } go func() { diff --git a/internal/smallwebwaf/smallwebwaf.go b/internal/smallwebwaf/smallwebwaf.go index 06b446b..32e56ee 100644 --- a/internal/smallwebwaf/smallwebwaf.go +++ b/internal/smallwebwaf/smallwebwaf.go @@ -1,6 +1,6 @@ -// Package smallwebwaf runs the smallwebwaf process: it reads the settings -// and the state files, serves requests until it is told to stop, and then -// stops in an orderly way, writing the state files. +// Package smallwebwaf runs the smallwebwaf process: it reads the settings, +// the rule files and the state files, serves requests until it is told to +// stop, and then stops in an orderly way, writing the state files. package smallwebwaf import ( @@ -19,6 +19,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/proxy" "sneak.berlin/go/smallwebwaf/internal/requestlog" + "sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/state" ) @@ -56,9 +57,9 @@ func Main(version string) int { }) } -// Run reads the settings and the state files, then serves requests until -// ctx is done. It returns the process's exit status, 1 when smallwebwaf -// cannot start. +// Run reads the settings, the rule files and the state files, then serves +// requests until ctx is done. It returns the process's exit status, 1 +// when smallwebwaf cannot start. func Run(ctx context.Context, params Params) int { processLog := requestlog.NewProcessLogger(params.Stdout) @@ -69,6 +70,17 @@ func Run(ctx context.Context, params Params) int { return 1 } + ruleFiles, err := rules.Load(rules.Params{ + Dir: cfg.RulesDir, + Enabled: cfg.RulesEnabled, + ProcessLog: processLog, + }) + if err != nil { + processLog.Error("cannot use the rule files", "error", err.Error()) + + return 1 + } + // The state files give times in UTC. now := func() time.Time { return time.Now().UTC() } @@ -78,6 +90,7 @@ func Run(ctx context.Context, params Params) int { ProcessLog: processLog, GeoJSURL: lookup.URL, Now: now, + Rules: ruleFiles, }) files, err := state.Load(state.Params{ @@ -110,16 +123,16 @@ func Run(ctx context.Context, params Params) int { "address", listener.Addr().String(), "settings", cfg) - return serve(ctx, server.Server, listener, files, processLog) + return serve(ctx, server.Server, listener, files, ruleFiles, processLog) } // serve serves requests on listener, writes the state files as they are -// due, and takes in an admin's edits of them, until ctx is done. Then it -// gives the requests in progress shutdownTimeout to finish, and writes -// every state file. +// due, takes in an admin's edits of them, and reads the rule files again +// as they change, until ctx is done. Then it gives the requests in +// progress shutdownTimeout to finish, and writes every state file. func serve( ctx context.Context, server *http.Server, listener net.Listener, - files *state.Files, processLog *slog.Logger, + files *state.Files, ruleFiles *rules.Files, processLog *slog.Logger, ) int { served := make(chan error, 1) @@ -132,6 +145,7 @@ func serve( written := make(chan struct{}) watched := make(chan struct{}) + rulesWatched := make(chan struct{}) go func() { files.Run(writing) @@ -143,6 +157,11 @@ func serve( close(watched) }() + go func() { + ruleFiles.Watch(writing) + close(rulesWatched) + }() + select { case err := <-served: processLog.Error("serving failed", "error", err.Error()) @@ -181,6 +200,7 @@ func serve( // missing from clients.json. <-written <-watched + <-rulesWatched err = files.WriteAll() if err != nil { diff --git a/internal/smallwebwaf/smallwebwaf_test.go b/internal/smallwebwaf/smallwebwaf_test.go index d70f809..fa5be79 100644 --- a/internal/smallwebwaf/smallwebwaf_test.go +++ b/internal/smallwebwaf/smallwebwaf_test.go @@ -34,6 +34,7 @@ const ( stateWriteDelay = "SWWAF_STATE_WRITE_DELAY" stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL" rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY" + rulesDir = "SWWAF_RULES_DIR" // greeting is what the tests' app answers. greeting = "hello from the app" ) @@ -163,6 +164,7 @@ func TestAddressInUseStopsTheStart(t *testing.T) { status := run(t.Context(), map[string]string{ listenAddr: taken.Addr().String(), stateDir: t.TempDir(), + rulesDir: t.TempDir(), }, out) if status != 1 { t.Errorf("exit status %d, want 1", status) @@ -186,9 +188,15 @@ func TestServesUntilToldToStop(t *testing.T) { listenAddr: localhost + ":0", upstreamURL: appURL, stateDir: dir, + rulesDir: filepath.Join("..", "..", "share", "rules.d"), }, out) }() + // The default rule file is read. + if rules := out.line(t, "msg", "read the rule files")["rules"]; rules != 12.0 { + t.Errorf("read %v rules from the default rule file, want 12", rules) + } + starting := out.line(t, "msg", "starting") wantStartingLine(t, starting, appURL, dir) @@ -217,6 +225,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) { listenAddr: localhost + ":0", upstreamURL: startApp(t), stateDir: t.TempDir(), + rulesDir: t.TempDir(), rateLimitPerDay: "2", // Neither comes due in the test: the files are written as // smallwebwaf stops. @@ -253,6 +262,7 @@ func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) { listenAddr: localhost + ":0", upstreamURL: startApp(t), stateDir: t.TempDir(), + rulesDir: t.TempDir(), trustedProxies: localhost + "/32", rateLimitPerDay: "1", scope: "24", @@ -300,6 +310,7 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) { listenAddr: localhost + ":0", upstreamURL: startApp(t), stateDir: dir, + rulesDir: t.TempDir(), trustedProxies: localhost + "/32", // No write comes due in the test, so only the watch on the // directory can take the edits in. @@ -316,6 +327,64 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) { }) } +func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + env := map[string]string{ + listenAddr: localhost + ":0", + upstreamURL: startApp(t), + stateDir: t.TempDir(), + rulesDir: dir, + // The requests sent until the rule takes effect must not break a + // rate limit, whose ban would refuse them too. + "SWWAF_RATE_LIMIT_PER_MINUTE": "off", + } + + out := runUntilStopped(t, env, func(url string) { + wantGreeting(t, url) + + // Written once: each change would start the rule files' wait + // again. A file written before smallwebwaf watches the directory is + // read once it does. + err := os.WriteFile(filepath.Join(dir, "50-app.rules"), + []byte("everything path block ^/\n"), 0o600) + if err != nil { + t.Fatalf("write the rule file: %v", err) + } + + // As long as that takes, so that a slow test process cannot fail + // the test. + for statusFrom(t, url, "203.0.113.9") != http.StatusForbidden { + time.Sleep(pollInterval) + } + }) + out.line(t, "action", "rule_blocked") +} + +func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "00-default.rules") + + err := os.WriteFile(path, []byte("# probes\nenv-file path bann ^/\\.env$\n"), 0o600) + if err != nil { + t.Fatalf("write the rule file: %v", err) + } + + wantRulesRefused(t, dir, path+`, line 2: the action "bann" is not log, block or ban`) +} + +func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), "rules.d") + + wantRulesRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+ + ": no such file or directory") +} + func TestStateFileThatDoesNotParseStopsTheStart(t *testing.T) { t.Parallel() @@ -349,7 +418,9 @@ func wantStartRefused(t *testing.T, dir, want string) { out := &output{} - status := run(ctx, map[string]string{listenAddr: localhost + ":0", stateDir: dir}, out) + status := run(ctx, map[string]string{ + listenAddr: localhost + ":0", stateDir: dir, rulesDir: t.TempDir(), + }, out) if status != 1 { t.Fatalf("exit status %d, want 1", status) } @@ -362,6 +433,30 @@ func wantStartRefused(t *testing.T, dir, want string) { } } +// wantRulesRefused runs smallwebwaf with its rule files in dir, and +// checks that it stops at start, with the error want. If it starts +// instead, it is stopped after waitLimit. +func wantRulesRefused(t *testing.T, dir, want string) { + t.Helper() + + ctx, stop := context.WithTimeout(t.Context(), waitLimit) + defer stop() + + out := &output{} + + status := run(ctx, map[string]string{ + listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: dir, + }, out) + if status != 1 { + t.Fatalf("exit status %d, want 1", status) + } + + line := out.line(t, "msg", "cannot use the rule files") + if line["error"] != want || line["level"] != "ERROR" { + t.Errorf("start refused with %v, want the error %q", line, want) + } +} + // startApp starts an app that answers every request with greeting, and // returns its URL. func startApp(t *testing.T) string { @@ -443,8 +538,10 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) { "SWWAF_LIMIT_BAN_DURATION": "1h", "SWWAF_LIMIT_BAN_REPEAT_WINDOW": "24h", "SWWAF_MAX_BAN_DURATION": "7d", + "SWWAF_ATTACK_BAN_DURATION": "7d", "SWWAF_MAX_BANS": "5000", "SWWAF_BAN_SCOPE_V4_PREFIX": "32", + "SWWAF_RULES_ENABLED": "true", } for name, value := range want { diff --git a/internal/state/state.go b/internal/state/state.go index 35551cb..930b09a 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -48,6 +48,7 @@ var ( errVersion = errors.New("unknown version") // errMissing is for an entry without a field it needs. errMissing = errors.New("has no") + errCause = errors.New("is not limit or attack") ) // Params are what Load needs. @@ -95,11 +96,12 @@ type bansFile struct { } // banEntry is a ban as bans.json holds it: a permanent ban's expires is -// null. +// null, and a ban an admin added may have no cause. type banEntry struct { Netblock netip.Prefix `json:"netblock"` Start time.Time `json:"start"` Expires *time.Time `json:"expires"` + Cause string `json:"cause,omitempty"` Notes bans.Notes `json:"notes"` } @@ -444,7 +446,9 @@ func (f *Files) encode(name string) ([]byte, error) { // newBanEntry returns ban as bans.json holds it. func newBanEntry(ban bans.Ban) banEntry { - entry := banEntry{Netblock: ban.Netblock, Start: ban.Start, Notes: ban.Notes} + entry := banEntry{ + Netblock: ban.Netblock, Start: ban.Start, Cause: ban.Cause, Notes: ban.Notes, + } if !ban.Permanent() { entry.Expires = &ban.Expires } @@ -454,7 +458,7 @@ func newBanEntry(ban bans.Ban) banEntry { // ban returns the ban an entry of bans.json holds. func (e banEntry) ban() bans.Ban { - ban := bans.Ban{Netblock: e.Netblock, Start: e.Start, Notes: e.Notes} + ban := bans.Ban{Netblock: e.Netblock, Start: e.Start, Cause: e.Cause, Notes: e.Notes} if e.Expires != nil { ban.Expires = *e.Expires } @@ -466,7 +470,8 @@ func (e banEntry) ban() bans.Ban { // client, a start, from which the length of the netblock's next ban is // worked out, or an expires, which would make it permanent. A permanent // ban's expires is null, which Bans cannot tell from a missing one, so -// each expires is read again as written. +// each expires is read again as written. A cause other than limit or +// attack, most likely misspelt, is refused too. func (f *bansFile) check(data []byte) error { var written struct { Bans []struct { @@ -487,6 +492,9 @@ func (f *bansFile) check(data []byte) error { return missing(i, "start") case written.Bans[i].Expires == nil: return missing(i, "expires") + case entry.Cause != "" && entry.Cause != bans.CauseLimit && + entry.Cause != bans.CauseAttack: + return fmt.Errorf("entry %d's cause %q %w", i+1, entry.Cause, errCause) } } diff --git a/internal/state/state_test.go b/internal/state/state_test.go index b508b96..f29c65e 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -63,7 +63,11 @@ const permanentBansJSON = `{ }, "requests": 1500, "refused": 3, - "earlier_bans": 5 + "earlier_bans": { + "limit": 3, + "attack": 1, + "without_cause": 1 + } } } ] @@ -263,6 +267,17 @@ func TestEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) { } } +func TestBanWithACauseSmallwebwafDoesNotGiveStopsTheStart(t *testing.T) { + t.Parallel() + + wantRefused(t, bansJSON, `{"version": 1, "bans": [`+ + `{"netblock": "203.0.113.9/32", "start": "2026-10-06T00:00:00Z", `+ + `"expires": null, "cause": "attack"}, `+ + `{"netblock": "203.0.113.10/32", "start": "2026-10-06T00:00:00Z", `+ + `"expires": null, "cause": "atack"}]}`, + `: entry 2's cause "atack" is not limit or attack`) +} + func TestUnknownVersionStopsTheStart(t *testing.T) { t.Parallel() @@ -881,6 +896,7 @@ func newParams(dir string) state.Params { LimitBanDuration: time.Hour, LimitBanRepeatWindow: 24 * time.Hour, MaxBanDuration: 7 * 24 * time.Hour, + AttackBanDuration: 7 * 24 * time.Hour, MaxBans: 5000, }), Limiter: ratelimit.New(ratelimit.Limits{}), @@ -893,14 +909,17 @@ func newParams(dir string) state.Params { } } -// fill puts a ban that ends and one that does not, clients with counts -// and histories, and GeoJS answers into the parts of params. +// fill puts a permanent ban without a cause, as an admin adds one, a ban +// for a broken limit and one for a clear sign of attack, clients with +// counts and histories, and GeoJS answers into the parts of params. func fill(params state.Params) { now := midnight() client := netip.MustParsePrefix("203.0.113.9/32") params.Ledger.Load([]bans.Ban{permanentBan()}) params.Ledger.BanForLimit(client, now, bans.Notes{Country: "DE", Limit: 1}) + params.Ledger.BanForAttack(netip.MustParsePrefix("192.0.2.1/32"), now, + bans.Notes{RuleID: "env-file", Target: "path"}) for _, c := range []string{"2001:db8::/64", "203.0.113.9/32", "192.0.2.1/32"} { params.Limiter.Count(netip.MustParsePrefix(c), now) @@ -939,7 +958,7 @@ func permanentBan() bans.Ban { }, Requests: 1500, Refused: 3, - EarlierBans: 5, + EarlierBans: bans.EarlierBans{Limit: 3, Attack: 1, WithoutCause: 1}, }, } } diff --git a/script/example-app b/script/example-app index b07236b..f2249a8 100755 --- a/script/example-app +++ b/script/example-app @@ -3,9 +3,10 @@ # deploy/example-app, then run the app's container with a volume for the # state files and check that the health check passes, that a request is # served through smallwebwaf, that a second one in a minute bans the -# client, that `sv stop` stops smallwebwaf in order, that `docker stop` -# stops the container without having to kill it, and that a new -# container on the same volume still refuses the banned client. The +# client, that a probe for /.env bans another client, which its next +# request bans for good, that `sv stop` stops smallwebwaf in order, that +# `docker stop` stops the container without having to kill it, and that +# a new container on the same volume still refuses the banned client. The # containers, the volume and both images are removed however the script # ends. Building the app needs network access, for nixpkgs' binary cache. # script/check does not run this. @@ -52,9 +53,13 @@ healthy() { [ "$status" = healthy ] } -# logged : the container's output holds text. +# logged ...: a line of the container's output holds every text, +# in any order. logged() { - docker logs "$CONTAINER" 2>&1 | grep -qF "$1" + lines="$(docker logs "$CONTAINER" 2>&1)" + for text in "$@"; do + lines="$(printf '%s\n' "$lines" | grep -F "$text")" || return 1 + done } # start_container: run the app's container, with the state files on the @@ -77,6 +82,15 @@ refused() { [ "$code" = 403 ] } +# refused_from : a request for path from client, as +# X-Forwarded-For names it, gets 403. smallwebwaf believes the header +# from docker's gateway, a private address. +refused_from() { + code="$(curl --silent --output /dev/null --write-out '%{http_code}' \ + --max-time 10 --header "X-Forwarded-For: $1" "http://$address$2")" || true + [ "$code" = 403 ] +} + main() { cd "$ROOT" trap cleanup EXIT @@ -100,6 +114,14 @@ main() { wait_for "smallwebwaf logged no ban" logged '"action":"rate_limited"' echo "example-app: a second request in a minute bans the client" + refused_from 203.0.113.9 /.env || fail "a probe for /.env was not refused" + wait_for "smallwebwaf logged no ban for the probe" \ + logged '"action":"banned"' '"rule_ids":["env-file"]' + refused_from 203.0.113.9 / || fail "the client of the probe was let through" + wait_for "the client's next request did not make its ban permanent" \ + logged '"ban_expires":"permanent"' + echo "example-app: a probe for /.env bans the client, its next request for good" + docker exec "$CONTAINER" sv stop smallwebwaf >/dev/null || fail "sv stop smallwebwaf failed" wait_for "smallwebwaf did not stop in order" logged '"msg":"stopped"' diff --git a/script/run b/script/run index b0cd063..43b5793 100755 --- a/script/run +++ b/script/run @@ -1,7 +1,9 @@ #!/bin/sh # script/run: build bin/smallwebwaf with script/build and run it, with # the settings in the environment. Unless SWWAF_STATE_DIR is set, the -# state files go in bin/state, beside the binary. +# state files go in bin/state, beside the binary, and unless +# SWWAF_RULES_DIR is set, the rule files are those of share/rules.d, +# which the image ships. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -14,6 +16,10 @@ main() { export SWWAF_STATE_DIR mkdir -p "$SWWAF_STATE_DIR" fi + if [ -z "${SWWAF_RULES_DIR+set}" ]; then + SWWAF_RULES_DIR="$ROOT/share/rules.d" + export SWWAF_RULES_DIR + fi exec "$ROOT/bin/smallwebwaf" } diff --git a/share/rules.d/00-default.rules b/share/rules.d/00-default.rules new file mode 100644 index 0000000..4ff626e --- /dev/null +++ b/share/rules.d/00-default.rules @@ -0,0 +1,15 @@ +# 00-default.rules: probes no real visitor sends, anchored at the site root + +# id target action regex +env-file path ban (?i)^/\.env(\.[a-z]+)?$ +vcs-dir path ban (?i)^/\.(git|svn|hg|bzr)(/|$) +secrets-dir path ban (?i)^/\.(aws|ssh|docker|kube)/ +secret-file path ban (?i)^/\.(htpasswd|htaccess|npmrc|netrc|pgpass|git-credentials|bash_history|DS_Store)$ +editor-dir path ban (?i)^/\.(vscode|idea)/ +backup-file path ban (?i)^/[^/]+\.(php(\.[a-z0-9]+|~)|sql(\.[a-z0-9]+)?)$ +log-file path ban (?i)^/(debug|error|access)\.log$ +compose-file path ban (?i)^/(docker-)?compose\.ya?ml$ +php-shell path ban (?i)^/(shell|c99|r57|wso|alfa)\.php$ +scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|masscan|zgrab|wpscan)\b +path-traversal uri block (\.\./){2,} +empty-agent user_agent log ^$