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

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

Model: opus-5-5
2026-11-09 02:32:38 +00:00
83 changed files with 1465 additions and 2381 deletions

No files matched your search

+44 -56
View File
@@ -12,8 +12,8 @@ configured only by environment variables, that provides:
- R1: rate limits per minute, per hour and per day - R1: rate limits per minute, per hour and per day
- R2: netblocks that bypass rate limiting - R2: netblocks that bypass rate limiting
- R3: alerts on attacks through webhook, Slack or ntfy - R3: alerts on attacks through webhook, Slack or ntfy
- R4: alerts on anomalies: requests made or bytes transferred per minute or - R4: alerts on anomalies: requests or bytes per minute or hour, for given IPs
hour, for given IPs or netblocks, crossing a threshold or netblocks, crossing a threshold
- R5: temporary blocks for abusers, permanent bans for repeat offenders - R5: temporary blocks for abusers, permanent bans for repeat offenders
- R6: one or more RBLs or IP reputation APIs - R6: one or more RBLs or IP reputation APIs
- R7: AS number lookup - R7: AS number lookup
@@ -24,19 +24,14 @@ configured only by environment variables, that provides:
## Verdict ## Verdict
Nothing existing is suitable. No surveyed tool meets R8, which asks for limits Nothing existing is suitable. No surveyed tool meets R8 (scaling limits by AS
scaled by AS number or country. None meets R4's alerts on bytes transferred. The number or country) or the bytes half of R4 at all, and the tools that come
tools that come closest on the rest fail R10: they need several containers, YAML closest on the rest fail R10: they need several containers, YAML or a web UI.
or a web UI. The recommendation is to build a small single-binary sidecar; The recommendation is to build a small single-binary sidecar; `SPEC.md` in this
`SPEC.md` in this directory begins that spec. directory begins that spec. It reuses the mature parts of the surveyed field as
libraries and data sources (Coraza and the OWASP Core Rule Set for attack
The sidecar reuses the mature parts of the surveyed field instead of detection, DNSBLs, AbuseIPDB, optionally a CrowdSec decision feed) instead of
reimplementing them. It takes these as libraries and data sources: reimplementing them.
- Coraza and the OWASP Core Rule Set, for attack detection
- DNSBLs
- AbuseIPDB
- optionally, a CrowdSec decision feed
Closest existing options, and why each still falls short: Closest existing options, and why each still falls short:
@@ -50,16 +45,13 @@ Closest existing options, and why each still falls short:
external blacklist URLs, optional CrowdSec), R7 partly (AS number used for external blacklist URLs, optional CrowdSec), R7 partly (AS number used for
blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule
Set, or Coraza plugin), env-var settings. Set, or Coraza plugin), env-var settings.
- Fails: - Fails: R8 (AS number and country can only allow or deny, never scale a
- R8: AS number and country can only allow or deny, never scale a limit. limit), R4 (no volume or byte threshold alerts in the free edition;
- R4: no alerts on request-count or byte thresholds in the free edition; reporting is a paid feature), R5 escalation, and R10 in spirit: since 1.6
reporting is a paid feature. it needs a `bunkerweb` container plus a `bw-scheduler` container and a
- R5: no escalation for repeat offenders. database, or the all-in-one image that bundles nginx, scheduler, UI and
- R10: since 1.6 it is not one plain sidecar. It needs a `bunkerweb` Redis in one container. It is designed to be the front door for many
container, a `bw-scheduler` container and a database, or the sites, not a per-app sidecar. AGPL-3.0.
all-in-one image that bundles nginx, the scheduler, the UI and Redis.
- It is designed as the proxy in front of many sites, not as a sidecar for
one app. AGPL-3.0.
- Unverified: whether several rates (minute, hour, day) can be stacked on - Unverified: whether several rates (minute, hour, day) can be stacked on
the same URL pattern; the documented design is one rate per URL pattern. the same URL pattern; the documented design is one rate per URL pattern.
- CrowdSec is the strongest detection and ban engine, but it is not a proxy. - CrowdSec is the strongest detection and ban engine, but it is not a proxy.
@@ -74,20 +66,16 @@ Closest existing options, and why each still falls short:
listed AS numbers is possible, but each is a hand-written YAML scenario, listed AS numbers is possible, but each is a hand-written YAML scenario,
it reacts after the fact by banning, and it is not an inline limiter that it reacts after the fact by banning, and it is not an inline limiter that
answers 429. answers 429.
- Fails: - Fails: R10 (needs the security engine container with persistent state, log
- R10, because it needs: acquisition from traefik, a bouncer such as the traefik plugin, and YAML
- the security engine container with persistent state for acquisition, profiles, scenarios and notifications), bytes half of R4.
- log acquisition from traefik
- a bouncer such as the traefik plugin
- YAML for acquisition, profiles, scenarios and notifications
- R4's alerts on bytes.
- CrowdSec plus traefik's own `rateLimit` middleware is the best combination - CrowdSec plus traefik's own `rateLimit` middleware is the best combination
with no new code. It gives inline limiting (one window per middleware, keyed with no new code. It gives inline limiting (one window per middleware, keyed
by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and
reputation lookups. It still fails R8, R4's alerts on bytes, and R10 entirely: reputation. It still fails R8, the bytes half of R4, R1's three stacked
the configuration lives in traefik labels and CrowdSec YAML on each host, not windows only by chaining three middlewares per router, and R10 entirely: the
in one sidecar's environment. It meets R1's three windows only by chaining configuration lives in traefik labels and CrowdSec YAML on each host, not in
three middlewares per router. one sidecar's environment.
## Candidate by candidate ## Candidate by candidate
@@ -99,10 +87,10 @@ Closest existing options, and why each still falls short:
cached, streamed decision list (recommended), standalone against the cached, streamed decision list (recommended), standalone against the
central API, or AppSec only. Supports captcha remediation. central API, or AppSec only. Supports captcha remediation.
- Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict). - Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict).
- Misses: the plugin itself does no rate limiting; R8; R4's alerts on bytes. - Misses: the plugin itself does no rate limiting; R8; R4 bytes.
- Configuration: traefik static config to load the plugin, dynamic config or - Configuration: traefik static config to load the plugin, dynamic config or
labels for the middleware, CrowdSec YAML for everything else. labels for the middleware, CrowdSec YAML for everything else.
- Sidecar fit: no. It runs inside traefik and needs a separate engine - Sidecar fit: no. It lives inside traefik, plus a separate engine
container. container.
- Maturity: widely used (about 900 stars, listed in the traefik plugin - Maturity: widely used (about 900 stars, listed in the traefik plugin
catalog, documented by CrowdSec itself), actively maintained. Traefik catalog, documented by CrowdSec itself), actively maintained. Traefik
@@ -127,7 +115,7 @@ Closest existing options, and why each still falls short:
- What it is: nginx-derived (Tengine) reverse proxy with a proprietary - What it is: nginx-derived (Tengine) reverse proxy with a proprietary
semantic detection engine and a web console. About 22,000 stars. semantic detection engine and a web console. About 22,000 stars.
- Covers: R9 well, basic per-IP rate limiting with block duration, bot - Covers: R9 well, basic per-IP rate limiting with block duration, bot
challenge, authentication in front of the app. challenge, authentication gate.
- Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert - Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert
messages, syslog forwarding and the better reputation database are paid messages, syslog forwarding and the better reputation database are paid
(Lite about 10 USD per month, Pro about 100 USD per month); the free (Lite about 10 USD per month, Pro about 100 USD per month); the free
@@ -140,7 +128,7 @@ Closest existing options, and why each still falls short:
- Coraza (`corazawaf/coraza`) and Coraza-based proxies - Coraza (`corazawaf/coraza`) and Coraza-based proxies
- What it is: a Go library that implements the ModSecurity rule language and - What it is: a Go library that implements the ModSecurity rule language and
runs the OWASP Core Rule Set. OWASP project, actively maintained, the runs the OWASP Core Rule Set. OWASP project, actively maintained, the
successor to ModSecurity, which is now in maintenance only. successor path now that ModSecurity is in maintenance only.
- Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy), - Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy),
`coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and `coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and
`coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set, `coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set,
@@ -154,7 +142,7 @@ Closest existing options, and why each still falls short:
- ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`) - ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`)
- What it is: official images of Apache or nginx with ModSecurity and the - What it is: official images of Apache or nginx with ModSecurity and the
Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`, Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`,
paranoia level, anomaly thresholds, many more). Current images contain paranoia level, anomaly thresholds, many more). Current images carry
ModSecurity 2.9.x on Apache and Core Rule Set 4.x. ModSecurity 2.9.x on Apache and Core Rule Set 4.x.
- Covers: R9, and R10 (single container, env vars, one backend). - Covers: R9, and R10 (single container, env vars, one backend).
- Misses: R1 to R8. - Misses: R1 to R8.
@@ -164,8 +152,8 @@ Closest existing options, and why each still falls short:
- Anubis (`TecharoHQ/anubis`), 1.27 current - Anubis (`TecharoHQ/anubis`), 1.27 current
- What it is: a single-binary reverse proxy that makes browsers solve a - What it is: a single-binary reverse proxy that makes browsers solve a
proof-of-work challenge before passing them to `TARGET`. Aimed at proof-of-work challenge before passing them to `TARGET`. Aimed at
scrapers, which are likely a large share of the unwanted traffic on a scrapers, which is likely a large share of unwanted traffic on a public
public code forge. code forge.
- Covers: R10 well (one container, env vars for listener, target, - Covers: R10 well (one container, env vars for listener, target,
difficulty, cookies). Policy rules can match on path, user agent, headers, difficulty, cookies). Policy rules can match on path, user agent, headers,
IP ranges, and with the vendor's hosted data service also AS number and IP ranges, and with the vendor's hosted data service also AS number and
@@ -192,13 +180,13 @@ Closest existing options, and why each still falls short:
temporary ban, no escalation, state lost on traefik restart) and R2 temporary ban, no escalation, state lost on traefik restart) and R2
(allowlist). Nothing else. (allowlist). Nothing else.
- `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock - `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock
plugins (country allow or deny through an external lookup API): each adds plugins (country allow or deny through an external lookup API): same
one small feature, is configured in traefik and keeps its state in memory. shape, each one small feature, configured in traefik, state in memory.
- Sidecar fit: no; they live inside traefik. - Sidecar fit: no; they live inside traefik.
- Traefik built-in middlewares - Traefik built-in middlewares
- `rateLimit` (one average-and-burst window per middleware, optional Redis - `rateLimit` (one average-and-burst window per middleware, optional Redis
in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation
lookups or AS number lookups. or AS number awareness.
- caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x - caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x
- What it is: a Caddy module with regex rules and anomaly scoring, per-IP - What it is: a Caddy module with regex rules and anomaly scoring, per-IP
and per-path rate limiting with one configurable window, IP and DNS and per-path rate limiting with one configurable window, IP and DNS
@@ -210,12 +198,12 @@ Closest existing options, and why each still falls short:
- open-appsec (Check Point) - open-appsec (Check Point)
- Machine-learning WAF agent attached to nginx, Kong, Envoy or similar, with - Machine-learning WAF agent attached to nginx, Kong, Envoy or similar, with
a declarative policy file or the vendor's cloud console. Covers R9 only; a declarative policy file or the vendor's cloud console. Covers R9 only;
rate limiting and richer features are in paid tiers. It attaches to an rate limiting and richer features are in paid tiers. Not a sidecar in the
existing proxy, so it is not the sidecar R10 asks for. required sense.
- iocaine - iocaine
- Serves generated garbage pages to clients the fronting proxy classifies as - Serves generated garbage pages to clients the fronting proxy classifies as
scrapers. Not a limiter, WAF or ban tool; out of scope, and only of scrapers. Not a limiter, WAF or ban tool; out of scope except as a
passing interest for scraper traffic. curiosity for scraper traffic.
- Pangolin - Pangolin
- A tunnelled access platform that bundles traefik and optionally CrowdSec. - A tunnelled access platform that bundles traefik and optionally CrowdSec.
Replaces the ingress rather than adding a sidecar; out of scope. Replaces the ingress rather than adding a sidecar; out of scope.
@@ -226,17 +214,17 @@ Closest existing options, and why each still falls short:
Everyone else offers one window per rule or none. Everyone else offers one window per rule or none.
- R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins. - R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins.
- R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid). - R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid).
- R4 alerts on request counts and bytes: none. CrowdSec can approximate alerts - R4 volume and byte anomalies: none. CrowdSec can approximate request-count
on request counts with a custom scenario; no surveyed tool alerts on bytes. anomalies with a custom scenario; nothing handles bytes.
- R5 temporary then permanent: CrowdSec only. BunkerWeb's ban is either one - R5 temporary then permanent: CrowdSec only. BunkerWeb has fixed or permanent,
fixed length or permanent, and does not change with a client's history. not both by history.
- R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists). - R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists).
- R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or - R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or
deny), Anubis (hosted service). deny), Anubis (hosted service).
- R8 biased thresholds: none. - R8 biased thresholds: none.
- R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open, - R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open,
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps Coraza standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps the
or ModSecurity). first).
- R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis. - R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis.
None of these covers anything beyond its one job. None of these covers anything beyond its one job.
+48 -52
View File
@@ -25,11 +25,11 @@ without an answer in time, `rproxy` passes the request to the app.
It protects against: It protects against:
- Scrapers that fetch every page of a site, all day. Each client gets a limit on - Scrapers that fetch every page of a site, all day. Each client gets a limit on
requests and on body bytes per minute, per hour and per day. The defaults requests and on bytes per minute, per hour and per day. The defaults leave
leave room for a person reading a site for hours. They catch a scraper that room for a person reading a site for hours. They catch a scraper that keeps up
keeps up one request every 2 seconds. A client that breaks a limit is banned: one request every 2 seconds. A client over a limit is refused and banned: for
for a while at first, for three times as long at each repeat, and permanently a while at first, for three times as long at each repeat, and permanently when
when it keeps at it. it keeps at it.
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The - Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
default rule file bans such a client at its first probe, and its next request default rule file bans such a client at its first probe, and its next request
during the ban makes the ban permanent. Your own rules, or a list of trap during the ban makes the ban permanent. Your own rules, or a list of trap
@@ -173,7 +173,7 @@ does.
the request have `"action":"forward"`. the request have `"action":"forward"`.
- To see a refusal without banning yourself, run `discriminator` with - To see a refusal without banning yourself, run `discriminator` with
`SWW_MODE=observe` and ask for `/.env`. The request reaches the app. `SWW_MODE=observe` and ask for `/.env`. The request reaches the app.
`discriminator`'s log lines have `"would_action":"temporarily_banned"` and `discriminator`'s log lines have `"would_action":"banned"` and
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as `"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
"Clients and bans" below describes. "Clients and bans" below describes.
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on - With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
@@ -210,10 +210,6 @@ account, a key, a token or an alert destination stays off until you give one.
percentage is a whole number from 0 to 100. A path prefix starts with `/`. percentage is a whole number from 0 to 100. A path prefix starts with `/`.
- `off` switches a limit, a threshold or a timeout off where the table says a - `off` switches a limit, a threshold or a timeout off where the table says a
setting takes it. setting takes it.
- The byte limits and the byte anomaly thresholds count only body bytes: the
app's response bodies, the visitors' request bodies, or both, as
`SWW_BYTES_COUNT` says. Headers are never counted. What the visitor sent over
a WebSocket counts as request bytes, and what the app sent as response bytes.
- An invalid setting stops the process at start with one log line naming the - An invalid setting stops the process at start with one log line naming the
variable and what is wrong with the value. The settings in effect are logged variable and what is wrong with the value. The settings in effect are logged
on one line at start, with tokens, keys and webhook paths shown as `********`. on one line at start, with tokens, keys and webhook paths shown as `********`.
@@ -221,7 +217,7 @@ account, a key, a token or an alert destination stays off until you give one.
Every setting the code reads, with its default: Every setting the code reads, with its default:
| Variable | Default | What it does | | Variable | Default | What it does |
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. | | `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. | | `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. | | `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
@@ -230,8 +226,8 @@ Every setting the code reads, with its default:
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. | | `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. | | `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. | | `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. | | `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. |
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. | | `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. | | `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. | | `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. | | `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
@@ -240,24 +236,24 @@ Every setting the code reads, with its default:
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. | | `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. | | `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. | | `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. | | `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. | | `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. | | `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. | | `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. | | `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. | | `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | | `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | | `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | | `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
| `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. | | `SWW_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. | | `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. | | `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. | | `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. | | `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. | | `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. | | `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. | | `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. | | `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. |
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. | | `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. | | `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. | | `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
@@ -265,7 +261,6 @@ Every setting the code reads, with its default:
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. | | `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. | | `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. | | `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
| `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. | | `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. | | `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. | | `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
@@ -280,6 +275,7 @@ Every setting the code reads, with its default:
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. | | `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. | | `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. | | `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWW_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;cloud.example.com:gitea`; only those apps' fixes apply there. Unset, every app's fixes apply to every hostname. |
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. | | `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. | | `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. | | `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
@@ -311,32 +307,32 @@ Every setting the code reads, with its default:
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. | | `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. | | `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. | | `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. | | `SWW_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. | | `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. | | `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. | | `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. | | `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. | | `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. | | `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. |
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. | | `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. | | `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. | | `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. | | `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. |
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. | | `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. | | `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. | | `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. | | `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. | | `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. | | `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. | | `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. | | `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. | | `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. | | `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. |
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. | | `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. | | `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. | | `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. | | `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. | | `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. |
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in [`docs/settings.md`](docs/settings.md) gives the values each setting takes in
full, and the limits that are fixed rather than settings. full, and the limits that are fixed rather than settings.
@@ -469,7 +465,7 @@ services:
# Alerts to an ntfy topic, with its token read from a file. # Alerts to an ntfy topic, with its token read from a file.
SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
SWW_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error SWW_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
volumes: volumes:
- discriminator-state:/var/lib/sww - discriminator-state:/var/lib/sww
- /srv/ipinfo:/var/lib/ipinfo:ro - /srv/ipinfo:/var/lib/ipinfo:ro
@@ -532,9 +528,8 @@ of the request reaches the app.
17. The request is passed to the app, streaming. 17. The request is passed to the app, streaming.
18. After the answer, `rproxy` sends `discriminator` a report with the status 18. After the answer, `rproxy` sends `discriminator` a report with the status
and the body bytes each way. For an allowed request, `discriminator` counts and the bytes each way. For an allowed request, `discriminator` counts the
the bytes `SWW_BYTES_COUNT` names toward the byte limits, bans the client if bytes toward the byte limits and adds the request to the client's history.
they take it past one, and adds the request to the client's history.
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
@@ -568,9 +563,7 @@ the `tags` field of its log line:
or `vpn=mullvad`; or `vpn=mullvad`;
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists - `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
the client; the client;
- `exploitsignature`, when a rule file or the Core Rule Set matched the request; - `exploitsignature`, when a rule file or the Core Rule Set matched the request.
- `asn=<number>` and `prefix=<netblock>`, from a routewatch instance, while
`SWW_ROUTEWATCH_URL` is set.
`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a `SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a
client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md). client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
@@ -581,11 +574,15 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
front of a code forge, bodies carry what people write and publish, which the front of a code forge, bodies carry what people write and publish, which the
rules take for attacks. rules take for attacks.
- `smallwebwaf` changes the Core Rule Set in eight ways so that it lets ordinary - `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary
requests to a code forge through, such as git over HTTP and sign-in through requests to a code forge through, such as git over HTTP and sign-in through
Git Credential Manager. On gitea's own paths, its searches, file names and Git Credential Manager. No setting undoes the changes; "Attack detection" in
branch names get through too. No setting undoes the changes; "Attack [`SPEC.md`](SPEC.md) lists them.
detection" in [`SPEC.md`](SPEC.md) lists them. - Each supported app also has its own fixes, which let its ordinary requests
through. gitea's let its searches, file names and branch names through.
`SWW_APPS` names the apps each hostname serves. A hostname gets only its own
apps' fixes, and a hostname it does not list gets none. Unset, every app's
fixes apply to every hostname.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own - `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
fields or paths. fields or paths.
- Rule files are plain text, one rule to a line, edited by hand and read again - Rule files are plain text, one rule to a line, edited by hand and read again
@@ -632,10 +629,9 @@ is set aside as `<name>.bad`, and never stops the running process (see
- `internal/rproxy`: the reverse proxy in front of one app. - `internal/rproxy`: the reverse proxy in front of one app.
- `internal/discriminator`: `discriminator`'s server, which runs the checks and - `internal/discriminator`: `discriminator`'s server, which runs the checks and
answers its own endpoints. answers its own endpoints.
- `internal/ratelimit`, `internal/bans`, `internal/rules`, - `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
`internal/coreruleset`, `internal/lookup`, `internal/reputation`, `internal/lookup`, `internal/reputation`, `internal/anomaly` and
`internal/routewatch`, `internal/anomaly` and `internal/alerts`: one check or `internal/alerts`: one check or source each.
source each.
- `internal/state`, `internal/requestlog`, `internal/remotelog` and - `internal/state`, `internal/requestlog`, `internal/remotelog` and
`internal/metrics`: the state files, the log lines, the syslog sender and the `internal/metrics`: the state files, the log lines, the syslog sender and the
metrics. metrics.
+102 -116
View File
@@ -22,8 +22,8 @@ to them.
key or a token stays off until the operator supplies it. key or a token stays off until the operator supplies it.
- It keeps a site answering while the part that decides is down, slow or - It keeps a site answering while the part that decides is down, slow or
restarting (see "Without an answer from `discriminator`"). restarting (see "Without an answer from `discriminator`").
- It limits each client's requests and body bytes per minute, hour and day. It - It limits each client's requests and bytes per minute, hour and day. It bounds
bounds the size and duration of every request and answer. It detects attacks. the size and duration of every request and answer. It detects attacks.
- It bans abusers: briefly at first, for seven days on a clear sign of attack, - It bans abusers: briefly at first, for seven days on a clear sign of attack,
and permanently when they keep at it. and permanently when they keep at it.
- It looks up each client's AS number and country. It can refuse whole countries - It looks up each client's AS number and country. It can refuse whole countries
@@ -81,14 +81,12 @@ to them.
of bans is full. of bans is full.
- `internal/rules`: reads the rule files at start and again when they - `internal/rules`: reads the rule files at start and again when they
change, and tells which rules a request matches. change, and tells which rules a request matches.
- `internal/coreruleset`: the Core Rule Set, with the eight changes listed - `internal/waf`: the Core Rule Set, with the six changes and the apps'
under "Attack detection", as Coraza directives. fixes listed under "Attack detection", as Coraza directives.
- `internal/lookup`: looks clients up through GeoJS or in the lookup - `internal/lookup`: looks clients up through GeoJS or in the lookup
database. database.
- `internal/reputation`: fetches the lists, asks the DNS blocklists and - `internal/reputation`: fetches the lists, asks the DNS blocklists and
AbuseIPDB in the background, and keeps their answers. AbuseIPDB in the background, and keeps their answers.
- `internal/routewatch`: asks a routewatch instance about clients in the
background, and keeps its answers.
- `internal/anomaly`: the anomaly counters and their alerts. - `internal/anomaly`: the anomaly counters and their alerts.
- `internal/alerts`: holds back repeats and alerts past the hourly limit, - `internal/alerts`: holds back repeats and alerts past the hourly limit,
and sends the rest to the webhook, Slack and ntfy, each from a queue of and sends the rest to the webhook, Slack and ntfy, each from a queue of
@@ -146,9 +144,7 @@ request log's `heuristic` field gives that name. `refusal_limit` and
around its address, a /64 by default, since one abuser usually controls a around its address, a /64 by default, since one abuser usually controls a
whole /64. A client is therefore one IPv4 address or one IPv6 group. whole /64. A client is therefore one IPv4 address or one IPv6 group.
3. The tag lists (`tag_lists`; see "Request tags"). Each list that holds the 3. The tag lists (`tag_lists`; see "Request tags"). Each list that holds the
client adds its tag. Next, `routewatch_tags` adds the client's `asn=` and client adds its tag. A client in `SWW_ALLOW_NETS` gets no tag.
`prefix=` tags while `SWW_ROUTEWATCH_URL` is set. A client in
`SWW_ALLOW_NETS` gets no tag.
4. The static lists. A client in `SWW_ALLOW_NETS` skips steps 5 to 15, the byte 4. The static lists. A client in `SWW_ALLOW_NETS` skips steps 5 to 15, the byte
limits and the error burst. A client in `SWW_DENY_NETS` is refused limits and the error burst. A client in `SWW_DENY_NETS` is refused
(`deny_nets`); a client in both lists is let through. (`deny_nets`); a client in both lists is let through.
@@ -196,12 +192,11 @@ request log's `heuristic` field gives that name. `refusal_limit` and
sent is removed. sent is removed.
18. After the visitor's answer has ended, `rproxy` sends the report (see "The 18. After the visitor's answer has ended, `rproxy` sends the report (see "The
report"). `discriminator` then counts: report"). `discriminator` then counts:
- For a request it allowed, the body bytes of the response, of the request - For a request it allowed, the response bytes, the request bytes or both
or of both are added to the byte counters, as `SWW_BYTES_COUNT` says. For are added to the byte counters, as `SWW_BYTES_COUNT` says. For a
a WebSocket, what it carried each way counts once it closes. A client WebSocket, what it carried each way counts once it closes. A client whose
whose total passes a byte limit times the percentage has broken that limit total passes a byte limit times the percentage has broken that limit and
and is banned (`byte_limits`). The request that broke it has already been is banned (`byte_limits`). The response in progress is not cut off.
passed on whole; nothing is cut off.
- For a request it allowed, the client's history and the anomaly counters - For a request it allowed, the client's history and the anomaly counters
are updated, and the alert thresholds are evaluated. are updated, and the alert thresholds are evaluated.
@@ -264,8 +259,7 @@ to a ban, each by its own rule:
clear sign of attack bans it permanently at once. clear sign of attack bans it permanently at once.
- A broken limit, one of: - A broken limit, one of:
- a request over a rate limit; - a request over a rate limit;
- the bytes counted for a request and its response taking the client's total - a response that takes the client's byte total past a byte limit;
past a byte limit;
- more than `SWW_ERROR_BURST_THRESHOLD` refusals within a minute. - more than `SWW_ERROR_BURST_THRESHOLD` refusals within a minute.
The bans for a broken limit: The bans for a broken limit:
@@ -321,13 +315,6 @@ ban is dropped alike. A scanner whose ban was dropped that way is refused and
banned again by its next probe. A ban whose cause is `admin` is never dropped banned again by its next probe. A ban whose cause is `admin` is never dropped
and does not count toward `SWW_MAX_BANS`, so such bans can never fill the table. and does not count toward `SWW_MAX_BANS`, so such bans can never fill the table.
A request refused under a ban that runs out is logged with the action
`temporarily_banned`. Under a permanent ban the action is `permanently_banned`.
A request refused for a clear sign of attack or a CrowdSec decision takes the
action for the ban it made. A request that makes a ban permanent is logged as
`permanently_banned`. A request over a rate limit is logged as
`client_is_rate_limited`, whichever ban it makes.
## Settings ## Settings
`docs/settings.md` lists every setting with its default and the values it takes, `docs/settings.md` lists every setting with its default and the values it takes,
@@ -461,15 +448,10 @@ the same way.
### Byte limits, per client ### Byte limits, per client
The byte limits count body bytes: the app's response bodies, the visitors' By default there is no minute limit, so a client can download in a burst at full
request bodies, or both, as `SWW_BYTES_COUNT` says. Headers are never counted. speed. The hour limit defaults to 50 GiB and the day limit to 100 GiB. They
What the visitor sent over a WebSocket counts as request bytes, and what the app allow several downloads of a few GiB each. A response's bytes are counted when
sent as response bytes. The bytes counted for a request and its response are it ends (see step 18).
added to the client's total once the response has ended (see step 18). So the
request that takes a client past a limit is passed on whole, and the ban refuses
the client's requests from then on. By default there is no minute limit, so a
client can download in a burst at full speed. The hour limit defaults to 50 GiB
and the day limit to 100 GiB. They allow several downloads of a few GiB each.
### Lookup of AS number and country ### Lookup of AS number and country
@@ -565,21 +547,6 @@ Each tag and where it comes from:
- `exploitsignature`, when a rule file or the Core Rule Set matched the request. - `exploitsignature`, when a rule file or the Core Rule Set matched the request.
For the Core Rule Set a match is a score at or over For the Core Rule Set a match is a score at or over
`SWW_WAF_ANOMALY_THRESHOLD`. `SWW_WAF_ANOMALY_THRESHOLD`.
- `asn=<number>` and `prefix=<netblock>` from a routewatch instance
(`routewatch_tags`), while `SWW_ROUTEWATCH_URL` is set:
- The tag `prefix=` holds the netblock the client's address is announced in,
such as `prefix=192.0.2.0/24`.
- An `asn=` tag has the same spelling as the lookup's. When routewatch and
the lookup give the same AS number, a request has one `asn=` tag. When
they differ, it has both.
- They are added before `deny_nets`, so the log line of a request it refused
has them.
- `discriminator` asks routewatch in the background and never waits for it.
An answer is kept for 1 hour, in memory only.
- After a query fails, routewatch is not asked for 1 minute.
- A request without these tags gives the reason in the log field
`routewatch_tags_missing` (see
[`docs/request-log.md`](docs/request-log.md)).
A client in `SWW_ALLOW_NETS` skips the checks and gets no tag. Every tag is in A client in `SWW_ALLOW_NETS` skips the checks and gets no tag. Every tag is in
lower case, except a country code, which is in capitals. `SWW_TAG_DENY` and lower case, except a country code, which is in capitals. `SWW_TAG_DENY` and
@@ -587,10 +554,9 @@ lower case, except a country code, which is in capitals. `SWW_TAG_DENY` and
Two settings act on tags: Two settings act on tags:
- `SWW_TAG_DENY` lists tags whose requests are refused with the action - `SWW_TAG_DENY` lists tags whose requests are refused with the action `denied`
`client_denied` (`tag_deny`). The check runs twice: just before the rate (`tag_deny`). The check runs twice: just before the rate limits, and again
limits, and again after the Core Rule Set, so that `exploitsignature` can after the Core Rule Set, so that `exploitsignature` can refuse too.
refuse too.
- `SWW_TAG_LIMIT_PERCENT` lists tags with a percentage from 1 to 99. Their - `SWW_TAG_LIMIT_PERCENT` lists tags with a percentage from 1 to 99. Their
clients get that percentage of every request and byte limit. The lowest clients get that percentage of every request and byte limit. The lowest
percentage among a client's tags applies. `exploitsignature` cannot be listed, percentage among a client's tags applies. `exploitsignature` cannot be listed,
@@ -598,9 +564,7 @@ Two settings act on tags:
The tag settings are empty by default, for the same reason as the country list. The tag settings are empty by default, for the same reason as the country list.
The lookup waits for GeoJS when either names an `asn=` or `country=` tag. A The lookup waits for GeoJS when either names an `asn=` or `country=` tag. A
setting that names one stops the start while `SWW_LOOKUP_SOURCE` is `off`. The setting that names one stops the start while `SWW_LOOKUP_SOURCE` is `off`.
exception is an `asn=` tag while `SWW_ROUTEWATCH_URL` is set, since routewatch
gives that tag.
### Biased thresholds ### Biased thresholds
@@ -655,7 +619,7 @@ files, the limits and the bans still apply to the client that sends it.
match, and scores are still logged. A request with more than 1000 query match, and scores are still logged. A request with more than 1000 query
parameters or body fields adds 5 to its score, since Coraza reads only the parameters or body fields adds 5 to its score, since Coraza reads only the
first 1000. For a match, in `block` and `detect` mode alike, the log line first 1000. For a match, in `block` and `detect` mode alike, the log line
gives the rule ids and the score, and a `core_rule_set_match` alert is sent. gives the rule ids and the score, and a `waf_block` alert is sent.
- `SWW_WAF_DISABLED_RULES` switches off rule ids when an app trips a false - `SWW_WAF_DISABLED_RULES` switches off rule ids when an app trips a false
positive. The default switches off six rules: positive. The default switches off six rules:
- 920340, 920420 and 920640 refuse a request for the type of its body, when - 920340, 920420 and 920640 refuse a request for the type of its body, when
@@ -674,7 +638,7 @@ files, the limits and the bans still apply to the client that sends it.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as - `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as
Coraza directives. An exclusion switches one rule off for some fields or paths Coraza directives. An exclusion switches one rule off for some fields or paths
only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file
holds an app's own exclusions; the eighth change below holds gitea's. holds an app's own exclusions; gitea's fixes below hold gitea's.
- `discriminator` loads the file after `smallwebwaf`'s own setup rules and - `discriminator` loads the file after `smallwebwaf`'s own setup rules and
before the Core Rule Set's rules. The Core Rule Set loads its own before the Core Rule Set's rules. The Core Rule Set loads its own
`REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point. `REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point.
@@ -720,7 +684,7 @@ files, the limits and the bans still apply to the client that sends it.
scanner from an ordinary client. In front of a code forge, registry and git scanner from an ordinary client. In front of a code forge, registry and git
clients get `404` and `401` by design, hundreds of times a minute. clients get `404` and `401` by design, hundreds of times a minute.
`smallwebwaf` changes the Core Rule Set 4.25.0 in eight ways, since in front of `smallwebwaf` changes the Core Rule Set 4.25.0 in six ways, since in front of
gitea it would otherwise refuse ordinary requests. The changes hold in front of gitea it would otherwise refuse ordinary requests. The changes hold in front of
every app, and no setting undoes them: every app, and no setting undoes them:
@@ -739,8 +703,52 @@ every app, and no setting undoes them:
and tea, which gitea registers for OAuth sign-in, ask to be sent back to and tea, which gitea registers for OAuth sign-in, ask to be sent back to
`http://127.0.0.1` on the user's own machine. The server never fetches that `http://127.0.0.1` on the user's own machine. The server never fetches that
address. address.
4. Ten query parameters are not checked against the Core Rule Set's lists of 4. `Referer` is not checked for a Unix command given without arguments (932340),
system files (930120), shell paths (932160) and command names (932260): for Java starting a process (944110) or for a Java class name (944130).
`Referer` names the page a request comes from, on everything that page loads
and on every link followed from it. The Core Rule Set takes some page
addresses for attacks:
- a search for `env`;
- OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`;
- a file named `java.lang.String.md`.
Every request from such a page would be refused. `Referer` keeps the other
rules, except after some gitea searches (gitea's fixes). An app that passes
`Referer` to a shell must check it itself.
5. Responses are not inspected. A raw file from a repository, such as a shell
script, looks to the response rules like source code leaking from the server.
6. The `from` parameter of a container registry's upload path,
`/v2/<image>/blobs/uploads/`, is not checked for command names (932260). When
a push reuses a layer, `from` names the image the layer comes from. An image
such as `owner/python3-app` would have that push refused.
Each supported app also has its own fixes, which let its ordinary requests
through. An app's fixes hold for the hostnames `SWW_APPS` lists the app for:
- `SWW_APPS` holds entries separated by semicolons. Each is a hostname, a colon,
then one or more apps separated by commas, for example
`git.example.com:gitea;code.example.com:gitea`.
- The request's `Host` is matched by exact name, without its port and without
regard to case. There are no wildcards.
- A hostname not listed gets no app's fixes, so every rule checks it in full.
Set empty, `SWW_APPS` lists no hostname.
- Unset, every app's fixes hold for every hostname. An app's fixes then weaken
the check of any other app that serves the same paths or fields.
- Any of these mistakes stops the start:
- an unknown app;
- a hostname that is not a DNS name, such as `*.example.com`;
- a hostname listed twice;
- an app listed twice for one hostname;
- an entry without a colon.
`gitea` is the only app supported. Each app's fixes are in one file, such as
`internal/waf/gitea.conf`, and one entry of `apps` in `internal/waf/waf.go`.
gitea's fixes are:
1. Ten query parameters are not checked against the Core Rule Set's lists of
system files (930120), shell paths (932160) and command names (932260), on
any path:
- `path`, `files`, `skip-to` and `sub_path`; - `path`, `files`, `skip-to` and `sub_path`;
- `ref`, `sha`, `branch` and `workflow`; - `ref`, `sha`, `branch` and `workflow`;
- `artifactName` and `redirect_to`. - `artifactName` and `redirect_to`.
@@ -753,44 +761,21 @@ every app, and no setting undoes them:
catch reaches the app, such as the command `|cat /etc/passwd`. Path catch reaches the app, such as the command `|cat /etc/passwd`. Path
traversal, SQL and script injection, and PHP, Java and Node.js code are traversal, SQL and script injection, and PHP, Java and Node.js code are
still refused there. Other parameters keep all three rules, except on the still refused there. Other parameters keep all three rules, except on the
gitea paths the eighth change names. An app that uses one of these gitea paths the third fix names.
parameters as a file on the server, or passes it to a shell, must check the
value itself. A rule file can refuse it instead.
5. Two cookies are left out, and `Referer` keeps fewer rules: 2. The Core Rule Set reads the request without the `gitea_flash` and
- The Core Rule Set reads the request without the `gitea_flash` and
`redirect_to` cookies. gitea puts a message naming what was just done into `redirect_to` cookies. gitea puts a message naming what was just done into
`gitea_flash`. It puts into `redirect_to` the page to return to after `gitea_flash`. It puts into `redirect_to` the page to return to after signing
signing in. The Core Rule Set takes file names in them, such as in. The Core Rule Set takes file names in them, such as `package.json`, for
`package.json`, for attacks. A browser sends such a cookie with every attacks. A browser sends such a cookie with every request until gitea
request until gitea replaces it. gitea cannot replace it while those replaces it. gitea cannot replace it while those requests are refused, so one
requests are refused, so one refusal would keep the browser out of the refusal would keep the browser out of the whole site. gitea shows the message
whole site. gitea shows the message with any script removed and returns with any script removed and returns only to its own pages. Every other cookie
only to its own pages. Every other cookie is read in full. An app that is read in full.
reads a cookie of either name must check it itself. 3. On gitea's paths, some fields skip the rules that refused ordinary gitea
- `Referer` is not checked for a Unix command given without arguments
(932340), for Java starting a process (944110) or for a Java class name
(944130). `Referer` names the page a request comes from, on everything
that page loads and on every link followed from it. The Core Rule Set
takes some page addresses for attacks:
- a search for `env`;
- OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`;
- a file named `java.lang.String.md`.
Every request from such a page would be refused. `Referer` keeps the
other rules, except after some gitea searches (the eighth change). An
app that passes `Referer` to a shell must check it itself.
6. Responses are not inspected. A raw file from a repository, such as a shell
script, looks to the response rules like source code leaking from the server.
7. The `from` parameter of a container registry's upload path,
`/v2/<image>/blobs/uploads/`, is not checked for command names (932260). When
a push reuses a layer, `from` names the image the layer comes from. An image
such as `owner/python3-app` would have that push refused.
8. On gitea's paths, some fields skip the rules that refused ordinary gitea
requests there. They are the fields where gitea sends a search, a file name requests there. They are the fields where gitea sends a search, a file name
or a branch name. `internal/coreruleset/gitea.conf` lists each rule and the or a branch name. `internal/waf/gitea.conf` lists each rule and the request
request it refused. The fields are: it refused. The fields are:
- `q` on gitea's searches of code, of issues and pull requests, and of - `q` on gitea's searches of code, of issues and pull requests, and of
commits. A code search holds code, such as `<script>`, commits. A code search holds code, such as `<script>`,
`SELECT * FROM users WHERE` or `../`. `SELECT * FROM users WHERE` or `../`.
@@ -805,8 +790,7 @@ every app, and no setting undoes them:
- packages. - packages.
A user's or an organisation's page is any path of one segment, so on A user's or an organisation's page is any path of one segment, so on
every such path, on any app, `q` skips these three rules, for example on every such path `q` skips these three rules, for example on `/search`.
`/search`.
Some of these searches also skip the rule for Java class names (944130), Some of these searches also skip the rule for Java class names (944130),
which refused `org.apache.commons.logging`. which refused `org.apache.commons.logging`.
@@ -844,11 +828,14 @@ every app, and no setting undoes them:
`actions/upload-artifact@v3` (920450). `actions/upload-artifact@v3` (920450).
Elsewhere these fields keep every rule. The paths are those of gitea at the Elsewhere these fields keep every rule. The paths are those of gitea at the
site root. An app other than gitea that serves one of them must check these site root.
fields itself.
The parameter and cookie names in these changes are matched without regard to On a hostname that gets gitea's fixes, an app other than gitea must check these
case, as Coraza matches them. fields and cookies itself. Listing each hostname's own apps in `SWW_APPS` keeps
the full check for the others.
The parameter names in these changes and fixes are matched without regard to
case, and the cookie names exactly.
### Ban lengths, response and scope ### Ban lengths, response and scope
@@ -989,8 +976,7 @@ which the metrics show. They reveal a widely distributed scraper, thousands of
addresses at low rates each, which the per-client limits do not see. A low addresses at low rates each, which the per-client limits do not see. A low
percentage for its `asn=` tag in `SWW_TAG_LIMIT_PERCENT` is the response. percentage for its `asn=` tag in `SWW_TAG_LIMIT_PERCENT` is the response.
- Requests and body bytes, as `SWW_BYTES_COUNT` counts them, are counted per - Requests and bytes are counted per minute and per hour, for each of:
minute and per hour, for each of:
- a client; - a client;
- the surrounding netblock; - the surrounding netblock;
- an AS number; - an AS number;
@@ -1300,9 +1286,9 @@ short.
### Answering in time ### Answering in time
`rproxy` stops waiting for `discriminator` after `SWRP_TIMEOUT_MS`, by default 2 `rproxy` stops waiting for `discriminator` after `SWRP_TIMEOUT_MS`, by default 2
seconds, and `SWRP_DEFAULT_DISPOSITION` then decides. The longest a question seconds, and `SWRP_DEFAULT_DISPOSITION` then decides. So at the defaults,
waits on an outside source is `SWW_LOOKUP_TIMEOUT`, by default 800 milliseconds, `discriminator` answers a request well within that time, whatever the request
well within that time. and whatever its outside sources do.
GeoJS is the only outside source a question can wait on. A question waits only GeoJS is the only outside source a question can wait on. A question waits only
when step 6 says so, and for at most `SWW_LOOKUP_TIMEOUT`, by default 800 when step 6 says so, and for at most `SWW_LOOKUP_TIMEOUT`, by default 800
@@ -1346,9 +1332,9 @@ over either one:
`Transfer-Encoding` do not count. `X-Request-ID` and `X-Forwarded-Proto` do. `Transfer-Encoding` do not count. `X-Request-ID` and `X-Forwarded-Proto` do.
Real clients never reach these limits. The answer is `deny` with `431`, and the Real clients never reach these limits. The answer is `deny` with `431`, and the
log line's action is `request_header_too_large`. The refusal makes no ban and is log line's action is `too_large`. The refusal makes no ban and is no offence.
no offence. The rate limits count the request as usual, so a client that keeps The rate limits count the request as usual, so a client that keeps sending such
sending such requests breaks a limit. requests breaks a limit.
The limits apply on every path and in every `SWW_WAF_MODE`. A client in The limits apply on every path and in every `SWW_WAF_MODE`. A client in
`SWW_ALLOW_NETS` skips them, as it skips the Core Rule Set. In `observe` mode `SWW_ALLOW_NETS` skips them, as it skips the Core Rule Set. In `observe` mode
@@ -1378,11 +1364,11 @@ these fields, named as the request log's fields of the same meaning:
- `request_bytes` and `response_bytes`: the body bytes each way, with what a - `request_bytes` and `response_bytes`: the body bytes each way, with what a
WebSocket carried each way, each a whole number from `0` to `1125899906842624` WebSocket carried each way, each a whole number from `0` to `1125899906842624`
(1 PiB); (1 PiB);
- `duration_total_ms`: milliseconds from the request's headers being read to the - `duration_total`: milliseconds from the request's headers being read to the
end of the answer, from `0` to `1000000000000` (about 31 years); end of the answer, from `0` to `1000000000000` (about 31 years);
- `duration_upstream_total_ms`: milliseconds from the hand-over to the app to - `duration_upstream_total`: milliseconds from the hand-over to the app to the
the end of the answer, in the same range, and `0` for a request not passed to end of the answer, in the same range, and `0` for a request not passed to the
the app. app.
`discriminator` answers `400` and writes a process warning when the body: `discriminator` answers `400` and writes a process warning when the body:
@@ -1398,9 +1384,9 @@ answer.
For a request it holds, `discriminator` writes the request's last log line (see For a request it holds, `discriminator` writes the request's last log line (see
"The log"). For a request it allowed, it first: "The log"). For a request it allowed, it first:
- counts the body bytes `SWW_BYTES_COUNT` names, toward the client's byte limits - counts the bytes as `SWW_BYTES_COUNT` says, toward the client's byte limits at
at the percentages it gave that request; the percentages it gave that request;
- bans a client whose total passes a byte limit; - bans a client that passes a byte limit;
- adds the request with its status to the client's history, the anomaly counters - adds the request with its status to the client's history, the anomaly counters
and the metrics. and the metrics.
+4 -4
View File
@@ -9,9 +9,9 @@ back or dropped.
- `ban`: a ban `discriminator` made. - `ban`: a ban `discriminator` made.
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by - `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
a request. a request.
- `core_rule_set_match`: the Core Rule Set scored a request at or over the - `waf_block`: the Core Rule Set scored a request at or over the threshold. The
threshold. The request was refused unless the alert's `detail` gives `mode`: request was refused unless the alert's `detail` gives `mode`: `detect` or
`detect` or `observe`. `observe`.
- `anomaly`: a count over an anomaly threshold. - `anomaly`: a count over an anomaly threshold.
- `reputation_hit`: a request from a client on a blocklist, the CrowdSec - `reputation_hit`: a request from a client on a blocklist, the CrowdSec
decision list or a DNS blocklist, or with an AbuseIPDB score that is a hit. decision list or a DNS blocklist, or with an AbuseIPDB score that is a hit.
@@ -130,7 +130,7 @@ The priority and tag by event:
- `ban`: priority `default`, tag `no_entry`. - `ban`: priority `default`, tag `no_entry`.
- `permanent_ban`: priority `high`, tag `no_entry`. - `permanent_ban`: priority `high`, tag `no_entry`.
- `core_rule_set_match`: priority `default`, tag `shield`. - `waf_block`: priority `default`, tag `shield`.
- `anomaly`: priority `high`, tag `chart_with_upwards_trend`. - `anomaly`: priority `high`, tag `chart_with_upwards_trend`.
- `reputation_hit`: priority `low`, tag `label`. - `reputation_hit`: priority `low`, tag `label`.
- `source_failure` and `file_error`: priority `high`, tag `warning`. - `source_failure` and `file_error`: priority `high`, tag `warning`.
+5 -9
View File
@@ -5,9 +5,6 @@ What `GET /_sww/metrics` answers with, at `discriminator`'s own address.
keeps the `instance` label as `exported_instance` unless the scrape sets keeps the `instance` label as `exported_instance` unless the scrape sets
`honor_labels: true`. `rproxy` has no metrics. `honor_labels: true`. `rproxy` has no metrics.
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
body bytes, never headers, whatever `SWW_BYTES_COUNT` says.
## Traffic ## Traffic
- `sww_requests_total`, `sww_request_bytes_total` and - `sww_requests_total`, `sww_request_bytes_total` and
@@ -28,8 +25,7 @@ body bytes, never headers, whatever `SWW_BYTES_COUNT` says.
## Attack detection ## Attack detection
- `sww_rule_matches_total`, by `rule_id` and `action`, and `sww_rules_loaded`; - `sww_rule_matches_total`, by `rule_id` and `action`, and `sww_rules_loaded`;
- `sww_core_rule_set_matches_total`, by `mode` and `rule_id`, for the rules that - `sww_waf_matches_total`, by `mode` and `rule_id`, for the rules that fired.
fired.
## Countries and AS numbers ## Countries and AS numbers
@@ -44,8 +40,8 @@ For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`:
## Lookups ## Lookups
- `sww_geojs_requests_total` and `sww_geojs_failures_total`; - `sww_geojs_requests_total` and `sww_geojs_failures_total`;
- `sww_requests_without_geojs_answer_total`: the requests that needed an answer - `sww_geojs_unanswered_total`: the requests that needed an answer and went on
and went on without it; without it;
- with the file source, `sww_lookup_database_last_read_timestamp_seconds` and - with the file source, `sww_lookup_database_last_read_timestamp_seconds` and
`sww_lookup_database_read_failures_total`. `sww_lookup_database_read_failures_total`.
@@ -71,8 +67,8 @@ By `file`:
## Remote log, alerts and the process ## Remote log, alerts and the process
- while a syslog server is set, `sww_remote_log_lines_sent_total`, - while a syslog server is set, `sww_remote_log_lines_sent_total`,
`sww_remote_log_lines_dropped_total` and `sww_remote_log_lines_waiting`; `sww_remote_log_lines_dropped_total` and `sww_remote_log_buffer_depth`;
- by `destination`, `sww_alerts_sent_total`, `sww_alert_send_failures_total`, - by `destination`, `sww_alerts_sent_total`, `sww_alerts_failed_total`,
`sww_alerts_suppressed_total` and `sww_alerts_dropped_total`; `sww_alerts_suppressed_total` and `sww_alerts_dropped_total`;
- Go's own `go_` metrics and the process's `process_` metrics. - Go's own `go_` metrics and the process's `process_` metrics.
+34 -45
View File
@@ -26,7 +26,7 @@ These questions write no line:
A `report` line: A `report` line:
``` ```
{"type":"request","discriminator_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/discriminator","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"core_rule_set_score":0,"duration_total_ms":3.217,"duration_checks_ms":0.041,"duration_core_rule_set_ms":0.029,"duration_upstream_total_ms":3.104} {"type":"request","discriminator_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/discriminator","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"waf_score":0,"duration_total":3.217,"duration_checks":0.041,"duration_waf":0.029,"duration_upstream_total":3.104}
``` ```
### Every line ### Every line
@@ -37,7 +37,7 @@ Every one of these lines has:
- the standard web log fields; - the standard web log fields;
- `request_id`, `peer_ip` and `client_group`; - `request_id`, `peer_ip` and `client_group`;
- `asn`, `as_name` and `country`; - `asn`, `as_name` and `country`;
- `action` and `duration_total_ms`. - `action` and `duration_total`.
### Standard web log fields ### Standard web log fields
@@ -96,17 +96,15 @@ Every one of these lines has:
- `action`: what was done: - `action`: what was done:
- `forward`: answered `allow`, so that `rproxy` passes it to the app; - `forward`: answered `allow`, so that `rproxy` passes it to the app;
- `client_denied`: refused for `SWW_DENY_NETS`, for a reputation source with - `denied`: refused for `SWW_DENY_NETS`, for a reputation source with the
the action `deny`, or for a tag in `SWW_TAG_DENY`; action `deny`, or for a tag in `SWW_TAG_DENY`;
- `temporarily_banned`: refused under a ban that runs out, or for a `ban` - `banned`: refused under a ban, or for a `ban` rule, a trap path or a
rule, a trap path or a CrowdSec decision that made such a ban; CrowdSec decision, each of which bans the client;
- `permanently_banned`: refused under a ban that never runs out, including a - `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
ban this request made or made permanent; refused by that check;
- `country_denied`, `client_is_rate_limited`, `blocked_by_block_rule` and - `too_large`: over a header limit;
`blocked_by_core_rule_set`: refused by that check; - `admin`: answered at one of `discriminator`'s own endpoints, or `404` for
- `request_header_too_large`: over a header limit; a path under `/_sww/`.
- `own_endpoint`: answered at one of `discriminator`'s own endpoints, or
`404` for a path under `/_sww/`.
- `would_action`: in `observe` mode, what `enforce` mode would have done with a - `would_action`: in `observe` mode, what `enforce` mode would have done with a
request it would have refused. `action` then names what was done, normally request it would have refused. `action` then names what was done, normally
`forward`. `forward`.
@@ -140,35 +138,32 @@ Every one of these lines has:
`byte_limits` bans when the report comes, so only the `report` line names `byte_limits` bans when the report comes, so only the `report` line names
it. it.
- `rate_limit_percent` and `rate_limit_percent_setting`: the client's percentage - `limit_percent` and `limit_percent_setting`: the client's percentage of the
of the rate limits and the setting that lowered it, when one did. rate limits and the setting that lowered it, when one did. `bytes_percent` and
`bytes_limit_percent` and `bytes_limit_percent_setting` do the same for the `bytes_percent_setting` do the same for the byte limits.
byte limits.
- `counts`: the client's totals as rate limiting counts them, this request - `counts`: the client's totals as rate limiting counts them, this request
included. It is left out when rate limiting did not count the request. It included. It is left out when rate limiting did not count the request. It
holds: holds:
- `minute`, `hour` and `day`: requests; - `minute`, `hour` and `day`: requests;
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes - `minute_bytes`, `hour_bytes` and `day_bytes`: bytes.
`SWW_BYTES_COUNT` names, in that window.
A count can have a fraction (see "Counting method" in A count can have a fraction (see "Counting method" in
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its [`SPEC.md`](../SPEC.md)). The bytes of an allowed request are counted when
response are added when its report comes. So on the `decision` line, the its report comes. So on the `decision` line, the bytes are without this
three byte totals leave them out. request's.
- `limit_hit`: the limit broken: - `limit_hit`: the limit broken:
- `minute`, `hour` or `day` for a rate limit; - `minute`, `hour` or `day` for a rate limit;
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit; - `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
- `minute_refusals` for the error burst (`SWW_ERROR_BURST_THRESHOLD`). - `error_burst`.
When the bytes counted for a request and its response break a limit, the A request whose bytes broke a limit was passed on whole, so its `action`
request has been passed on whole, so its `action` stays `forward`. Only its stays `forward`. Only its `report` line gives that limit.
`report` line gives that limit.
- `rule_ids`: the rule file rules that matched, in order, up to the one that - `rule_ids`: the rule file rules that matched, in order, up to the one that
refused. refused.
- `core_rule_set_score` and `core_rule_set_rule_ids`: the Core Rule Set's score - `waf_score` and `waf_rule_ids`: the Core Rule Set's score and the rules that
and the rules that matched, for a request it inspected. matched, for a request it inspected.
- `reputation`: the sources the client is on, in this order: - `reputation`: the sources the client is on, in this order:
- blocklist URLs; - blocklist URLs;
- the CrowdSec decision list's URL; - the CrowdSec decision list's URL;
@@ -176,12 +171,6 @@ Every one of these lines has:
- `abuseipdb`. - `abuseipdb`.
- `tags`: the request's tags, sorted, left out when it has none. Each tag and - `tags`: the request's tags, sorted, left out when it has none. Each tag and
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md). where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
- `routewatch_tags_missing`: why the request got no tags from routewatch,
present only while `SWW_ROUTEWATCH_URL` is set:
- `no_answer_yet`: routewatch has not answered about this client yet;
- `request_failed`: a query to routewatch failed within the last minute, and
the process log says why;
- `no_route`: routewatch knows no live route for the address.
- `offence`: `limit`, for a request that broke a limit. - `offence`: `limit`, for a request that broke a limit.
- `ban_expires`: when the client's ban ends, for a request that made one or was - `ban_expires`: when the client's ban ends, for a request that made one or was
refused under one: a time, or `permanent`. refused under one: a time, or `permanent`.
@@ -190,14 +179,14 @@ Every one of these lines has:
All in milliseconds: All in milliseconds:
- `duration_total_ms`: - `duration_total`:
- on the `decision` line, from `discriminator` having read the question's - on the `decision` line, from `discriminator` having read the question's
body to the line; body to the line;
- on the `report` line, from `rproxy` having read the request's headers to - on the `report` line, from `rproxy` having read the request's headers to
the end of the answer. the end of the answer.
- `duration_checks_ms`: to the end of the checks; - `duration_checks`: to the end of the checks;
- `duration_core_rule_set_ms`: the part of that the Core Rule Set took; - `duration_waf`: the part of that the Core Rule Set took;
- `duration_upstream_total_ms`: on the `report` line of an allowed request, from - `duration_upstream_total`: on the `report` line of an allowed request, from
the hand-over to the app to the end of the answer. the hand-over to the app to the end of the answer.
### Requests to its own endpoints ### Requests to its own endpoints
@@ -210,8 +199,8 @@ these meanings where they differ:
- `scheme`: `http`. - `scheme`: `http`.
- `time`: when the request's headers had been read. - `time`: when the request's headers had been read.
- `request_bytes`: the bytes read of a ban to add, and otherwise `0`. - `request_bytes`: the bytes read of a ban to add, and otherwise `0`.
- `action`: `own_endpoint`. - `action`: `admin`.
- `duration_total_ms`: from the request's headers being read to the line. - `duration_total`: from the request's headers being read to the line.
## `rproxy`'s line ## `rproxy`'s line
@@ -229,10 +218,10 @@ these meanings where they differ:
a WebSocket carried from the visitor. a WebSocket carried from the visitor.
- `response_bytes`: the bytes of the answer's body, with what a WebSocket - `response_bytes`: the bytes of the answer's body, with what a WebSocket
carried from the app. carried from the app.
- `duration_request_ms`: milliseconds from the request's headers being read - `duration_request`: milliseconds from the request's headers being read until
until the app had been sent the whole request, or the answer started if that the app had been sent the whole request, or the answer started if that was
was sooner. It includes the wait for `discriminator`. sooner. It includes the wait for `discriminator`.
- `duration_response_ms`: milliseconds from then until the end of the answer. - `duration_response`: milliseconds from then until the end of the answer.
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It - `disposition`: what was done with the request: `allow`, `deny` or `close`. It
is `discriminator`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is is `discriminator`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is
left out when: left out when:
@@ -246,5 +235,5 @@ these meanings where they differ:
For example: For example:
``` ```
{"type":"request","time":"2026-10-03T12:00:00.123Z","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","client_ip":"203.0.113.9","host":"app.example","method":"GET","path":"/","query":"","status":200,"request_bytes":0,"response_bytes":5120,"duration_request_ms":1.204,"duration_response_ms":3.104,"disposition":"allow"} {"type":"request","time":"2026-10-03T12:00:00.123Z","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","client_ip":"203.0.113.9","host":"app.example","method":"GET","path":"/","query":"","status":200,"request_bytes":0,"response_bytes":5120,"duration_request":1.204,"duration_response":3.104,"disposition":"allow"}
``` ```
+29 -35
View File
@@ -98,8 +98,6 @@ between the two (see "The image's command" below).
## Request rate limits, per client ## Request rate limits, per client
The request past one of these limits is refused, and the client is banned.
- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a - `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
client may make in a minute. A count or `off`. The default leaves room for a client may make in a minute. A count or `off`. The default leaves room for a
person opening two pages at once with an empty cache, up to 60 files each. person opening two pages at once with an empty cache, up to 60 files each.
@@ -122,22 +120,11 @@ The request past one of these limits is refused, and the client is banned.
## Byte limits, per client ## Byte limits, per client
These count body bytes, never headers. What the visitor sent over a WebSocket
counts as request bytes, and what the app sent as response bytes. The bytes
counted for a request and its response are added to the client's total once the
response has ended. The request that takes a client's total past a limit is not
cut off; the client is banned.
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR` - `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes (default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes
counted for a client in each window, a size or `off`. `SWW_BYTES_COUNT` says counted for a client in each window. A size or `off`.
which bytes count: response bodies, request bodies or both. The request that - `SWW_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
passes the limit is not cut off, and the client is banned when its response `response`, `request` or `both`.
ends.
- `SWW_BYTES_COUNT` (default `both`): what the byte limits and the byte anomaly
thresholds count: `response`, the app's response bodies; `request`, the
visitors' request bodies; or `both`. What the visitor sent over a WebSocket
counts as request bytes, and what the app sent as response bytes.
## Lookup of AS number and country ## Lookup of AS number and country
@@ -145,8 +132,8 @@ cut off; the client is banned.
the start while a setting that needs a lookup is set: the start while a setting that needs a lookup is set:
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`; - `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
- `SWW_ADD_LOOKUP_HEADERS`; - `SWW_ADD_LOOKUP_HEADERS`;
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming a `country=` tag, or an - `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming an `asn=` or `country=`
`asn=` tag while `SWW_ROUTEWATCH_URL` is unset; tag;
- the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`; - the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`;
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`; - `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
- an anomaly threshold per AS number. - an anomaly threshold per AS number.
@@ -186,20 +173,11 @@ Every request gets tags from what `discriminator` knows of its client. See
`exploitsignature`: a request gets that tag after the rate limits have counted `exploitsignature`: a request gets that tag after the rate limits have counted
it. it.
- `SWW_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a
routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a
service that gives an address's announced netblock and origin AS.
`discriminator` asks it about each client, in the background, and adds the
tags `asn=<number>` and `prefix=<netblock>` from its answer. While it is
unset, nothing asks routewatch and no request gets a `prefix=` tag.
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag `SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag
that no request can get stops the start. A request can get only these: that no request can get stops the start. A request can get only these:
- a name in `SWW_TAG_LISTS`; - a name in `SWW_TAG_LISTS`;
- an `asn=` or `country=` tag; - an `asn=` or `country=` tag;
- a `prefix=` tag with a netblock, while `SWW_ROUTEWATCH_URL` is set. It matches
only that exact announced netblock, such as `prefix=192.0.2.0/24`;
- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key - a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key
masked as the `reputation` field shows it; masked as the `reputation` field shows it;
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set; - `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set;
@@ -255,9 +233,26 @@ the request first.
a range. So does a directive that removes or changes rules as the file loads, a range. So does a directive that removes or changes rules as the file loads,
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
yet. It has no `_FILE` form. yet. It has no `_FILE` form.
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get - `SWW_APPS` (default unset): the apps each hostname serves. A request gets the
in a minute from a rule file, a trap path or the Core Rule Set, a count or fixes of its hostname's apps only (see "Attack detection" in
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`). [`SPEC.md`](../SPEC.md)).
- Entries are separated by semicolons. Each is a hostname, a colon, then one
or more apps separated by commas, for example
`git.example.com:gitea;code.example.com:gitea`.
- `gitea` is the only app supported.
- The request's `Host` is matched by exact name, without its port and
without regard to case. There are no wildcards.
- A hostname not listed gets no app's fixes, so every rule checks it in
full. Set empty, it lists no hostname.
- Unset, every app's fixes apply to every hostname.
- Any of these mistakes stops the start:
- an unknown app;
- a hostname that is not a DNS name, such as `*.example.com`;
- a hostname listed twice;
- an app listed twice for one hostname;
- an entry without a colon.
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client in
a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
## Ban lengths, response and scope ## Ban lengths, response and scope
@@ -324,7 +319,7 @@ the request first.
- `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control - `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
character in it is refused. character in it is refused.
- `SWW_ALERT_EVENTS` (default - `SWW_ALERT_EVENTS` (default
`ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error`): `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
the event types alerts are sent for, from those in [`alerts.md`](alerts.md) the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
but `summary`. Any other name is refused. but `summary`. Any other name is refused.
- `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its - `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
@@ -334,10 +329,9 @@ the request first.
## Anomaly thresholds ## Anomaly thresholds
Each scope has four thresholds: the most requests and the most body bytes Each scope has four thresholds: the most requests and the most bytes counted in
counted in a minute and in an hour. The bytes are those `SWW_BYTES_COUNT` names. a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by
Requests are counts, bytes are sizes. Each is `off` by default. The four default. The four thresholds per client are:
thresholds per client are:
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`; - `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`; - `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
+3 -4
View File
@@ -61,11 +61,10 @@ since it was first seen:
- the first and last time seen; - the first and last time seen;
- the AS number, AS name and country last looked up, and when; - the AS number, AS name and country last looked up, and when;
- total requests, and how many were forwarded and how many refused; - total requests, and how many were forwarded and how many refused;
- body bytes of its requests, and of the responses it was sent; - bytes in each direction;
- responses by status class; - responses by status class;
- offences by kind: `limit`, `attack`, `blocked_by_block_rule` and - offences by kind: `limit`, `attack`, `rule_blocked` and `waf_blocked`. A file
`blocked_by_core_rule_set`. A file may also hold `token_refused`, which is may also hold `token_refused`, which is kept, and which nothing adds to.
kept, and which nothing adds to.
## `lookups.json` ## `lookups.json`
+10 -12
View File
@@ -44,10 +44,10 @@ const (
// EventAnomaly is a count of requests or bytes over an anomaly // EventAnomaly is a count of requests or bytes over an anomaly
// threshold. // threshold.
EventAnomaly = "anomaly" EventAnomaly = "anomaly"
// EventCoreRuleSetMatch is a request the Core Rule Set scored at or over // EventWAFBlock is a request the Core Rule Set scored at or over
// SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in // SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
// detect mode. // detect mode.
EventCoreRuleSetMatch = "core_rule_set_match" EventWAFBlock = "waf_block"
// EventReputationHit is a request whose client a blocklist, the // EventReputationHit is a request whose client a blocklist, the
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score // CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
// is a hit. // is a hit.
@@ -72,7 +72,7 @@ const (
// default. // default.
func Events() []string { func Events() []string {
return []string{ return []string{
EventBan, EventPermanentBan, EventCoreRuleSetMatch, EventAnomaly, EventBan, EventPermanentBan, EventWAFBlock, EventAnomaly,
EventReputationHit, EventSourceFailure, EventFileError, EventReputationHit, EventSourceFailure, EventFileError,
} }
} }
@@ -220,7 +220,7 @@ type State struct {
// requests to it failed, and how many alerts were dropped from its full // requests to it failed, and how many alerts were dropped from its full
// queue or given up as it refused them. // queue or given up as it refused them.
type Counts struct { type Counts struct {
Sent, SendFailures, Dropped int64 Sent, Failed, Dropped int64
} }
// Queue takes the alerts raised, holds back those it must, and sends the // Queue takes the alerts raised, holds back those it must, and sends the
@@ -264,7 +264,7 @@ type destination struct {
// waiting are the alerts waiting to be sent, oldest first. // waiting are the alerts waiting to be sent, oldest first.
waiting []*Alert waiting []*Alert
sent, sendFailures, dropped atomic.Int64 sent, failed, dropped atomic.Int64
} }
// cooldownKey is what makes an alert a repeat of another: the same event // cooldownKey is what makes an alert a repeat of another: the same event
@@ -426,9 +426,7 @@ func (q *Queue) Counts(name string) Counts {
for _, d := range q.destinations { for _, d := range q.destinations {
if d.name == name { if d.name == name {
return Counts{ return Counts{
Sent: d.sent.Load(), Sent: d.sent.Load(), Failed: d.failed.Load(), Dropped: d.dropped.Load(),
SendFailures: d.sendFailures.Load(),
Dropped: d.dropped.Load(),
} }
} }
} }
@@ -677,7 +675,7 @@ func (d *destination) run(ctx context.Context) {
retryAt = time.Time{} retryAt = time.Time{}
case errors.Is(err, errRefused): case errors.Is(err, errRefused):
d.remove(alert) d.remove(alert)
d.sendFailures.Add(1) d.failed.Add(1)
d.dropped.Add(1) d.dropped.Add(1)
retryDelay = 0 retryDelay = 0
@@ -686,7 +684,7 @@ func (d *destination) run(ctx context.Context) {
d.processLog.Warn("gave up an alert "+d.setting+" refused", d.processLog.Warn("gave up an alert "+d.setting+" refused",
"event", alert.Event, "error", err.Error()) "event", alert.Event, "error", err.Error())
case ctx.Err() == nil: // not cut off as sww stops case ctx.Err() == nil: // not cut off as sww stops
d.sendFailures.Add(1) d.failed.Add(1)
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay), retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
maxRetryDelay) maxRetryDelay)
@@ -873,7 +871,7 @@ func ntfyPriority(event string) string {
return "high" return "high"
case EventReputationHit: case EventReputationHit:
return "low" return "low"
default: // ban, core_rule_set_match and summary default: // ban, waf_block and summary
return "default" return "default"
} }
} }
@@ -884,7 +882,7 @@ func ntfyTag(event string) string {
switch event { switch event {
case EventBan, EventPermanentBan: case EventBan, EventPermanentBan:
return "no_entry" return "no_entry"
case EventCoreRuleSetMatch: case EventWAFBlock:
return "shield" return "shield"
case EventAnomaly: case EventAnomaly:
return "chart_with_upwards_trend" return "chart_with_upwards_trend"
+6 -6
View File
@@ -897,7 +897,7 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
synctest.Wait() synctest.Wait()
wantDestinationCounts(t, q, alerts.DestinationSlack, wantDestinationCounts(t, q, alerts.DestinationSlack,
alerts.Counts{Sent: 3, SendFailures: 1}) alerts.Counts{Sent: 3, Failed: 1})
logged := log.String() logged := log.String()
if !strings.Contains(logged, if !strings.Contains(logged,
@@ -934,7 +934,7 @@ func TestDropsAreCountedForEachDestination(t *testing.T) {
for destination, want := range map[string]alerts.Counts{ for destination, want := range map[string]alerts.Counts{
alerts.DestinationWebhook: {Sent: all}, alerts.DestinationWebhook: {Sent: all},
alerts.DestinationSlack: {Dropped: 1}, alerts.DestinationSlack: {Dropped: 1},
alerts.DestinationNtfy: {SendFailures: all, Dropped: all}, alerts.DestinationNtfy: {Failed: all, Dropped: all},
} { } {
wantDestinationCounts(t, q, destination, want) wantDestinationCounts(t, q, destination, want)
} }
@@ -1223,12 +1223,12 @@ func wantEvents(t *testing.T, webhook *standIn, want ...string) {
// requests to it that it counts as failed, the alerts it counts as held // requests to it that it counts as failed, the alerts it counts as held
// back, and those it counts as dropped for the webhook. // back, and those it counts as dropped for the webhook.
func wantCounts( func wantCounts(
t *testing.T, q *alerts.Queue, sent, sendFailures, suppressed, dropped int64, t *testing.T, q *alerts.Queue, sent, failed, suppressed, dropped int64,
) { ) {
t.Helper() t.Helper()
wantDestinationCounts(t, q, alerts.DestinationWebhook, wantDestinationCounts(t, q, alerts.DestinationWebhook,
alerts.Counts{Sent: sent, SendFailures: sendFailures, Dropped: dropped}) alerts.Counts{Sent: sent, Failed: failed, Dropped: dropped})
if q.Suppressed() != suppressed { if q.Suppressed() != suppressed {
t.Errorf("%d alerts held back, want %d", q.Suppressed(), suppressed) t.Errorf("%d alerts held back, want %d", q.Suppressed(), suppressed)
@@ -1281,12 +1281,12 @@ func anAlertForEachEvent() []eventMessage {
}, },
{ {
alerts.Alert{ alerts.Alert{
Event: alerts.EventCoreRuleSetMatch, Client: netip.MustParseAddr("192.0.2.1"), Event: alerts.EventWAFBlock, Client: netip.MustParseAddr("192.0.2.1"),
Netblock: netip.MustParsePrefix("192.0.2.1/32"), Netblock: netip.MustParsePrefix("192.0.2.1/32"),
Reason: "refused by the Core Rule Set", Reason: "refused by the Core Rule Set",
Host: "app.example", Host: "app.example",
}, },
"fsn1app1/blog: core_rule_set_match", "default shield", "fsn1app1/blog: waf_block", "default shield",
"refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" + "refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" +
"netblock: 192.0.2.1/32", "netblock: 192.0.2.1/32",
}, },
+5 -5
View File
@@ -37,13 +37,13 @@ const (
ScopeASN = "asn" ScopeASN = "asn"
// ScopeTotal is the whole service. // ScopeTotal is the whole service.
ScopeTotal = "total" ScopeTotal = "total"
// ScopeWatchedNet is a named netblock of SWW_WATCH_NETS. // ScopeWatch is a named netblock of SWW_WATCH_NETS.
ScopeWatchedNet = "watched_net" ScopeWatch = "watch"
) )
// Scopes returns every scope. // Scopes returns every scope.
func Scopes() []string { func Scopes() []string {
return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatchedNet} return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatch}
} }
// The windows a counter counts in, as the alerts name them. // The windows a counter counts in, as the alerts name them.
@@ -257,7 +257,7 @@ func (c *Counters) scopesOf(r Request) []scope {
for _, named := range p.NamedNetblocks { for _, named := range p.NamedNetblocks {
if named.Netblock.Contains(client) { if named.Netblock.Contains(client) {
all = append(all, scope{ all = append(all, scope{
key{scope: ScopeWatchedNet, netblock: named.Netblock, name: named.Name}, p.Watch, key{scope: ScopeWatch, netblock: named.Netblock, name: named.Name}, p.Watch,
}) })
} }
} }
@@ -391,7 +391,7 @@ func alertFor(r Request, k key, o overThreshold) alerts.Alert {
detail["asn"] = k.asn detail["asn"] = k.asn
case ScopeTotal: case ScopeTotal:
counted = "the whole service" counted = "the whole service"
default: // watched_net default: // watch
counted = "the named netblock " + k.name + ", " + k.netblock.String() counted = "the named netblock " + k.name + ", " + k.netblock.String()
detail["name"] = k.name detail["name"] = k.name
} }
+14 -59
View File
@@ -13,7 +13,7 @@ func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
t.Parallel() t.Parallel()
netblock := netip.MustParsePrefix("203.0.113.0/24") netblock := netip.MustParsePrefix("203.0.113.0/24")
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{{Netblock: netblock, Start: midnight()}}) ledger.Load([]bans.Ban{{Netblock: netblock, Start: midnight()}})
if got := ledger.Bans(netblock)[0].Cause; got != bans.CauseAdmin { if got := ledger.Bans(netblock)[0].Cause; got != bans.CauseAdmin {
@@ -24,9 +24,9 @@ func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) { func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
t.Parallel() t.Parallel()
params := defaultParams() rules := defaultRules()
params.MaxBans = 1 rules.MaxBans = 1
ledger := bans.New(params) ledger := bans.New(rules)
adminsOnly := netip.MustParsePrefix("198.51.100.0/24") adminsOnly := netip.MustParsePrefix("198.51.100.0/24")
both := netip.MustParsePrefix("203.0.113.1/32") both := netip.MustParsePrefix("203.0.113.1/32")
second := netip.MustParsePrefix("203.0.113.2/32") second := netip.MustParsePrefix("203.0.113.2/32")
@@ -64,9 +64,9 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) { func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
t.Parallel() t.Parallel()
params := defaultParams() rules := defaultRules()
params.MaxBans = 2 rules.MaxBans = 2
ledger := bans.New(params) ledger := bans.New(rules)
netblock := netip.MustParsePrefix("203.0.113.1/32") netblock := netip.MustParsePrefix("203.0.113.1/32")
other := netip.MustParsePrefix("203.0.113.2/32") other := netip.MustParsePrefix("203.0.113.2/32")
@@ -93,7 +93,7 @@ func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) { func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.1/32") netblock := netip.MustParsePrefix("203.0.113.1/32")
// The netblock breaks a limit twice, the second time after its first // The netblock breaks a limit twice, the second time after its first
@@ -117,7 +117,7 @@ func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
func TestReasonOfTheBansSwwMakes(t *testing.T) { func TestReasonOfTheBansSwwMakes(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(), limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"}) bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"})
@@ -150,7 +150,7 @@ func TestLiftedBanForALimitRefusesNothingAndMakesNoBanLonger(t *testing.T) {
Lifted: midnight().Add(10 * time.Minute), Lifted: midnight().Add(10 * time.Minute),
} }
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{lifted}) ledger.Load([]bans.Ban{lifted})
// While it would still last, it refuses nothing, and a limit broken // While it would still last, it refuses nothing, and a limit broken
@@ -181,7 +181,7 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
// A permanent ban for a clear sign of attack, lifted. // A permanent ban for a clear sign of attack, lifted.
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{{ ledger.Load([]bans.Ban{{
Netblock: netblock, Netblock: netblock,
Start: midnight(), Start: midnight(),
@@ -211,7 +211,7 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) { func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(), made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
bans.Notes{}) bans.Notes{})
atStart := bans.Ban{ atStart := bans.Ban{
@@ -246,7 +246,7 @@ func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) {
t.Parallel() t.Parallel()
netblock := netip.MustParsePrefix("203.0.113.0/24") netblock := netip.MustParsePrefix("203.0.113.0/24")
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
// An hour's ban for a broken limit. // An hour's ban for a broken limit.
ledger.BanForLimit(netblock, midnight(), bans.Notes{}) ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -292,7 +292,7 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
wide := netip.MustParsePrefix("203.0.113.0/24") wide := netip.MustParsePrefix("203.0.113.0/24")
other := netip.MustParsePrefix("203.0.113.10/32") other := netip.MustParsePrefix("203.0.113.10/32")
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{ ledger.Load([]bans.Ban{
// Ended an hour ago. // Ended an hour ago.
{ {
@@ -341,48 +341,3 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
wantChanged(t, ledger, false) wantChanged(t, ledger, false)
} }
func TestPermanentBanOnAWiderNetblockIsReturnedOverATemporaryBan(t *testing.T) {
t.Parallel()
client := netip.MustParseAddr("203.0.113.9")
ledger := bans.New(defaultParams())
// The temporary ban is made first, so that its netblock is looked at first.
ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(), bans.Notes{})
ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.0/24"), midnight(), time.Time{},
"for good")
wantPermanentBan(t, ledger, client, midnight().Add(time.Minute))
}
func TestOlderPermanentBanIsReturnedOverANewerTemporaryBan(t *testing.T) {
t.Parallel()
netblock := netip.MustParsePrefix("203.0.113.0/24")
ledger := bans.New(defaultParams())
ledger.BanForAdmin(netblock, midnight(), time.Time{}, "for good")
ledger.BanForAdmin(netblock, midnight().Add(time.Minute), midnight().Add(time.Hour),
"for an hour")
wantPermanentBan(t, ledger, netblock.Addr(), midnight().Add(2*time.Minute))
}
// wantPermanentBan checks that Find and Check return a permanent ban for
// client at now.
func wantPermanentBan(
t *testing.T, ledger *bans.Ledger, client netip.Addr, now time.Time,
) {
t.Helper()
for name, look := range map[string]func(netip.Addr, time.Time) (bans.Ban, bool, bool){
"Find": ledger.Find,
"Check": ledger.Check,
} {
ban, banned, _ := look(client, now)
if !banned || !ban.Permanent() {
t.Errorf("%s returned %+v and banned %v, want a permanent ban", name, ban, banned)
}
}
}
+19 -41
View File
@@ -40,8 +40,8 @@ const repeatFactor = 3
// maxTextBytes is how much of each text in a ban's notes is kept. // maxTextBytes is how much of each text in a ban's notes is kept.
const maxTextBytes = 256 const maxTextBytes = 256
// Params are how long a ban lasts, and how many bans are held. // Rules are how long a ban lasts, and how many bans are held.
type Params struct { type Rules struct {
// LimitBanDuration is how long a first ban for a broken limit lasts. // LimitBanDuration is how long a first ban for a broken limit lasts.
LimitBanDuration time.Duration LimitBanDuration time.Duration
// LimitBanRepeatWindow is how soon after the end of the netblock's // LimitBanRepeatWindow is how soon after the end of the netblock's
@@ -180,7 +180,7 @@ type Request struct {
// Ledger holds the bans. It is safe for concurrent use. // Ledger holds the bans. It is safe for concurrent use.
type Ledger struct { type Ledger struct {
params Params rules Rules
// changed receives a value when a ban is made, unless one is waiting // changed receives a value when a ban is made, unless one is waiting
// already. // already.
changed chan struct{} changed chan struct{}
@@ -190,7 +190,7 @@ type Ledger struct {
// Find make each netblock they find the most recently seen. // Find make each netblock they find the most recently seen.
netblocks *simplelru.LRU[netip.Prefix, *[]Ban] netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
// held is how many bans netblocks holds whose cause is not CauseAdmin, // held is how many bans netblocks holds whose cause is not CauseAdmin,
// at most params.MaxBans. // at most rules.MaxBans.
held int held int
// made is how many bans have been made since the start, by cause: by // made is how many bans have been made since the start, by cause: by
// the ledger, and by an admin, through BanForAdmin or in an edit of // the ledger, and by an admin, through BanForAdmin or in an edit of
@@ -205,7 +205,7 @@ type Ledger struct {
} }
// New returns a Ledger with no ban yet. // New returns a Ledger with no ban yet.
func New(params Params) *Ledger { func New(rules Rules) *Ledger {
// The ledger drops bans itself, and never those whose cause is // The ledger drops bans itself, and never those whose cause is
// CauseAdmin, however many there are, so the LRU has no limit of its // CauseAdmin, however many there are, so the LRU has no limit of its
// own: it keeps the netblocks in the order they were last seen. // own: it keeps the netblocks in the order they were last seen.
@@ -215,7 +215,7 @@ func New(params Params) *Ledger {
} }
return &Ledger{ return &Ledger{
params: params, rules: rules,
changed: make(chan struct{}, 1), changed: make(chan struct{}, 1),
netblocks: netblocks, netblocks: netblocks,
made: map[string]int{}, made: map[string]int{},
@@ -274,27 +274,16 @@ func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool, bool) {
// activeBan returns the ban in bans, a netblock's bans oldest first, that // activeBan returns the ban in bans, a netblock's bans oldest first, that
// is active at now, or nil when none is. If several are, it returns the // is active at now, or nil when none is. If several are, it returns the
// newest permanent one, or the newest when none is permanent. Every ban is // one that started last. Every ban is looked at, since a ban an admin adds
// looked at, since a ban an admin adds to bans.json can start before the // to bans.json can start before the netblock's others and outlast them.
// netblock's others and outlast them.
func activeBan(bans []Ban, now time.Time) *Ban { func activeBan(bans []Ban, now time.Time) *Ban {
var newest *Ban
for i := len(bans) - 1; i >= 0; i-- { for i := len(bans) - 1; i >= 0; i-- {
if !bans[i].ActiveAt(now) { if bans[i].ActiveAt(now) {
continue
}
if bans[i].Permanent() {
return &bans[i] return &bans[i]
} }
if newest == nil {
newest = &bans[i]
}
} }
return newest return nil
} }
// BanForLimit bans netblock at now for a broken limit, with notes, and // BanForLimit bans netblock at now for a broken limit, with notes, and
@@ -754,16 +743,13 @@ func (l *Ledger) markChanged() {
} }
// active returns the ban active at now on a netblock client is in, or // active returns the ban active at now on a netblock client is in, or
// nil. If several netblocks have one, it returns a permanent ban when // nil.
// there is one.
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban { func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
lengths := l.v6Lengths lengths := l.v6Lengths
if client.Is4() { if client.Is4() {
lengths = l.v4Lengths lengths = l.v4Lengths
} }
var first *Ban
for _, length := range lengths { for _, length := range lengths {
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked()) bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
if !found { if !found {
@@ -771,20 +757,12 @@ func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
} }
ban := activeBan(*bans, now) ban := activeBan(*bans, now)
if ban == nil { if ban != nil {
continue
}
if ban.Permanent() {
return ban return ban
} }
if first == nil {
first = ban
}
} }
return first return nil
} }
// covering returns the bans of each netblock held that client is in, // covering returns the bans of each netblock held that client is in,
@@ -812,7 +790,7 @@ func (l *Ledger) covering(client netip.Addr) []*[]Ban {
// unless ban's cause is CauseAdmin, which does not count toward MaxBans. // unless ban's cause is CauseAdmin, which does not count toward MaxBans.
func (l *Ledger) add(ban Ban) { func (l *Ledger) add(ban Ban) {
counted := ban.Cause != CauseAdmin counted := ban.Cause != CauseAdmin
if counted && l.held == l.params.MaxBans { if counted && l.held == l.rules.MaxBans {
l.dropOne() l.dropOne()
} }
@@ -847,7 +825,7 @@ func (l *Ledger) add(ban Ban) {
// new ban longer. A ban an admin adds to bans.json can start after // 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. // 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 { func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
length := l.params.LimitBanDuration length := l.rules.LimitBanDuration
var last *Ban var last *Ban
@@ -858,18 +836,18 @@ func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
} }
} }
if last != nil && now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow { if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow {
lastLength := last.Expires.Sub(last.Start) lastLength := last.Expires.Sub(last.Start)
// This is repeatFactor * lastLength > MaxBanDuration, written so // This is repeatFactor * lastLength > MaxBanDuration, written so
// that it cannot overflow. // that it cannot overflow.
if lastLength > l.params.MaxBanDuration/repeatFactor { if lastLength > l.rules.MaxBanDuration/repeatFactor {
return time.Time{} return time.Time{}
} }
length = repeatFactor * lastLength length = repeatFactor * lastLength
} }
if length > l.params.MaxBanDuration { if length > l.rules.MaxBanDuration {
return time.Time{} return time.Time{}
} }
@@ -887,7 +865,7 @@ func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time {
} }
} }
return now.Add(l.params.AttackBanDuration) return now.Add(l.rules.AttackBanDuration)
} }
// dropOne drops the earliest ban whose cause is not CauseAdmin of the // dropOne drops the earliest ban whose cause is not CauseAdmin of the
+27 -27
View File
@@ -15,7 +15,7 @@ const day = 24 * time.Hour
func TestRepeatsTripleUntilPermanent(t *testing.T) { func TestRepeatsTripleUntilPermanent(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
now := midnight() now := midnight()
@@ -62,7 +62,7 @@ func TestRepeatWindowRunsOut(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{}) first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -80,9 +80,9 @@ func TestRepeatWindowRunsOut(t *testing.T) {
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) { func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
t.Parallel() t.Parallel()
params := defaultParams() rules := defaultRules()
params.LimitBanDuration = params.MaxBanDuration + time.Hour rules.LimitBanDuration = rules.MaxBanDuration + time.Hour
ledger := bans.New(params) ledger := bans.New(rules)
ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(), ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
bans.Notes{}) bans.Notes{})
@@ -97,9 +97,9 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
// With bans of up to 100,000 days, the 14th ban in a row, of 3^13 // With bans of up to 100,000 days, the 14th ban in a row, of 3^13
// hours, is within the maximum, and three times as long would not fit // hours, is within the maximum, and three times as long would not fit
// in a time.Duration. The 15th is permanent. // in a time.Duration. The 15th is permanent.
params := defaultParams() rules := defaultRules()
params.MaxBanDuration = 100000 * day rules.MaxBanDuration = 100000 * day
ledger := bans.New(params) ledger := bans.New(rules)
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
now := midnight() now := midnight()
@@ -121,7 +121,7 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) { func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
first, made := ledger.BanForLimit(netblock, midnight(), bans.Notes{}) first, made := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -146,7 +146,7 @@ func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) { func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5}) ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
@@ -178,7 +178,7 @@ func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
func TestFindCountsNothing(t *testing.T) { func TestFindCountsNothing(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5}) ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
@@ -200,9 +200,9 @@ func TestFindCountsNothing(t *testing.T) {
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) { func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
t.Parallel() t.Parallel()
params := defaultParams() rules := defaultRules()
params.MaxBans = 3 rules.MaxBans = 3
ledger := bans.New(params) ledger := bans.New(rules)
a := netip.MustParsePrefix("203.0.113.1/32") a := netip.MustParsePrefix("203.0.113.1/32")
b := netip.MustParsePrefix("203.0.113.2/32") b := netip.MustParsePrefix("203.0.113.2/32")
c := netip.MustParsePrefix("203.0.113.3/32") c := netip.MustParsePrefix("203.0.113.3/32")
@@ -239,9 +239,9 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
// With room for one ban, the netblock's ended ban goes to make room for // With room for one ban, the netblock's ended ban goes to make room for
// its new one, whose notes still count it. // its new one, whose notes still count it.
params := defaultParams() rules := defaultRules()
params.MaxBans = 1 rules.MaxBans = 1
ledger := bans.New(params) ledger := bans.New(rules)
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{}) first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -258,7 +258,7 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) { func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
notes := bans.Notes{RuleID: "env-file", Target: "path"} notes := bans.Notes{RuleID: "env-file", Target: "path"}
@@ -304,7 +304,7 @@ func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) { func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
// A ban for a broken limit before does not count. // A ban for a broken limit before does not count.
@@ -336,7 +336,7 @@ func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) { func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{}) first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
wantChanged(t, ledger, true) wantChanged(t, ledger, true)
@@ -380,7 +380,7 @@ func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) { func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
now := midnight() now := midnight()
@@ -414,7 +414,7 @@ func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) { func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
// Three times the seven days would be permanent; a limit broken as the // Three times the seven days would be permanent; a limit broken as the
@@ -436,7 +436,7 @@ func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
func TestRequestTextsAreCutTo256Bytes(t *testing.T) { func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
long := strings.Repeat("a", 300) long := strings.Repeat("a", 300)
request := bans.Request{ request := bans.Request{
@@ -458,7 +458,7 @@ func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) { func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
other := netip.MustParsePrefix("198.51.100.7/32") other := netip.MustParsePrefix("198.51.100.7/32")
@@ -494,9 +494,9 @@ func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
} }
} }
// defaultParams are the params at the settings' defaults. // defaultRules are the rules at the settings' defaults.
func defaultParams() bans.Params { func defaultRules() bans.Rules {
return bans.Params{ return bans.Rules{
LimitBanDuration: time.Hour, LimitBanDuration: time.Hour,
LimitBanRepeatWindow: day, LimitBanRepeatWindow: day,
MaxBanDuration: 7 * day, MaxBanDuration: 7 * day,
+3 -3
View File
@@ -15,7 +15,7 @@ const scenario = "crowdsecurity/ssh-bf"
func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) { func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
expires := midnight().Add(4 * time.Hour) expires := midnight().Add(4 * time.Hour)
@@ -49,7 +49,7 @@ func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) { func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
expires := midnight().Add(4 * time.Hour) expires := midnight().Add(4 * time.Hour)
ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, bans.Notes{}) ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, bans.Notes{})
@@ -72,7 +72,7 @@ func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) { func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
// Three times the three days would be permanent; a limit broken as the // Three times the three days would be permanent; a limit broken as the
+14 -14
View File
@@ -14,7 +14,7 @@ import (
func TestChangedAfterABanIsMade(t *testing.T) { func TestChangedAfterABanIsMade(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
wantChanged(t, ledger, false) wantChanged(t, ledger, false)
@@ -38,7 +38,7 @@ func TestChangedAfterABanIsMade(t *testing.T) {
func TestSnapshotListsEveryBanByNetblock(t *testing.T) { func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
v6 := netip.MustParsePrefix("2001:db8::/64") v6 := netip.MustParsePrefix("2001:db8::/64")
high := netip.MustParsePrefix("203.0.113.10/32") high := netip.MustParsePrefix("203.0.113.10/32")
low := netip.MustParsePrefix("203.0.113.9/32") low := netip.MustParsePrefix("203.0.113.9/32")
@@ -67,14 +67,14 @@ func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
func TestLoadedBansCarryOn(t *testing.T) { func TestLoadedBansCarryOn(t *testing.T) {
t.Parallel() t.Parallel()
before := bans.New(defaultParams()) before := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
ban, _ := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1}) ban, _ := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1})
// Loaded into a new ledger, as across a restart, the ban still refuses // Loaded into a new ledger, as across a restart, the ban still refuses
// while it lasts, and once it has ended a broken limit bans for three // while it lasts, and once it has ended a broken limit bans for three
// times as long, with the loaded ban counted among the earlier ones. // times as long, with the loaded ban counted among the earlier ones.
after := bans.New(defaultParams()) after := bans.New(defaultRules())
after.Load(before.Snapshot()) after.Load(before.Snapshot())
_, banned, _ := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second)) _, banned, _ := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
@@ -96,7 +96,7 @@ func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) {
// Two entries as an admin might write them, with addresses not masked // Two entries as an admin might write them, with addresses not masked
// to their lengths, the IPv6 one shorter than the /64 an IPv6 client's // to their lengths, the IPv6 one shorter than the /64 an IPv6 client's
// ban covers, beside a ban the ledger makes on one IPv4 address. // ban covers, beside a ban the ledger makes on one IPv4 address.
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{ ledger.Load([]bans.Ban{
{Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()}, {Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()},
{Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()}, {Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()},
@@ -145,7 +145,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
Expires: midnight().Add(time.Hour), Expires: midnight().Add(time.Hour),
} }
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{permanent, ended}) ledger.Load([]bans.Ban{permanent, ended})
now := midnight().Add(2 * time.Hour) now := midnight().Add(2 * time.Hour)
@@ -189,7 +189,7 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
Expires: midnight().Add(2 * time.Hour), Expires: midnight().Add(2 * time.Hour),
} }
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{nineHours, admins}) ledger.Load([]bans.Ban{nineHours, admins})
// Once both have ended, a limit broken within the repeat window bans // Once both have ended, a limit broken within the repeat window bans
@@ -219,9 +219,9 @@ func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
Cause: bans.CauseLimit, Cause: bans.CauseLimit,
} }
params := defaultParams() rules := defaultRules()
params.MaxBans = 1 rules.MaxBans = 1
ledger := bans.New(params) ledger := bans.New(rules)
ledger.Load([]bans.Ban{later, earlier}) ledger.Load([]bans.Ban{later, earlier})
held := ledger.Snapshot() held := ledger.Snapshot()
@@ -235,9 +235,9 @@ func TestLoadReplacesTheBansHeld(t *testing.T) {
// Room for three bans, so that the second load, were it added to the // Room for three bans, so that the second load, were it added to the
// two bans held, would drop none of them to make room. // two bans held, would drop none of them to make room.
params := defaultParams() rules := defaultRules()
params.MaxBans = 3 rules.MaxBans = 3
ledger := bans.New(params) ledger := bans.New(rules)
kept := bans.Ban{ kept := bans.Ban{
Netblock: netip.MustParsePrefix("2001:db8::/64"), Netblock: netip.MustParsePrefix("2001:db8::/64"),
Start: midnight(), Start: midnight(),
@@ -285,7 +285,7 @@ func TestLoadCutsTheTextsTo256Bytes(t *testing.T) {
}}, }},
} }
ledger := bans.New(defaultParams()) ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{ban}) ledger.Load([]bans.Ban{ban})
cut := long[:256] cut := long[:256]
+102 -60
View File
@@ -12,7 +12,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"maps"
"math" "math"
"net" "net"
"net/http" "net/http"
@@ -30,6 +29,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/remotelog" "sneak.berlin/go/smallwebwaf/internal/remotelog"
"sneak.berlin/go/smallwebwaf/internal/waf"
) )
// Config is the settings of `sww discriminator`. A time or size limit of // Config is the settings of `sww discriminator`. A time or size limit of
@@ -107,9 +107,6 @@ type Config struct {
TagLists []TagList TagLists []TagList
TagDeny []string TagDeny []string
TagLimitPercent map[string]int64 TagLimitPercent map[string]int64
// RouteWatchURL is where the routewatch instance is that adds the asn=
// and prefix= tags, "" for none (SWW_ROUTEWATCH_URL).
RouteWatchURL string
// The biased thresholds. ASNBytesPercent and CountryBytesPercent give // The biased thresholds. ASNBytesPercent and CountryBytesPercent give
// those they list a percentage of the byte limits in place of the one // those they list a percentage of the byte limits in place of the one
// TagLimitPercent gives their asn= or country= tag // TagLimitPercent gives their asn= or country= tag
@@ -235,6 +232,11 @@ type Config struct {
WAFExemptPaths []string WAFExemptPaths []string
WAFBodyLimit int64 WAFBodyLimit int64
CoreRuleSetExclusionsFile string CoreRuleSetExclusionsFile string
// Apps are the names of the apps each hostname serves, by hostname in
// lowercase (SWW_APPS): a request gets only its hostname's apps' fixes
// to the Core Rule Set. Apps is nil while SWW_APPS is unset, and every
// request gets every app's fixes.
Apps map[string][]string
// TrapPaths are the paths a request for which is a clear sign of // TrapPaths are the paths a request for which is a clear sign of
// attack (SWW_TRAP_PATHS), each starting with / and without a ?. // attack (SWW_TRAP_PATHS), each starting with / and without a ?.
TrapPaths []string TrapPaths []string
@@ -336,7 +338,7 @@ const (
maxParanoiaLevel = 4 maxParanoiaLevel = 4
// firstSetupRuleID to lastSetupRuleID are the ids the Core Rule Set // firstSetupRuleID to lastSetupRuleID are the ids the Core Rule Set
// keeps for the rules that set it up, which sww's own rules // keeps for the rules that set it up, which sww's own rules
// have too (see internal/coreruleset). Switching one off would undo a change // have too (see internal/waf). Switching one off would undo a change
// that no setting undoes. // that no setting undoes.
firstSetupRuleID = 900000 firstSetupRuleID = 900000
lastSetupRuleID = 900999 lastSetupRuleID = 900999
@@ -404,6 +406,11 @@ var (
errNotTrapPath = errors.New( errNotTrapPath = errors.New(
"is not a path starting with / and without a ?, such as /wp-login.php", "is not a path starting with / and without a ?, such as /wp-login.php",
) )
errNotAppsEntry = errors.New(
"is not a hostname, : and a list of apps, such as git.example.com:gitea",
)
errNotHostname = errors.New("is not a DNS name such as git.example.com")
errNotApp = errors.New("is not an app sww has fixes for; the apps are")
errNotWAFMode = errors.New("is not off, detect or block") errNotWAFMode = errors.New("is not off, detect or block")
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4") errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
errNotRuleID = errors.New( errNotRuleID = errors.New(
@@ -432,7 +439,7 @@ var (
"holds a control character, such as the carriage return of a Windows line end", "holds a control character, such as the carriage return of a Windows line end",
) )
errNotAlertEvent = errors.New( errNotAlertEvent = errors.New(
"is not ban, permanent_ban, core_rule_set_match, anomaly, reputation_hit, " + "is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
"source_failure or file_error") "source_failure or file_error")
errNotNumberOrOff = errors.New( errNotNumberOrOff = errors.New(
"is not a whole number above zero, such as 60, or off", "is not a whole number above zero, such as 60, or off",
@@ -466,9 +473,6 @@ var (
errNotLAPIURL = errors.New( errNotLAPIURL = errors.New(
"is not an http or https URL without a user or a fragment, " + "is not an http or https URL without a user or a fragment, " +
"such as http://172.17.0.1:8080") "such as http://172.17.0.1:8080")
errNotRouteWatchURL = errors.New(
"is not an http or https URL without a user or a fragment, " +
"such as https://routewatch.example")
errNeedsLAPIKey = errors.New("the engine answers no request without it") errNeedsLAPIKey = errors.New("the engine answers no request without it")
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL") errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
errAnotherList = errors.New( errAnotherList = errors.New(
@@ -482,8 +486,6 @@ var (
"is not a tag, : and a percentage, such as vpn:50 or asn=14061:25") "is not a tag, : and a percentage, such as vpn:50 or asn=14061:25")
errNotTagPercent = errors.New("is not a percentage from 1 to 99") errNotTagPercent = errors.New("is not a percentage from 1 to 99")
errNotASNTag = errors.New("is not asn= and a number, such as asn=14061") errNotASNTag = errors.New("is not asn= and a number, such as asn=14061")
errNotPrefixTag = errors.New(
"is not prefix= and a netblock, such as prefix=192.0.2.0/24")
errExploitTag = errors.New( errExploitTag = errors.New(
"cannot lower limits: the rate limits count a request before its " + "cannot lower limits: the rate limits count a request before its " +
"rules are matched") "rules are matched")
@@ -492,9 +494,8 @@ var (
errNoRequestGetsTag = errors.New( errNoRequestGetsTag = errors.New(
"is a tag no request can get: it is not a name in SWW_TAG_LISTS, an " + "is a tag no request can get: it is not a name in SWW_TAG_LISTS, an " +
"asn= or country= tag, a dnsbl= tag of a zone in SWW_DNSBL_ZONES, " + "asn= or country= tag, a dnsbl= tag of a zone in SWW_DNSBL_ZONES, " +
"a prefix= tag with SWW_ROUTEWATCH_URL set, abuseipdb with " + "abuseipdb with SWW_ABUSEIPDB_KEY set, or exploitsignature with " +
"SWW_ABUSEIPDB_KEY set, or exploitsignature with rule files or the " + "rule files or the Core Rule Set on")
"Core Rule Set on")
) )
// FromEnvironment reads the settings with lookupEnv, normally // FromEnvironment reads the settings with lookupEnv, normally
@@ -534,7 +535,6 @@ func FromEnvironment(
TagLists: env.tagLists("SWW_TAG_LISTS"), TagLists: env.tagLists("SWW_TAG_LISTS"),
TagDeny: env.tags("SWW_TAG_DENY"), TagDeny: env.tags("SWW_TAG_DENY"),
TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"), TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"),
RouteWatchURL: env.routeWatchURL("SWW_ROUTEWATCH_URL"),
ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN), ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN),
CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry), CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry),
UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"), UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"),
@@ -577,6 +577,7 @@ func FromEnvironment(
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off), WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
CoreRuleSetExclusionsFile: env.filePath( CoreRuleSetExclusionsFile: env.filePath(
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"), "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
Apps: env.apps("SWW_APPS"),
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"), TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"), ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"), LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
@@ -832,6 +833,25 @@ func (e *environment) pathPrefixes(name, defaultValue string) []string {
return prefixes return prefixes
} }
// apps reads the setting that is the apps each hostname serves. It is nil
// while the setting is unset, and logged as null, unlike a setting set
// empty.
func (e *environment) apps(name string) map[string][]string {
value, set := e.lookup(name)
if !set {
e.settings = append(e.settings, slog.Any(name, nil))
return nil
}
e.settings = append(e.settings, slog.String(name, value))
apps, err := parseApps(value)
e.check(name, err)
return apps
}
// trapPaths reads the setting that is the list of trap paths. It is empty // trapPaths reads the setting that is the list of trap paths. It is empty
// by default. // by default.
func (e *environment) trapPaths(name string) []string { func (e *environment) trapPaths(name string) []string {
@@ -1030,22 +1050,6 @@ func (e *environment) crowdSecDecisionsURL(name string) string {
return lapi.JoinPath("v1", "decisions").String() return lapi.JoinPath("v1", "decisions").String()
} }
// routeWatchURL reads the setting that is the URL of a routewatch instance,
// "" while it is unset or empty.
func (e *environment) routeWatchURL(name string) string {
value := e.value(name, "")
if value == "" {
return ""
}
instance, err := url.Parse(value)
if err != nil || !isHTTPURL(instance) {
e.check(name, fmt.Errorf("%q %w", value, errNotRouteWatchURL))
}
return value
}
// lookupSource reads the setting that is where clients are looked up: // lookupSource reads the setting that is where clients are looked up:
// geojs, file, or off. // geojs, file, or off.
func (e *environment) lookupSource(name, defaultValue string) string { func (e *environment) lookupSource(name, defaultValue string) string {
@@ -1091,21 +1095,14 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
return return
} }
// routewatch gives asn= tags too.
onlyLookupGives := func(tag string) bool {
return isLookupTag(tag) &&
(cfg.RouteWatchURL == "" || !strings.HasPrefix(tag, ASNTagPrefix))
}
for _, setting := range []struct { for _, setting := range []struct {
name string name string
set bool set bool
}{ }{
{"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0}, {"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
{"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders}, {"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
{"SWW_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, onlyLookupGives)}, {"SWW_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, isLookupTag)},
{"SWW_TAG_LIMIT_PERCENT", slices.ContainsFunc( {"SWW_TAG_LIMIT_PERCENT", tagLimitNeedsLookups(cfg)},
slices.Collect(maps.Keys(cfg.TagLimitPercent)), onlyLookupGives)},
{"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0}, {"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
{"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0}, {"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
{"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100}, {"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
@@ -1205,10 +1202,6 @@ func requestCanGetTag(cfg *Config, tag string) bool {
return cfg.AbuseIPDBKey != "" return cfg.AbuseIPDBKey != ""
} }
if strings.HasPrefix(tag, PrefixTagPrefix) {
return cfg.RouteWatchURL != ""
}
zone, isZoneTag := strings.CutPrefix(tag, DNSBLTagPrefix) zone, isZoneTag := strings.CutPrefix(tag, DNSBLTagPrefix)
return isZoneTag && slices.ContainsFunc(cfg.DNSBLZones, func(each string) bool { return isZoneTag && slices.ContainsFunc(cfg.DNSBLZones, func(each string) bool {
@@ -1217,7 +1210,7 @@ func requestCanGetTag(cfg *Config, tag string) bool {
} }
// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT // TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT
// names an asn= or country= tag. // names an asn= or country= tag, which only the lookup can give a request.
func (c *Config) TagsNeedLookup() bool { func (c *Config) TagsNeedLookup() bool {
return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c) return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c)
} }
@@ -1849,6 +1842,65 @@ func parseTrapPaths(value string) ([]string, error) {
return paths, nil return paths, nil
} }
// parseApps reads the apps each hostname serves: entries separated by
// semicolons, each a hostname, a colon, and a comma-separated list of the
// names waf.AppNames returns, such as
// git.example.com:gitea;cloud.example.com:nextcloud,wordpress. Each
// hostname is a DNS name, without a wildcard. It returns them by hostname
// in lowercase, since a hostname is matched without regard to case. An
// empty value lists no hostname.
func parseApps(value string) (map[string][]string, error) {
apps := map[string][]string{}
if strings.TrimSpace(value) == "" {
return apps, nil
}
for entry := range strings.SplitSeq(value, ";") {
hostname, list, found := strings.Cut(entry, ":")
if !found {
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
}
hostname = strings.ToLower(strings.TrimSpace(hostname))
if hostname == "" {
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
}
if !isZone(hostname) {
return nil, fmt.Errorf("%q %w", hostname, errNotHostname)
}
if _, listed := apps[hostname]; listed {
return nil, fmt.Errorf("%q %w", hostname, errListedTwice)
}
names, err := parseList(list)
if err != nil {
return nil, err
}
if len(names) == 0 {
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
}
for i, name := range names {
if !slices.Contains(waf.AppNames(), name) {
return nil, fmt.Errorf("%q %w %s", name, errNotApp,
strings.Join(waf.AppNames(), ", "))
}
if slices.Contains(names[:i], name) {
return nil, fmt.Errorf("%q %w", name, errListedTwice)
}
}
apps[hostname] = names
}
return apps, nil
}
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set // parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
// rules, each a whole number above zero and outside firstSetupRuleID to // rules, each a whole number above zero and outside firstSetupRuleID to
// lastSetupRuleID. // lastSetupRuleID.
@@ -1961,7 +2013,6 @@ const (
ASNTagPrefix = "asn=" ASNTagPrefix = "asn="
CountryTagPrefix = "country=" CountryTagPrefix = "country="
DNSBLTagPrefix = "dnsbl=" DNSBLTagPrefix = "dnsbl="
PrefixTagPrefix = "prefix="
AbuseIPDBTag = "abuseipdb" AbuseIPDBTag = "abuseipdb"
CrowdSecTag = "crowdsec" CrowdSecTag = "crowdsec"
// ExploitSignatureTag is the tag of a request a rule file or the Core // ExploitSignatureTag is the tag of a request a rule file or the Core
@@ -2038,10 +2089,9 @@ func parseTags(value string) ([]string, error) {
// parseTag reads a tag in either case, with spaces around it, and returns it // parseTag reads a tag in either case, with spaces around it, and returns it
// as a request carries it: in lower case, but for a country code, which is in // as a request carries it: in lower case, but for a country code, which is in
// capitals, an asn= tag's number without leading zeros, and a prefix= tag's // capitals, and an asn= tag's number without leading zeros. An asn= or
// netblock as routewatch writes it. An asn=, country= or prefix= tag must name // country= tag must name a number or a country, so that a mistyped one stops
// a number, a country or a netblock, so that a mistyped one stops the start // the start instead of never matching.
// instead of never matching.
func parseTag(value string) (string, error) { func parseTag(value string) (string, error) {
tag := strings.ToLower(strings.TrimSpace(value)) tag := strings.ToLower(strings.TrimSpace(value))
@@ -2063,15 +2113,6 @@ func parseTag(value string) (string, error) {
return CountryTagPrefix + country, nil return CountryTagPrefix + country, nil
} }
if text, isPrefix := strings.CutPrefix(tag, PrefixTagPrefix); isPrefix {
netblock, err := netip.ParsePrefix(text)
if err != nil || netblock != netblock.Masked() {
return "", fmt.Errorf("%q %w", value, errNotPrefixTag)
}
return PrefixTagPrefix + netblock.String(), nil
}
return tag, nil return tag, nil
} }
@@ -2455,7 +2496,8 @@ func MaskZoneKey(zone string) string {
return masked + "." + rest return masked + "." + rest
} }
// isZone reports whether each label of zone is as parseZones takes it. // isZone reports whether each label of zone is as parseZones takes it. It
// also checks the hostnames of SWW_APPS, which are DNS names too.
func isZone(zone string) bool { func isZone(zone string) bool {
for label := range strings.SplitSeq(zone, ".") { for label := range strings.SplitSeq(zone, ".") {
badChar := strings.ContainsFunc(label, func(char rune) bool { badChar := strings.ContainsFunc(label, func(char rune) bool {
+89 -60
View File
@@ -61,7 +61,6 @@ const (
reputationAction = "SWW_REPUTATION_ACTION" reputationAction = "SWW_REPUTATION_ACTION"
reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL" reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
reputationTimeout = "SWW_REPUTATION_TIMEOUT" reputationTimeout = "SWW_REPUTATION_TIMEOUT"
routeWatchURL = "SWW_ROUTEWATCH_URL"
crowdSecURL = "SWW_CROWDSEC_LAPI_URL" crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY" crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
banResponse = "SWW_BAN_RESPONSE" banResponse = "SWW_BAN_RESPONSE"
@@ -89,6 +88,7 @@ const (
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS" wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_BODY_LIMIT" wafBodyLimit = "SWW_WAF_BODY_LIMIT"
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE" exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
apps = "SWW_APPS"
trapPaths = "SWW_TRAP_PATHS" trapPaths = "SWW_TRAP_PATHS"
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD" errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
logRemoteURL = "SWW_LOG_REMOTE_URL" logRemoteURL = "SWW_LOG_REMOTE_URL"
@@ -160,7 +160,7 @@ func loggedAnomalyDefaults() map[string]string {
// defaultAlertEvents is the default of SWW_ALERT_EVENTS, and // defaultAlertEvents is the default of SWW_ALERT_EVENTS, and
// defaultAlertCooldown that of SWW_ALERT_COOLDOWN. // defaultAlertCooldown that of SWW_ALERT_COOLDOWN.
const ( const (
defaultAlertEvents = "ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit," + defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
"source_failure,file_error" "source_failure,file_error"
defaultAlertCooldown = "15m" defaultAlertCooldown = "15m"
) )
@@ -582,6 +582,92 @@ func TestCoreRuleSetSettings(t *testing.T) {
} }
} }
func TestApps(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
env environment
want map[string][]string
}{
{environment{}, nil},
{environment{apps: ""}, map[string][]string{}},
{
environment{apps: "Git.Example.com:gitea; cloud.example.com : gitea "},
map[string][]string{
"git.example.com": {"gitea"}, "cloud.example.com": {"gitea"},
},
},
} {
cfg := fromEnvironment(t, tc.env)
if !reflect.DeepEqual(cfg.Apps, tc.want) {
t.Errorf("%v gave %#v, want %#v", tc.env, cfg.Apps, tc.want)
}
}
}
// Unset and set empty, SWW_APPS has opposite effects, so it logs differently.
func TestUnsetAndEmptyAppsAreLoggedDifferently(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
env environment
want string
}{
{environment{}, `"` + apps + `":null`},
{environment{apps: ""}, `"` + apps + `":""`},
} {
var out bytes.Buffer
slog.New(slog.NewJSONHandler(&out, nil)).
Info("starting", "settings", fromEnvironment(t, tc.env))
if !strings.Contains(out.String(), tc.want) {
t.Errorf("%v logged %s, want %s in it", tc.env, out.String(), tc.want)
}
}
}
func TestInvalidAppsStopsTheStart(t *testing.T) {
t.Parallel()
const (
notEntry = " is not a hostname, : and a list of apps, " +
"such as git.example.com:gitea"
notHostname = " is not a DNS name such as git.example.com"
)
for _, tc := range []struct{ value, want string }{
{"git.example.com:gitea;blog.example.com", `"blog.example.com"` + notEntry},
{"git.example.com:gitea;", `""` + notEntry},
{":gitea", `":gitea"` + notEntry},
{"git.example.com:", `"git.example.com:"` + notEntry},
{"*.example.com:gitea", `"*.example.com"` + notHostname},
{"git.example.com/:gitea", `"git.example.com/"` + notHostname},
{"git example.com:gitea", `"git example.com"` + notHostname},
{
"git.example.com:gitea,nextcloud",
`"nextcloud" is not an app sww has fixes for; the apps are gitea`,
},
{
"git.example.com:gitea;GIT.example.com:gitea",
`"git.example.com" is listed twice`,
},
{"git.example.com:gitea,gitea", `"gitea" is listed twice`},
{"git.example.com:gitea,", `"gitea," has an empty item in its list`},
} {
t.Run(tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{apps: tc.value}.lookupEnv)
want := apps + ": " + tc.want
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
}
}
func TestWAFBodyLimitOf64K(t *testing.T) { func TestWAFBodyLimitOf64K(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1404,63 +1490,6 @@ func TestTagSettingsAsSet(t *testing.T) {
} }
} }
func TestRouteWatchURLAndPrefixTags(t *testing.T) {
t.Parallel()
const rw = "https://routewatch.example"
if cfg := fromEnvironment(t, environment{}); cfg.RouteWatchURL != "" {
t.Errorf("default %s is %q, want unset", routeWatchURL, cfg.RouteWatchURL)
}
cfg := fromEnvironment(t, environment{
routeWatchURL: rw, tagDeny: "prefix=2001:DB8::/32",
tagLimitPercent: "prefix=192.0.2.0/24:50",
})
wantDeny := []string{"prefix=2001:db8::/32"}
if cfg.RouteWatchURL != rw || !slices.Equal(cfg.TagDeny, wantDeny) ||
cfg.TagLimitPercent["prefix=192.0.2.0/24"] != 50 {
t.Errorf("gave %q, %v, %v", cfg.RouteWatchURL, cfg.TagDeny, cfg.TagLimitPercent)
}
// routewatch gives asn= tags too, so an asn= tag needs no lookups while it is set.
fromEnvironment(t, environment{
lookupSource: off, routeWatchURL: rw, tagDeny: "asn=64496",
})
for _, stopped := range []struct {
env environment
name string
}{
{environment{routeWatchURL: "ftp://routewatch.example"}, routeWatchURL},
{environment{routeWatchURL: "routewatch.example"}, routeWatchURL},
{environment{tagDeny: "prefix=192.0.2.0/24"}, tagDeny},
{environment{routeWatchURL: rw, tagDeny: "prefix=nowhere"}, tagDeny},
{environment{routeWatchURL: rw, tagDeny: "prefix=192.0.2.5/24"}, tagDeny},
{
environment{routeWatchURL: rw, tagLimitPercent: "prefix=192.0.2.0/33:50"},
tagLimitPercent,
},
{environment{lookupSource: off, routeWatchURL: rw, tagDeny: "country=DE"}, tagDeny},
{environment{lookupSource: off, tagDeny: "asn=64496"}, tagDeny},
} {
_, err := config.FromEnvironment(stopped.env.lookupEnv)
if err == nil || !strings.HasPrefix(err.Error(), stopped.name+": ") {
t.Errorf("%v gave the error %v, want one naming %s", stopped.env, err, stopped.name)
}
}
badURL := environment{routeWatchURL: "ftp://routewatch.example"}
_, err := config.FromEnvironment(badURL.lookupEnv)
want := routeWatchURL + `: "ftp://routewatch.example" is not an http or https URL ` +
"without a user or a fragment, such as https://routewatch.example"
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
}
func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) { func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
t.Parallel() t.Parallel()
@@ -2561,7 +2590,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
reputationAction: "limit:25", reputationAction: "limit:25",
reputationCacheTTL: defaultReputationCacheTTL, reputationCacheTTL: defaultReputationCacheTTL,
reputationTimeout: "2s", reputationTimeout: "2s",
routeWatchURL: "",
crowdSecURL: "", crowdSecURL: "",
crowdSecKey: "", crowdSecKey: "",
banResponse: "403", banResponse: "403",
@@ -2589,6 +2617,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
wafExemptPaths: "", wafExemptPaths: "",
wafBodyLimit: off, wafBodyLimit: off,
exclusionsFile: "", exclusionsFile: "",
apps: "", // logged as null, which decodes as ""
trapPaths: "", trapPaths: "",
errorBurstThreshold: "30", errorBurstThreshold: "30",
logRemoteURL: "", logRemoteURL: "",
+1 -1
View File
@@ -50,7 +50,7 @@ func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
} }
return &verdict{ return &verdict{
action: requestlog.ActionClientDenied, action: requestlog.ActionDenied,
figures: map[string]any{ figures: map[string]any{
figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore, figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore,
}, },
+2 -2
View File
@@ -42,7 +42,7 @@ var (
// answerHealth answers a request for the health endpoint. // answerHealth answers a request for the health endpoint.
func (rq *request) answerHealth() { func (rq *request) answerHealth() {
rq.line.Action = requestlog.ActionOwnEndpoint rq.line.Action = requestlog.ActionAdmin
// Set here rather than left to Go's server, which would set it only // Set here rather than left to Go's server, which would set it only
// after the log line has taken the response's headers. // after the log line has taken the response's headers.
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8") rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
@@ -55,7 +55,7 @@ func (rq *request) answerHealth() {
// refused with 401. An endpoint whose token is unset answers 404, as any other // refused with 401. An endpoint whose token is unset answers 404, as any other
// path does. // path does.
func (rq *request) answerAdmin() { func (rq *request) answerAdmin() {
rq.line.Action = requestlog.ActionOwnEndpoint rq.line.Action = requestlog.ActionAdmin
token, answer := rq.endpoint() token, answer := rq.endpoint()
+11 -11
View File
@@ -40,7 +40,7 @@ func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
// An empty token does not match the unset one either. // An empty token does not match the unset one either.
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} { for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
for _, e := range adminEndpoints() { for _, e := range adminEndpoints() {
s.ownEndpointRequest(authorization, e.method, e.path, e.body, http.StatusNotFound) s.adminRequest(authorization, e.method, e.path, e.body, http.StatusNotFound)
} }
} }
@@ -67,7 +67,7 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
for _, authorization := range []string{ for _, authorization := range []string{
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret, "", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
} { } {
got := s.ownEndpointRequest(authorization, e.method, e.path, e.body, got := s.adminRequest(authorization, e.method, e.path, e.body,
http.StatusUnauthorized) http.StatusUnauthorized)
if got.header.Get("WWW-Authenticate") != "Bearer" { if got.header.Get("WWW-Authenticate") != "Bearer" {
t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer", t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer",
@@ -121,7 +121,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient, wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
http.StatusOK), want) http.StatusOK), want)
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
if line.BanExpires != requestlog.FormatTime(expires) { if line.BanExpires != requestlog.FormatTime(expires) {
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires) t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
} }
@@ -276,7 +276,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
// limit of two a minute, and bans it. // limit of two a minute, and bans it.
s.get(otherClient, http.StatusOK, requestlog.ActionForward) s.get(otherClient, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusOK, requestlog.ActionForward) s.get(otherClient, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
// Asked about by its address in IPv6 form too. // Asked about by its address in IPv6 form too.
for _, addr := range []string{otherClient, "::ffff:" + otherClient} { for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
@@ -329,7 +329,7 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
}) })
for _, e := range adminEndpoints() { for _, e := range adminEndpoints() {
s.ownEndpointRequest("", e.method, e.path, e.body, http.StatusUnauthorized) s.adminRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
} }
if held := server.Ledger.Snapshot(); len(held) != 0 { if held := server.Ledger.Snapshot(); len(held) != 0 {
@@ -356,16 +356,16 @@ func adminEndpoints() []adminEndpoint {
// admin sends a request with method for path, with body, to discriminator's own // admin sends a request with method for path, with body, to discriminator's own
// address, with the admin token, and checks that it is answered with status, its // address, with the admin token, and checks that it is answered with status, its
// log line's action own_endpoint. It returns the answer. // log line's action admin. It returns the answer.
func (s *sender) admin(method, path, body string, status int) answer { func (s *sender) admin(method, path, body string, status int) answer {
s.t.Helper() s.t.Helper()
return s.ownEndpointRequest(adminBearer, method, path, body, status) return s.adminRequest(adminBearer, method, path, body, status)
} }
// ownEndpointRequest is admin with authorization as the request's Authorization // adminRequest is admin with authorization as the request's Authorization header
// header unless it is "". // unless it is "".
func (s *sender) ownEndpointRequest( func (s *sender) adminRequest(
authorization, method, path, body string, status int, authorization, method, path, body string, status int,
) answer { ) answer {
s.t.Helper() s.t.Helper()
@@ -380,7 +380,7 @@ func (s *sender) ownEndpointRequest(
line := s.out.endedLines(s.t, s.sent+1)[s.sent] line := s.out.endedLines(s.t, s.sent+1)[s.sent]
s.sent++ s.sent++
wantLine(s.t, line, status, requestlog.ActionOwnEndpoint) wantLine(s.t, line, status, requestlog.ActionAdmin)
return got return got
} }
+7 -9
View File
@@ -31,14 +31,14 @@ func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
start := clk.Now() start := clk.Now()
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
netblock := netip.MustParsePrefix("203.0.113.0/24") netblock := netip.MustParsePrefix("203.0.113.0/24")
ban := server.Ledger.Bans(netblock)[0] ban := server.Ledger.Bans(netblock)[0]
// A request refused under the ban raises no other alert. // A request refused under the ban raises no other alert.
clk.advance(time.Minute) clk.advance(time.Minute)
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(client, http.StatusForbidden, requestlog.ActionBanned)
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{ wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{
Netblock: netblock, Cause: bans.CauseLimit, Netblock: netblock, Cause: bans.CauseLimit,
@@ -62,24 +62,22 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
// The probe bans the client for seven days, and its next request makes // The probe bans the client for seven days, and its next request makes
// the ban permanent. The request after that changes nothing. // the ban permanent. The request after that changes nothing.
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
attackBan := server.Ledger.Bans(netblock)[0] attackBan := server.Ledger.Bans(netblock)[0]
clk.advance(time.Minute) clk.advance(time.Minute)
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned) s.get(client, http.StatusForbidden, requestlog.ActionBanned)
permanentBan := server.Ledger.Bans(netblock)[0] permanentBan := server.Ledger.Bans(netblock)[0]
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned) s.get(client, http.StatusForbidden, requestlog.ActionBanned)
// Another client's probe after its first ban has run out without a // Another client's probe after its first ban has run out without a
// request makes a permanent ban at once. // request makes a permanent ban at once.
s.request(otherClient, "/.env", http.StatusForbidden, s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
requestlog.ActionTemporarilyBanned)
clk.advance(7 * 24 * time.Hour) clk.advance(7 * 24 * time.Hour)
s.request(otherClient, "/.env", http.StatusForbidden, s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
requestlog.ActionPermanentlyBanned)
otherBans := server.Ledger.Bans(other) otherBans := server.Ledger.Bans(other)
+2 -2
View File
@@ -73,7 +73,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) {
{anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}}, {anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}},
{anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil}, {anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil},
{ {
anomalyWatch, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet), anomalyWatch, anomaly.ScopeWatch, netip.MustParsePrefix(clientsNet),
"the named netblock office, " + clientsNet, map[string]any{"name": "office"}, "the named netblock office, " + clientsNet, map[string]any{"name": "office"},
}, },
} { } {
@@ -187,7 +187,7 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
{allowed, "/", http.StatusOK, requestlog.ActionForward}, {allowed, "/", http.StatusOK, requestlog.ActionForward},
{exempt, "/", http.StatusOK, requestlog.ActionForward}, {exempt, "/", http.StatusOK, requestlog.ActionForward},
{client, "/static/app.js", http.StatusOK, requestlog.ActionForward}, {client, "/static/app.js", http.StatusOK, requestlog.ActionForward},
{denied, "/", http.StatusForbidden, requestlog.ActionClientDenied}, {denied, "/", http.StatusForbidden, requestlog.ActionDenied},
} { } {
for range 3 { for range 3 {
s.request(sent.from, sent.path, sent.status, sent.action) s.request(sent.from, sent.path, sent.status, sent.action)
+25 -31
View File
@@ -36,8 +36,8 @@ const (
// tooLarge is how discriminator refuses a request over the header limits. // tooLarge is how discriminator refuses a request over the header limits.
tooLarge = http.StatusRequestHeaderFieldsTooLarge tooLarge = http.StatusRequestHeaderFieldsTooLarge
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in // silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
// that silentGeoJSURL names too. // that unansweredGeoJSURL names too.
silentAbuseIPDBURL = "silent://abuseipdb/api/v2/check" silentAbuseIPDBURL = "unanswered://abuseipdb/api/v2/check"
// wholeLimit is the percentage of a rate limit that lowers it not at all. // wholeLimit is the percentage of a rate limit that lowers it not at all.
wholeLimit = 100 wholeLimit = 100
// clientsDecision is the CrowdSec decision list holding client alone. // clientsDecision is the CrowdSec decision list holding client alone.
@@ -84,10 +84,10 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny) wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
line := s.out.requestLine(t) line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge) wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
if line.DurationCoreRuleSetMS != nil { if line.DurationWAF != nil {
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationCoreRuleSetMS) t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF)
} }
} }
@@ -157,7 +157,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
if tc.refused { if tc.refused {
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny) wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge) wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
return return
} }
@@ -165,7 +165,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow) wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
wantLine(t, line, http.StatusOK, requestlog.ActionForward) wantLine(t, line, http.StatusOK, requestlog.ActionForward)
if line.DurationCoreRuleSetMS == nil { if line.DurationWAF == nil {
t.Error("the Core Rule Set did not read a request within the limits") t.Error("the Core Rule Set did not read a request within the limits")
} }
}) })
@@ -219,10 +219,9 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow) wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
line := s.out.requestLine(t) line := s.out.requestLine(t)
if line.WouldAction != requestlog.ActionRequestHeaderTooLarge || if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil {
line.DurationCoreRuleSetMS != nil { t.Errorf("would_action %q and duration_waf %v, want %q and none",
t.Errorf("would_action %q and duration_core_rule_set_ms %v, want %q and none", line.WouldAction, line.DurationWAF, requestlog.ActionTooLarge)
line.WouldAction, line.DurationCoreRuleSetMS, requestlog.ActionRequestHeaderTooLarge)
} }
} }
@@ -241,7 +240,7 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny) wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny)
lines := s.out.requestLines(t, 2) lines := s.out.requestLines(t, 2)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionClientIsRateLimited) wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited)
} }
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) { func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
@@ -250,8 +249,8 @@ func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name, path, action string name, path, action string
}{ }{
{"a trap path", "/wp-login.php", requestlog.ActionTemporarilyBanned}, {"a trap path", "/wp-login.php", requestlog.ActionBanned},
{"a block rule", "/blocked", requestlog.ActionBlockedByBlockRule}, {"a block rule", "/blocked", requestlog.ActionRuleBlocked},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -281,10 +280,10 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny) wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
line := s.out.requestLine(t) line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge) wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
if line.RateLimitPercent != nil { if line.LimitPercent != nil {
t.Errorf("limit percentage %d, want none", *line.RateLimitPercent) t.Errorf("limit percentage %d, want none", *line.LimitPercent)
} }
// The client is under no ban, and GeoJS places it in a country. // The client is under no ban, and GeoJS places it in a country.
@@ -305,8 +304,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny) wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden, wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited)
requestlog.ActionClientIsRateLimited)
} }
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) { func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
@@ -340,7 +338,7 @@ func newDiscriminatorInBubble(
settings := map[string]string{tagDeny: "country=KP"} settings := map[string]string{tagDeny: "country=KP"}
maps.Copy(settings, env) maps.Copy(settings, env)
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now, settings) server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, settings)
return server, out return server, out
} }
@@ -414,37 +412,33 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
}{ }{
{ {
name: "SWW_DENY_NETS", env: map[string]string{denyNets: client}, name: "SWW_DENY_NETS", env: map[string]string{denyNets: client},
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionClientDenied, prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied,
}, },
{ {
name: "a ban", name: "a ban",
prepare: banClient, status: forbidden, action: requestlog.ActionTemporarilyBanned, prepare: banClient, status: forbidden, action: requestlog.ActionBanned,
}, },
{ {
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"}, name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
prepare: countClient, status: forbidden, prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
action: requestlog.ActionClientIsRateLimited,
}, },
{ {
name: "the rate limits, with a biased threshold", name: "the rate limits, with a biased threshold",
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"}, env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
prepare: countClient, status: forbidden, prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
action: requestlog.ActionClientIsRateLimited,
}, },
{ {
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL}, name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
prepare: listClient, status: forbidden, action: requestlog.ActionClientDenied, prepare: listClient, status: forbidden, action: requestlog.ActionDenied,
}, },
{ {
name: "the CrowdSec decision list", name: "the CrowdSec decision list",
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey}, env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
prepare: decideOnClient, status: forbidden, prepare: decideOnClient, status: forbidden, action: requestlog.ActionBanned,
action: requestlog.ActionTemporarilyBanned,
}, },
{ {
name: "the header limits", cookie: cookies(cookieLimit + 1), name: "the header limits", cookie: cookies(cookieLimit + 1),
prepare: leaveAsItIs, status: tooLarge, prepare: leaveAsItIs, status: tooLarge, action: requestlog.ActionTooLarge,
action: requestlog.ActionRequestHeaderTooLarge,
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
+12 -26
View File
@@ -50,23 +50,13 @@ func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
} }
return &verdict{ return &verdict{
action: banAction(ban.Permanent()), action: requestlog.ActionBanned,
figures: map[string]any{ figures: map[string]any{
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires, figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
}, },
} }
} }
// banAction is the action of a request refused under a ban or for making one:
// permanent says the ban never runs out.
func banAction(permanent bool) string {
if permanent {
return requestlog.ActionPermanentlyBanned
}
return requestlog.ActionTemporarilyBanned
}
// limitFigures are the figures of a heuristic that broke hit, a limit: its // limitFigures are the figures of a heuristic that broke hit, a limit: its
// window, the count and the limit. // window, the count and the limit.
func limitFigures(hit ratelimit.Hit) map[string]any { func limitFigures(hit ratelimit.Hit) map[string]any {
@@ -106,7 +96,7 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
case ratelimit.KindBytes: case ratelimit.KindBytes:
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
case ratelimit.KindRefusals: case ratelimit.KindRefusals:
rq.line.LimitHit = requestlog.LimitHitMinuteRefusals rq.line.LimitHit = requestlog.LimitHitErrorBurst
default: default:
rq.line.LimitHit = hit.Window rq.line.LimitHit = hit.Window
} }
@@ -134,9 +124,9 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
switch hit.Kind { switch hit.Kind {
case ratelimit.KindRequests: case ratelimit.KindRequests:
notes.LimitPercent, notes.LimitPercentSetting = rq.rateLimitPercent.logged() notes.LimitPercent, notes.LimitPercentSetting = rq.limitPercent.logged()
case ratelimit.KindBytes: case ratelimit.KindBytes:
notes.LimitPercent, notes.LimitPercentSetting = rq.bytesLimitPercent.logged() notes.LimitPercent, notes.LimitPercentSetting = rq.bytesPercent.logged()
} }
if rq.h.config.Observe { if rq.h.config.Observe {
@@ -161,11 +151,11 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
// attack, which notes name: the ban rule that matched, or the trap path // attack, which notes name: the ban rule that matched, or the trap path
// asked for. It fills in the rest of the notes. In observe mode it makes // asked for. It fills in the rest of the notes. In observe mode it makes
// no ban, and raises the alert for the ban it would have made, if that // no ban, and raises the alert for the ban it would have made, if that
// alert would be sent. It returns the action for that ban. // alert would be sent.
func (rq *request) banForAttack(now time.Time, notes bans.Notes) string { func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
netblock := rq.h.netblock(rq.client) netblock := rq.h.netblock(rq.client)
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) { if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseAttack)) return
} }
notes.ASN = rq.line.ASN notes.ASN = rq.line.ASN
@@ -181,7 +171,7 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
rq.alertBan(ban) rq.alertBan(ban)
} }
return banAction(ban.Permanent()) return
} }
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes) ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
@@ -190,18 +180,16 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
if made { if made {
rq.alertBan(ban) rq.alertBan(ban)
} }
return banAction(ban.Permanent())
} }
// banForCrowdSec bans the client's netblock at now until decision, // banForCrowdSec bans the client's netblock at now until decision,
// CrowdSec's decision on the client, ends. In observe mode it makes no // CrowdSec's decision on the client, ends. In observe mode it makes no
// ban, and raises the alert for the ban it would have made, if that alert // ban, and raises the alert for the ban it would have made, if that alert
// would be sent. It returns the action for that ban. // would be sent.
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) string { func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
netblock := rq.h.netblock(rq.client) netblock := rq.h.netblock(rq.client)
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) { if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseCrowdSec)) return
} }
notes := bans.Notes{ notes := bans.Notes{
@@ -220,7 +208,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) s
rq.alertBan(ban) rq.alertBan(ban)
} }
return banAction(ban.Permanent()) return
} }
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires, ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
@@ -230,8 +218,6 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) s
if made { if made {
rq.alertBan(ban) rq.alertBan(ban)
} }
return banAction(ban.Permanent())
} }
// wouldAlertBan reports whether the alert for a ban on netblock for cause // wouldAlertBan reports whether the alert for a ban on netblock for cause
+25 -27
View File
@@ -40,7 +40,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
// client for an hour, the default. // client for an hour, the default.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited) line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit || if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
line.BanExpires != expires { line.BanExpires != expires {
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+ t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
@@ -51,7 +51,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
// Every request while the ban lasts is refused. // Every request while the ban lasts is refused.
clk.advance(time.Hour - time.Second) clk.advance(time.Hour - time.Second)
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" { if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" {
t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+ t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+
"want %s and neither of the others", line.BanExpires, line.Offence, "want %s and neither of the others", line.BanExpires, line.Offence,
@@ -78,9 +78,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
breakLimit := func() string { breakLimit := func() string {
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited) return s.get(client, http.StatusForbidden, requestlog.ActionRateLimited).BanExpires
return line.BanExpires
} }
wantExpires := func(got string, length time.Duration) { wantExpires := func(got string, length time.Duration) {
t.Helper() t.Helper()
@@ -113,7 +111,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
} }
clk.advance(365 * 24 * time.Hour) clk.advance(365 * 24 * time.Hour)
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned) s.get(client, http.StatusForbidden, requestlog.ActionBanned)
} }
func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) { func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
@@ -125,10 +123,10 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
// client for an hour. // client for an hour.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
for range 3 { for range 3 {
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(client, http.StatusForbidden, requestlog.ActionBanned)
} }
// Later the same day the client has its whole allowance again: the // Later the same day the client has its whole allowance again: the
@@ -137,7 +135,7 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
clk.advance(time.Hour) clk.advance(time.Hour)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32")) banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
if len(banned) != 2 || banned[0].Notes.Refused != 3 { if len(banned) != 2 || banned[0].Notes.Refused != 3 {
@@ -192,10 +190,10 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
s, _, _ := startWithClock(t, "", env) s, _, _ := startWithClock(t, "", env)
s.get(tc.breaker, http.StatusOK, requestlog.ActionForward) s.get(tc.breaker, http.StatusOK, requestlog.ActionForward)
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(tc.breaker, http.StatusForbidden, requestlog.ActionRateLimited)
for _, sent := range tc.refused { for _, sent := range tc.refused {
s.get(sent, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(sent, http.StatusForbidden, requestlog.ActionBanned)
} }
for _, sent := range tc.let { for _, sent := range tc.let {
@@ -218,9 +216,9 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
// fromDE's ban covers otherClient, which is refused unasked about. // fromDE's ban covers otherClient, which is refused unasked about.
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned) line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
if line.Country != "" { if line.Country != "" {
t.Errorf("log line has country %q, want none", line.Country) t.Errorf("log line has country %q, want none", line.Country)
} }
@@ -239,9 +237,9 @@ func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
expires := requestlog.FormatTime(clk.Now().Add(time.Hour)) expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned) line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
wantHeuristic(t, line, "active_ban", wantHeuristic(t, line, "active_ban",
`{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`) `{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`)
} }
@@ -277,11 +275,11 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
s, _, _ := startWithClock(t, geojsURL, env) s, _, _ := startWithClock(t, geojsURL, env)
s.get(denied, tc.status, requestlog.ActionClientDenied) s.get(denied, tc.status, requestlog.ActionDenied)
s.get(fromKP, tc.status, requestlog.ActionClientDenied) s.get(fromKP, tc.status, requestlog.ActionDenied)
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, tc.status, requestlog.ActionClientIsRateLimited) s.get(fromDE, tc.status, requestlog.ActionRateLimited)
s.get(fromDE, tc.status, requestlog.ActionTemporarilyBanned) s.get(fromDE, tc.status, requestlog.ActionBanned)
}) })
} }
} }
@@ -299,9 +297,9 @@ func TestBanNotes(t *testing.T) {
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.request(fromDE, "/repo/commits?page=2", s.request(fromDE, "/repo/commits?page=2",
http.StatusForbidden, requestlog.ActionClientIsRateLimited) http.StatusForbidden, requestlog.ActionRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
netblock := netip.MustParsePrefix(fromDE + "/32") netblock := netip.MustParsePrefix(fromDE + "/32")
want := bans.Ban{ want := bans.Ban{
@@ -344,7 +342,7 @@ func TestBanNotes(t *testing.T) {
// The next ban counts this one among the earlier. // The next ban counts this one among the earlier.
clk.advance(time.Hour) clk.advance(time.Hour)
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
got = ledger.Bans(netblock) got = ledger.Bans(netblock)
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
@@ -362,12 +360,12 @@ func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) {
// One ban is held, so otherClient's ban drops client's. // One ban is held, so otherClient's ban drops client's.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(otherClient, http.StatusOK, requestlog.ActionForward) s.get(otherClient, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
} }
// clock is the time a test sets, by which sww counts requests and // clock is the time a test sets, by which sww counts requests and
@@ -415,7 +413,7 @@ func startWithClock(
// sender sends requests to rproxy one after another, each on a connection of its // sender sends requests to rproxy one after another, each on a connection of its
// own, and checks each one's answer and log line. discriminatorAddr is where // own, and checks each one's answer and log line. discriminatorAddr is where
// discriminator's own endpoints are, which ownEndpointRequest asks. Every request // discriminator's own endpoints are, which adminRequest asks. Every request
// discriminator gets must come through the sender, since the log lines are // discriminator gets must come through the sender, since the log lines are
// matched to them in order. // matched to them in order.
type sender struct { type sender struct {
+27 -35
View File
@@ -76,19 +76,17 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
}{ }{
{http.StatusOK, requestlog.ActionForward}, {http.StatusOK, requestlog.ActionForward},
{http.StatusOK, requestlog.ActionForward}, {http.StatusOK, requestlog.ActionForward},
{http.StatusForbidden, requestlog.ActionClientIsRateLimited}, {http.StatusForbidden, requestlog.ActionRateLimited},
} { } {
line := s.get(tc.from, sent.status, sent.action) line := s.get(tc.from, sent.status, sent.action)
wantPercent(t, "rate_limit_percent", wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
line.RateLimitPercent, line.RateLimitPercentSetting,
"50 from "+tc.setting) "50 from "+tc.setting)
} }
// fromKP, which no setting lists, has the whole limit. // fromKP, which no setting lists, has the whole limit.
for range 3 { for range 3 {
line := s.get(fromKP, http.StatusOK, requestlog.ActionForward) line := s.get(fromKP, http.StatusOK, requestlog.ActionForward)
wantPercent(t, "rate_limit_percent", wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
line.RateLimitPercent, line.RateLimitPercentSetting,
none) none)
} }
}) })
@@ -121,8 +119,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes) t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
} }
wantPercent(t, "bytes_limit_percent", wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
line.BytesLimitPercent, line.BytesLimitPercentSetting,
"50 from "+tc.setting) "50 from "+tc.setting)
// fromKP, which no setting lists, has the whole limit. // fromKP, which no setting lists, has the whole limit.
@@ -131,8 +128,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit) t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
} }
wantPercent(t, "bytes_limit_percent", wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, none)
line.BytesLimitPercent, line.BytesLimitPercentSetting, none)
}) })
} }
} }
@@ -143,9 +139,9 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
for _, tc := range []struct { for _, tc := range []struct {
name string name string
env map[string]string env map[string]string
// rateLimitPercent and bytesLimitPercent are the log line's, as // limitPercent and bytesPercent are the log line's, as percentText
// percentText gives them, and limitHit is its limit_hit. // gives them, and limitHit is its limit_hit.
rateLimitPercent, bytesLimitPercent, limitHit string limitPercent, bytesPercent, limitHit string
}{ }{
{ {
"lowering the byte limits alone", "lowering the byte limits alone",
@@ -181,12 +177,10 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit) t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit)
} }
wantPercent(t, "rate_limit_percent", wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
line.RateLimitPercent, line.RateLimitPercentSetting, tc.limitPercent)
tc.rateLimitPercent) wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
wantPercent(t, "bytes_limit_percent", tc.bytesPercent)
line.BytesLimitPercent, line.BytesLimitPercentSetting,
tc.bytesLimitPercent)
}) })
} }
} }
@@ -198,15 +192,15 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
// The first request breaks the limit, and bans the client; the log line // The first request breaks the limit, and bans the client; the log line
// gives the 0. // gives the 0.
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionClientIsRateLimited) line := s.get(unplaced, http.StatusForbidden, requestlog.ActionRateLimited)
if line.fields["rate_limit_percent"] != float64(0) || if line.fields["limit_percent"] != float64(0) ||
line.fields["rate_limit_percent_setting"] != unknownLimitPercent { line.fields["limit_percent_setting"] != unknownLimitPercent {
t.Errorf("log line has rate_limit_percent %v from %v, want 0 from %s", t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
line.fields["rate_limit_percent"], line.fields["rate_limit_percent_setting"], line.fields["limit_percent"], line.fields["limit_percent_setting"],
unknownLimitPercent) unknownLimitPercent)
} }
s.get(unplaced, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(unplaced, http.StatusForbidden, requestlog.ActionBanned)
} }
func TestLowestPercentageApplies(t *testing.T) { func TestLowestPercentageApplies(t *testing.T) {
@@ -216,7 +210,7 @@ func TestLowestPercentageApplies(t *testing.T) {
name string name string
env map[string]string env map[string]string
from string from string
// want is the log line's rate_limit_percent, as percentText gives it. // want is the log line's limit_percent, as percentText gives it.
want string want string
}{ }{
{ {
@@ -256,9 +250,8 @@ func TestLowestPercentageApplies(t *testing.T) {
// A quarter of 4 requests a minute: the second breaks the limit. // A quarter of 4 requests a minute: the second breaks the limit.
s.get(tc.from, http.StatusOK, requestlog.ActionForward) s.get(tc.from, http.StatusOK, requestlog.ActionForward)
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionClientIsRateLimited) line := s.get(tc.from, http.StatusForbidden, requestlog.ActionRateLimited)
wantPercent(t, "rate_limit_percent", wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.want) tc.want)
}) })
} }
@@ -276,7 +269,7 @@ func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(t *test
for _, from := range []string{unplaced, "10.0.0.8"} { for _, from := range []string{unplaced, "10.0.0.8"} {
s.get(from, http.StatusOK, requestlog.ActionForward) s.get(from, http.StatusOK, requestlog.ActionForward)
s.get(from, http.StatusOK, requestlog.ActionForward) s.get(from, http.StatusOK, requestlog.ActionForward)
s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(from, http.StatusForbidden, requestlog.ActionRateLimited)
} }
// One in a country has the whole limit. // One in a country has the whole limit.
@@ -291,7 +284,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers. // says, with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now, server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
map[string]string{unknownLimitPercent: "0"}) map[string]string{unknownLimitPercent: "0"})
// Once the time the request waits for its answer is up, the client // Once the time the request waits for its answer is up, the client
@@ -300,9 +293,8 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
serveFromDE(t, server, http.MethodGet, http.NoBody) serveFromDE(t, server, http.MethodGet, http.NoBody)
line := out.requestLine(t) line := out.requestLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited) wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited)
wantPercent(t, "rate_limit_percent", wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
line.RateLimitPercent, line.RateLimitPercentSetting,
"0 from "+unknownLimitPercent) "0 from "+unknownLimitPercent)
}) })
} }
@@ -335,7 +327,7 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers. // says, with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now, server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
map[string]string{ map[string]string{
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value, lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
}) })
@@ -382,7 +374,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
// A quarter of 12 requests a minute is 3: the fourth breaks it. // A quarter of 12 requests a minute is 3: the fourth breaks it.
"a rate limit", "a rate limit",
map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter}, map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
3, http.StatusForbidden, requestlog.ActionClientIsRateLimited, 3, http.StatusForbidden, requestlog.ActionRateLimited,
"requests per minute over the limit of 3", "25 from " + tagLimitPercent, "requests per minute over the limit of 3", "25 from " + tagLimitPercent,
}, },
{ {
+1 -1
View File
@@ -28,7 +28,7 @@ func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
} }
return &verdict{ return &verdict{
action: requestlog.ActionClientDenied, action: requestlog.ActionDenied,
figures: map[string]any{"lists": listedBy}, figures: map[string]any{"lists": listedBy},
} }
} }
+4 -2
View File
@@ -6,6 +6,8 @@ package discriminator
import ( import (
"context" "context"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// byteLimits counts the request's bytes, as countedBytes gives them, for the // byteLimits counts the request's bytes, as countedBytes gives them, for the
@@ -26,7 +28,7 @@ func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict
} }
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now, counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
rq.countedBytes(), rq.bytesLimitPercent.percent) rq.countedBytes(), rq.bytesPercent.percent)
rq.line.Counts.MinuteBytes = counts.MinuteBytes rq.line.Counts.MinuteBytes = counts.MinuteBytes
rq.line.Counts.HourBytes = counts.HourBytes rq.line.Counts.HourBytes = counts.HourBytes
rq.line.Counts.DayBytes = counts.DayBytes rq.line.Counts.DayBytes = counts.DayBytes
@@ -37,5 +39,5 @@ func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict
rq.banForLimit(now, hit, rq.out.status) rq.banForLimit(now, hit, rq.out.status)
return &verdict{figures: limitFigures(hit)} return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
} }
+6 -6
View File
@@ -90,7 +90,7 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
line.BanExpires, tc.window, expires) line.BanExpires, tc.window, expires)
} }
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(client, http.StatusForbidden, requestlog.ActionBanned)
wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+ wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1) `instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
@@ -125,7 +125,7 @@ func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes) len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
} }
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(client, http.StatusForbidden, requestlog.ActionBanned)
} }
func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) { func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
@@ -147,7 +147,7 @@ func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
answerBytes, minuteBytes, expires) answerBytes, minuteBytes, expires)
} }
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(client, http.StatusForbidden, requestlog.ActionBanned)
} }
func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) { func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
@@ -176,7 +176,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted) line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
} }
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(client, http.StatusForbidden, requestlog.ActionBanned)
}) })
} }
} }
@@ -218,7 +218,7 @@ func TestBytesCountSaysWhichBytesCount(t *testing.T) {
} }
} }
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(client, http.StatusForbidden, requestlog.ActionBanned)
}) })
} }
} }
@@ -386,7 +386,7 @@ func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T)
// counted, and the byte limit is not broken. Its line gives the bytes // counted, and the byte limit is not broken. Its line gives the bytes
// counted before it. // counted before it.
line := s.upload() line := s.upload()
wantWouldAction(t, line, requestlog.ActionClientIsRateLimited) wantWouldAction(t, line, requestlog.ActionRateLimited)
if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes { if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes {
t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d", t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d",
+13 -13
View File
@@ -9,8 +9,8 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
) )
// coreRuleSet inspects the request with the Core Rule Set, unless // coreRuleSet inspects the request with the Core Rule Set, unless
@@ -18,7 +18,7 @@ import (
// pathExempt decides, and notes the rules it matched and its score in the // pathExempt decides, and notes the rules it matched and its score in the
// log line, and the rules in the metrics. A score at or over // log line, and the rules in the metrics. A score at or over
// SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature, // SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature,
// raises the core_rule_set_match alert, and in block mode refuses the request with 403, // raises the waf_block alert, and in block mode refuses the request with 403,
// which is an offence its client's history counts. Its figures are the score // which is an offence its client's history counts. Its figures are the score
// and the threshold. // and the threshold.
type coreRuleSet struct{} type coreRuleSet struct{}
@@ -38,12 +38,12 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
result, read, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.in.Body) result, read, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.in.Body)
rq.requestBytes = int64(len(read)) rq.requestBytes = int64(len(read))
rq.line.DurationCoreRuleSetMS = new(requestlog.Milliseconds(time.Since(start))) rq.line.DurationWAF = new(requestlog.Milliseconds(time.Since(start)))
rq.line.CoreRuleSetRuleIDs = result.RuleIDs rq.line.WAFRuleIDs = result.RuleIDs
rq.line.CoreRuleSetScore = &result.Score rq.line.WAFScore = &result.Score
for _, id := range result.RuleIDs { for _, id := range result.RuleIDs {
rq.h.metrics.CoreRuleSetMatched(cfg.WAFMode, id) rq.h.metrics.WAFMatched(cfg.WAFMode, id)
} }
threshold := cfg.WAFAnomalyThreshold threshold := cfg.WAFAnomalyThreshold
@@ -52,26 +52,26 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
} }
rq.addTag(config.ExploitSignatureTag) rq.addTag(config.ExploitSignatureTag)
rq.alertCoreRuleSetMatch(result) rq.alertWAFBlock(result)
if cfg.WAFMode == config.WAFModeDetect { if cfg.WAFMode == config.WAFModeDetect {
return nil return nil
} }
rq.blockedByCoreRuleSet = true rq.wafBlocked = true
return &verdict{ return &verdict{
action: requestlog.ActionBlockedByCoreRuleSet, action: requestlog.ActionWAFBlocked,
figures: map[string]any{figureScore: result.Score, "threshold": threshold}, figures: map[string]any{figureScore: result.Score, "threshold": threshold},
} }
} }
// alertCoreRuleSetMatch raises the core_rule_set_match alert for the request, // alertWAFBlock raises the waf_block alert for the request, which the Core
// which the Core Rule Set scored at result, at or over SWW_WAF_ANOMALY_THRESHOLD. Its // Rule Set scored at result, at or over SWW_WAF_ANOMALY_THRESHOLD. Its
// detail gives the rule ids, the score, the method and the path with the // detail gives the rule ids, the score, the method and the path with the
// query, and, for a request that is not refused for it, the mode: detect, // query, and, for a request that is not refused for it, the mode: detect,
// or observe in observe mode. // or observe in observe mode.
func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) { func (rq *request) alertWAFBlock(result waf.Result) {
detail := map[string]any{ detail := map[string]any{
"rule_ids": result.RuleIDs, "rule_ids": result.RuleIDs,
"score": result.Score, "score": result.Score,
@@ -87,7 +87,7 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
} }
rq.h.alerts.Raise(alerts.Alert{ rq.h.alerts.Raise(alerts.Alert{
Event: alerts.EventCoreRuleSetMatch, Event: alerts.EventWAFBlock,
Host: rq.site, Host: rq.site,
Client: rq.client, Client: rq.client,
Netblock: rq.h.clientGroup(rq.client), Netblock: rq.h.clientGroup(rq.client),
+75 -61
View File
@@ -10,6 +10,7 @@ import (
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
@@ -21,6 +22,7 @@ const (
wafDisabledRules = "SWW_WAF_DISABLED_RULES" wafDisabledRules = "SWW_WAF_DISABLED_RULES"
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS" wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_BODY_LIMIT" wafBodyLimit = "SWW_WAF_BODY_LIMIT"
apps = "SWW_APPS"
block = "block" block = "block"
detect = "detect" detect = "detect"
) )
@@ -40,30 +42,24 @@ const (
// SWW_WAF_ANOMALY_THRESHOLD. // SWW_WAF_ANOMALY_THRESHOLD.
const sqlInjection = "/?id=1'%20OR%20'1'='1" const sqlInjection = "/?id=1'%20OR%20'1'='1"
// wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and // wantWAF checks the request log line's waf_rule_ids and waf_score, and
// core_rule_set_score, and that it has duration_core_rule_set_ms, or with no // that it has duration_waf, or with no score, that it has none of the
// score, that it has none of the three: the Core Rule Set did not inspect the // three: the Core Rule Set did not inspect the request.
// request. func wantWAF(t *testing.T, line logLine, score *int, ruleIDs ...int) {
func wantCoreRuleSet(t *testing.T, line logLine, score *int, ruleIDs ...int) {
t.Helper() t.Helper()
if !slices.Equal(line.CoreRuleSetRuleIDs, ruleIDs) { if !slices.Equal(line.WAFRuleIDs, ruleIDs) {
t.Errorf("log line has core_rule_set_rule_ids %v, want %v", t.Errorf("log line has waf_rule_ids %v, want %v", line.WAFRuleIDs, ruleIDs)
line.CoreRuleSetRuleIDs, ruleIDs)
} }
switch { switch {
case score == nil && case score == nil && (line.WAFScore != nil || line.DurationWAF != nil):
(line.CoreRuleSetScore != nil || line.DurationCoreRuleSetMS != nil): t.Errorf("log line has waf_score %v and duration_waf %v, want neither",
t.Errorf("log line has core_rule_set_score %v and "+ line.fields["waf_score"], line.fields["duration_waf"])
"duration_core_rule_set_ms %v, want neither", case score != nil && (line.WAFScore == nil || *line.WAFScore != *score):
line.fields["core_rule_set_score"], line.fields["duration_core_rule_set_ms"]) t.Errorf("log line has waf_score %v, want %d", line.fields["waf_score"], *score)
case score != nil && case score != nil && line.DurationWAF == nil:
(line.CoreRuleSetScore == nil || *line.CoreRuleSetScore != *score): t.Error("log line has no duration_waf")
t.Errorf("log line has core_rule_set_score %v, want %d",
line.fields["core_rule_set_score"], *score)
case score != nil && line.DurationCoreRuleSetMS == nil:
t.Error("log line has no duration_core_rule_set_ms")
} }
} }
@@ -76,7 +72,7 @@ func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
status int status int
action string action string
}{ }{
{block, block, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet}, {block, block, http.StatusForbidden, requestlog.ActionWAFBlocked},
{detect, detect, http.StatusOK, requestlog.ActionForward}, {detect, detect, http.StatusOK, requestlog.ActionForward},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -98,8 +94,7 @@ func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
wafMode: detect, tagDeny: "exploitsignature", wafMode: detect, tagDeny: "exploitsignature",
}) })
line := s.request(client, sqlInjection, http.StatusForbidden, line := s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionDenied)
requestlog.ActionClientDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`) wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
} }
@@ -139,14 +134,14 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
mode, action string mode, action string
status int status int
}{ }{
{block, requestlog.ActionBlockedByCoreRuleSet, http.StatusForbidden}, {block, requestlog.ActionWAFBlocked, http.StatusForbidden},
{detect, requestlog.ActionForward, http.StatusOK}, {detect, requestlog.ActionForward, http.StatusOK},
} { } {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode}) s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
line := s.requestWithHeader(client, attack.path, attack.header, line := s.requestWithHeader(client, attack.path, attack.header,
tc.status, tc.action) tc.status, tc.action)
wantCoreRuleSet(t, line, &attack.score, attack.ruleIDs...) wantWAF(t, line, &attack.score, attack.ruleIDs...)
} }
}) })
} }
@@ -158,7 +153,7 @@ func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block}) s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line := s.request(client, sqlInjection, http.StatusForbidden, line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet) requestlog.ActionWAFBlocked)
wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`) wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`)
} }
@@ -169,7 +164,7 @@ func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source", line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source",
http.StatusOK, requestlog.ActionForward) http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, new(0)) wantWAF(t, line, new(0))
} }
func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal( func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
@@ -189,26 +184,26 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
// A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS // A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS
// exempts, are not inspected. // exempts, are not inspected.
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward) line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, nil) wantWAF(t, line, nil)
line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK, line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK,
requestlog.ActionForward) requestlog.ActionForward)
wantCoreRuleSet(t, line, nil) wantWAF(t, line, nil)
// The prefix is matched as rate limit exempt paths are: a path that // The prefix is matched as rate limit exempt paths are: a path that
// goes up and out of it is inspected. // goes up and out of it is inspected.
line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden, line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet) requestlog.ActionWAFBlocked)
wantCoreRuleSet(t, line, new(25), 930100, 930110, 942100) wantWAF(t, line, new(25), 930100, 930110, 942100)
// A request a rule file refuses is not inspected. // A request a rule file refuses is not inspected.
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden, line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
requestlog.ActionBlockedByBlockRule) requestlog.ActionRuleBlocked)
wantCoreRuleSet(t, line, nil) wantWAF(t, line, nil)
// With SWW_WAF_MODE off, no request is. // With SWW_WAF_MODE off, no request is.
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off}) s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, nil) wantWAF(t, line, nil)
} }
func TestAnomalyThreshold(t *testing.T) { func TestAnomalyThreshold(t *testing.T) {
@@ -222,14 +217,13 @@ func TestAnomalyThreshold(t *testing.T) {
}) })
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, new(5), 942100) wantWAF(t, line, new(5), 942100)
} }
s, _, _ := startWithClock(t, "", map[string]string{ s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block, wafAnomalyThreshold: "5", wafMode: block, wafAnomalyThreshold: "5",
}) })
s.request(client, sqlInjection, http.StatusForbidden, s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked)
requestlog.ActionBlockedByCoreRuleSet)
} }
func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) { func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
@@ -272,7 +266,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block}) s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line, _ := s.requestWithBody(request.method, client, request.path, line, _ := s.requestWithBody(request.method, client, request.path,
request.header, body, http.StatusOK, requestlog.ActionForward) request.header, body, http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, new(0)) wantWAF(t, line, new(0))
// A list given replaces the default. // A list given replaces the default.
s, _, _ = startWithClock(t, "", map[string]string{ s, _, _ = startWithClock(t, "", map[string]string{
@@ -280,16 +274,37 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
}) })
score := 5 * len(request.ruleIDs) score := 5 * len(request.ruleIDs)
line, _ = s.requestWithBody(request.method, client, request.path, line, _ = s.requestWithBody(request.method, client, request.path,
request.header, body, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet) request.header, body, http.StatusForbidden, requestlog.ActionWAFBlocked)
wantCoreRuleSet(t, line, &score, request.ruleIDs...) wantWAF(t, line, &score, request.ruleIDs...)
// And switches off the rules it lists. // And switches off the rules it lists.
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, new(0)) wantWAF(t, line, new(0))
}) })
} }
} }
func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
t.Parallel()
// Only gitea's fixes let a file name through in path, a parameter
// gitea sends file names in.
const fileName = "/?path=.gitignore"
s := startDiscriminator(t, "", map[string]string{
wafMode: block, apps: siteA + ":gitea",
})
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
http.StatusOK, discriminator.DispositionAllow)
wantDisposition(t, do(t, s.question(siteB, client, fileName)),
http.StatusForbidden, discriminator.DispositionDeny)
lines := s.out.requestLines(t, 2)
wantLine(t, lines[0], http.StatusOK, requestlog.ActionForward)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionWAFBlocked)
}
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) { func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
t.Parallel() t.Parallel()
@@ -301,14 +316,14 @@ func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block}) s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body, line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
http.StatusOK, requestlog.ActionForward) http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, new(0)) wantWAF(t, line, new(0))
s, _, _ = startWithClock(t, "", map[string]string{ s, _, _ = startWithClock(t, "", map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting, wafMode: block, wafBodyLimit: sizeLimitSetting,
}) })
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body, line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet) http.StatusForbidden, requestlog.ActionWAFBlocked)
wantCoreRuleSet(t, line, new(5), 942100) wantWAF(t, line, new(5), 942100)
} }
func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) { func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
@@ -398,33 +413,32 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
}) })
for range 2 { for range 2 {
s.request(client, sqlInjection, http.StatusForbidden, s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked)
requestlog.ActionBlockedByCoreRuleSet)
} }
// The third refusal in a minute breaks the error burst, and bans the // The third refusal in a minute breaks the error burst, and bans the
// client. // client.
line := s.request(client, sqlInjection, http.StatusForbidden, line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet) requestlog.ActionWAFBlocked)
if line.LimitHit != requestlog.LimitHitMinuteRefusals || if line.LimitHit != requestlog.LimitHitErrorBurst ||
line.Offence != requestlog.OffenceLimit { line.Offence != requestlog.OffenceLimit {
t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals and limit", t.Errorf("log line has limit_hit %q and offence %q, want error_burst and limit",
line.LimitHit, line.Offence) line.LimitHit, line.Offence)
} }
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(client, http.StatusForbidden, requestlog.ActionBanned)
want := ratelimit.Offences{Limit: 1, BlockedByCoreRuleSet: 3} want := ratelimit.Offences{Limit: 1, WAFBlocked: 3}
if offences := historyOf(t, server, client).Offences; offences != want { if offences := historyOf(t, server, client).Offences; offences != want {
t.Errorf("history counts the offences %+v, want %+v", offences, want) t.Errorf("history counts the offences %+v, want %+v", offences, want)
} }
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`sww_core_rule_set_matches_total{instance="app",mode="block",rule_id="942100"}`, 3) `sww_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
wantMetric(t, metrics, wantMetric(t, metrics,
`sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`, 3) `sww_offences_total{instance="app",kind="waf_blocked"}`, 3)
wantMetric(t, metrics, `sww_requests_total{action="blocked_by_core_rule_set",`+ wantMetric(t, metrics, `sww_requests_total{action="waf_blocked",`+
`instance="app",status_class="4xx"}`, 3) `instance="app",status_class="4xx"}`, 3)
} }
@@ -448,9 +462,9 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`sww_core_rule_set_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3) `sww_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
wantNoSeries(t, metrics, wantNoSeries(t, metrics,
`sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`) `sww_offences_total{instance="app",kind="waf_blocked"}`)
} }
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) { func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
@@ -459,17 +473,17 @@ func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe}) s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe})
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBlockedByCoreRuleSet) wantWouldAction(t, line, requestlog.ActionWAFBlocked)
wantCoreRuleSet(t, line, new(5), 942100) wantWAF(t, line, new(5), 942100)
// It is an offence as in enforce mode. // It is an offence as in enforce mode.
want := ratelimit.Offences{BlockedByCoreRuleSet: 1} want := ratelimit.Offences{WAFBlocked: 1}
if offences := historyOf(t, server, client).Offences; offences != want { if offences := historyOf(t, server, client).Offences; offences != want {
t.Errorf("history counts the offences %+v, want %+v", offences, want) t.Errorf("history counts the offences %+v, want %+v", offences, want)
} }
} }
func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) { func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
t.Parallel() t.Parallel()
for _, tc := range []struct { for _, tc := range []struct {
@@ -483,7 +497,7 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
}{ }{
{ {
"block", map[string]string{wafMode: block}, "block", map[string]string{wafMode: block},
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet, "", http.StatusForbidden, requestlog.ActionWAFBlocked, "",
}, },
{ {
"detect", map[string]string{wafMode: detect}, "detect", map[string]string{wafMode: detect},
@@ -518,7 +532,7 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
wantAlerts(t, queue, alerts.Alert{ wantAlerts(t, queue, alerts.Alert{
Instance: alertInstance, Instance: alertInstance,
Time: clk.Now(), Time: clk.Now(),
Event: alerts.EventCoreRuleSetMatch, Event: alerts.EventWAFBlock,
Host: appHost, Host: appHost,
Client: netip.MustParseAddr(client), Client: netip.MustParseAddr(client),
Netblock: netip.MustParsePrefix(client + "/32"), Netblock: netip.MustParsePrefix(client + "/32"),
+2 -1
View File
@@ -30,9 +30,10 @@ func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
rq.addTag(config.CrowdSecTag) rq.addTag(config.CrowdSecTag)
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL}, rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
"listed by the CrowdSec decision list") "listed by the CrowdSec decision list")
rq.banForCrowdSec(now, decision)
return &verdict{ return &verdict{
action: rq.banForCrowdSec(now, decision), action: requestlog.ActionBanned,
figures: map[string]any{ figures: map[string]any{
"scenario": decision.Scenario, "scenario": decision.Scenario,
"expires": requestlog.FormatTime(decision.Expires), "expires": requestlog.FormatTime(decision.Expires),
+7 -7
View File
@@ -39,7 +39,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
expires := requestlog.FormatTime(fetched.Add(4 * time.Hour)) expires := requestlog.FormatTime(fetched.Add(4 * time.Hour))
// Its first request is refused, and bans it until the decision ends. // Its first request is refused, and bans it until the decision ends.
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
wantReputation(t, line, decisionsURL) wantReputation(t, line, decisionsURL)
if line.BanExpires != expires { if line.BanExpires != expires {
@@ -72,7 +72,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
// ban, and once it has ended the client is let through. // ban, and once it has ended the client is let through.
clk.advance(4*time.Hour - time.Second) clk.advance(4*time.Hour - time.Second)
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
wantReputation(t, line) wantReputation(t, line)
if line.BanExpires != expires { if line.BanExpires != expires {
@@ -105,7 +105,7 @@ func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+ `"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
`"value": "`+client+`"}]`) `"value": "`+client+`"}]`)
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned) line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+ wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`) `"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
wantTags(t, line, "crowdsec") wantTags(t, line, "crowdsec")
@@ -129,9 +129,9 @@ func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T
// Just before its end, the decision bans a client in the netblock, and // Just before its end, the decision bans a client in the netblock, and
// one on an IPv6 address bans the address's group, the /64. // one on an IPv6 address bans the address's group, the /64.
clk.advance(time.Minute - time.Nanosecond) clk.advance(time.Minute - time.Nanosecond)
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionBanned)
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionBanned)
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionBanned)
// Once it has ended, it bans no other client, and the bans it made end // Once it has ended, it bans no other client, and the bans it made end
// with it. // with it.
@@ -164,7 +164,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
`"value": "`+client+`"}]`) `"value": "`+client+`"}]`)
line := s.get(client, http.StatusOK, requestlog.ActionForward) line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned) wantWouldAction(t, line, requestlog.ActionBanned)
wantReputation(t, line, decisionsURL) wantReputation(t, line, decisionsURL)
if held := server.Ledger.Snapshot(); len(held) != 0 { if held := server.Ledger.Snapshot(); len(held) != 0 {
+1 -1
View File
@@ -19,7 +19,7 @@ func (denyNets) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, netblock := range rq.h.config.DenyNets { for _, netblock := range rq.h.config.DenyNets {
if netblock.Contains(rq.client) { if netblock.Contains(rq.client) {
return &verdict{ return &verdict{
action: requestlog.ActionClientDenied, action: requestlog.ActionDenied,
figures: map[string]any{figureNetblock: netblock.String()}, figures: map[string]any{figureNetblock: netblock.String()},
} }
} }
+14 -15
View File
@@ -93,8 +93,8 @@ type Report struct {
Status int `json:"status"` Status int `json:"status"`
RequestBytes int64 `json:"request_bytes"` RequestBytes int64 `json:"request_bytes"`
ResponseBytes int64 `json:"response_bytes"` ResponseBytes int64 `json:"response_bytes"`
DurationTotalMS float64 `json:"duration_total_ms"` DurationTotal float64 `json:"duration_total"`
DurationUpstreamTotalMS float64 `json:"duration_upstream_total_ms"` DurationUpstreamTotal float64 `json:"duration_upstream_total"`
} }
// New returns the server `sww discriminator` runs. It answers // New returns the server `sww discriminator` runs. It answers
@@ -123,7 +123,6 @@ func New(params Params) *Server {
Lists: h.lists, Lists: h.lists,
DNSBL: h.dnsbl, DNSBL: h.dnsbl,
AbuseIPDB: h.abuseIPDB, AbuseIPDB: h.abuseIPDB,
RouteWatch: h.routeWatch,
Metrics: h.metrics, Metrics: h.metrics,
} }
} }
@@ -183,7 +182,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
rq.line.DiscriminatorLine = requestlog.DiscriminatorLineDecision rq.line.DiscriminatorLine = requestlog.DiscriminatorLineDecision
refused := rq.check(r.Context()) refused := rq.check(r.Context())
rq.checked = time.Now() rq.checked = time.Now()
underOwnPrefix := strings.HasPrefix(r.URL.Path, ownEndpointPrefix) underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix)
if refused == nil && !underOwnPrefix { if refused == nil && !underOwnPrefix {
// In observe mode, what enforce mode would have refused counts here. // In observe mode, what enforce mode would have refused counts here.
@@ -208,7 +207,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
// The app never sees this prefix. discriminator's own endpoints are at its // The app never sees this prefix. discriminator's own endpoints are at its
// own address, so this answers as a path with no endpoint does. // own address, so this answers as a path with no endpoint does.
rq.line.Action = requestlog.ActionOwnEndpoint rq.line.Action = requestlog.ActionAdmin
rq.out.Header().Set(DispositionHeader, DispositionDeny) rq.out.Header().Set(DispositionHeader, DispositionDeny)
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound) http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
} }
@@ -405,7 +404,7 @@ func readReport(w http.ResponseWriter, r *http.Request) (Report, error) {
for _, name := range []string{ for _, name := range []string{
"request_id", "status", "request_bytes", "response_bytes", "request_id", "status", "request_bytes", "response_bytes",
"duration_total_ms", "duration_upstream_total_ms", "duration_total", "duration_upstream_total",
} { } {
value, there := fields[name] value, there := fields[name]
if !there || string(value) == "null" { if !there || string(value) == "null" {
@@ -431,11 +430,11 @@ func (report *Report) checkRanges() error {
return fmt.Errorf("%w: request_bytes", errReportValue) return fmt.Errorf("%w: request_bytes", errReportValue)
case report.ResponseBytes < 0 || report.ResponseBytes > maxReportedBytes: case report.ResponseBytes < 0 || report.ResponseBytes > maxReportedBytes:
return fmt.Errorf("%w: response_bytes", errReportValue) return fmt.Errorf("%w: response_bytes", errReportValue)
case report.DurationTotalMS < 0 || report.DurationTotalMS > maxReportedMilliseconds: case report.DurationTotal < 0 || report.DurationTotal > maxReportedMilliseconds:
return fmt.Errorf("%w: duration_total_ms", errReportValue) return fmt.Errorf("%w: duration_total", errReportValue)
case report.DurationUpstreamTotalMS < 0 || case report.DurationUpstreamTotal < 0 ||
report.DurationUpstreamTotalMS > maxReportedMilliseconds: report.DurationUpstreamTotal > maxReportedMilliseconds:
return fmt.Errorf("%w: duration_upstream_total_ms", errReportValue) return fmt.Errorf("%w: duration_upstream_total", errReportValue)
} }
return nil return nil
@@ -459,18 +458,18 @@ func (h *handler) finishWith(ctx context.Context, held *decision, report Report)
rq.line.Status = report.Status rq.line.Status = report.Status
rq.line.RequestBytes = report.RequestBytes rq.line.RequestBytes = report.RequestBytes
rq.line.ResponseBytes = report.ResponseBytes rq.line.ResponseBytes = report.ResponseBytes
rq.line.DurationTotalMS = report.DurationTotalMS rq.line.DurationTotal = report.DurationTotal
if rq.allowed { if rq.allowed {
rq.line.DurationUpstreamTotalMS = &report.DurationUpstreamTotalMS rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal
rq.runBanningHeuristics(ctx, onReport()) rq.runBanningHeuristics(ctx, onReport())
rq.countAnomalies() rq.countAnomalies()
rq.addToHistory() rq.addToHistory()
rq.h.metrics.RequestReported(&rq.line, rq.h.metrics.RequestReported(&rq.line,
time.Duration(report.DurationTotalMS*float64(time.Millisecond)), time.Duration(report.DurationTotal*float64(time.Millisecond)),
time.Duration(report.DurationUpstreamTotalMS*float64(time.Millisecond))) time.Duration(report.DurationUpstreamTotal*float64(time.Millisecond)))
} }
rq.writeLine() rq.writeLine()
+31 -33
View File
@@ -48,8 +48,8 @@ const (
reportStatus = "status" reportStatus = "status"
reportRequestBytes = "request_bytes" reportRequestBytes = "request_bytes"
reportResponseBytes = "response_bytes" reportResponseBytes = "response_bytes"
reportDuration = "duration_total_ms" reportDuration = "duration_total"
reportUpstreamDuration = "duration_upstream_total_ms" reportUpstreamDuration = "duration_upstream_total"
) )
// Discriminator is a discriminator running for one test, on a clock set to midnight. // Discriminator is a discriminator running for one test, on a clock set to midnight.
@@ -639,7 +639,7 @@ func TestQuestionDeniedGetsTheBanResponse(t *testing.T) {
got := s.ask(client, "/") got := s.ask(client, "/")
wantDisposition(t, got, status, discriminator.DispositionDeny) wantDisposition(t, got, status, discriminator.DispositionDeny)
wantBody(t, got, http.StatusText(status)+"\n") wantBody(t, got, http.StatusText(status)+"\n")
wantLine(t, s.out.requestLine(t), status, requestlog.ActionClientDenied) wantLine(t, s.out.requestLine(t), status, requestlog.ActionDenied)
}) })
} }
} }
@@ -655,8 +655,8 @@ func TestBanResponseCloseIsAnAnswerOfItsOwn(t *testing.T) {
wantDisposition(t, got, http.StatusOK, discriminator.DispositionClose) wantDisposition(t, got, http.StatusOK, discriminator.DispositionClose)
wantBody(t, got, "") wantBody(t, got, "")
// The visitor is sent nothing. // The visitor is sent nothing.
wantLine(t, s.out.requestLine(t), 0, requestlog.ActionClientDenied) wantLine(t, s.out.requestLine(t), 0, requestlog.ActionDenied)
wantMetric(t, s.metrics(), `sww_requests_total{action="client_denied",instance="`+ wantMetric(t, s.metrics(), `sww_requests_total{action="denied",instance="`+
alertInstance+`",status_class="none"}`, 1) alertInstance+`",status_class="none"}`, 1)
} }
@@ -671,12 +671,12 @@ func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(t *testing.T) {
got := s.ask(client, "/blocked") got := s.ask(client, "/blocked")
wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden, wantLine(t, s.out.requestLine(t), http.StatusForbidden,
requestlog.ActionBlockedByBlockRule) requestlog.ActionRuleBlocked)
got = s.ask(client, "/.env") got = s.ask(client, "/.env")
wantDisposition(t, got, http.StatusTooManyRequests, discriminator.DispositionDeny) wantDisposition(t, got, http.StatusTooManyRequests, discriminator.DispositionDeny)
wantLine(t, s.out.requestLines(t, 2)[1], http.StatusTooManyRequests, wantLine(t, s.out.requestLines(t, 2)[1], http.StatusTooManyRequests,
requestlog.ActionTemporarilyBanned) requestlog.ActionBanned)
} }
func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) { func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) {
@@ -693,7 +693,7 @@ func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) {
wantDisposition(t, got, http.StatusNotFound, discriminator.DispositionDeny) wantDisposition(t, got, http.StatusNotFound, discriminator.DispositionDeny)
} }
wantLine(t, s.out.requestLine(t), http.StatusNotFound, requestlog.ActionOwnEndpoint) wantLine(t, s.out.requestLine(t), http.StatusNotFound, requestlog.ActionAdmin)
got := s.ask(otherClient, discriminator.MetricsPath) got := s.ask(otherClient, discriminator.MetricsPath)
wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny)
@@ -709,7 +709,7 @@ func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) {
line := s.out.requestLine(t) line := s.out.requestLine(t)
wantLine(t, line, http.StatusOK, requestlog.ActionForward) wantLine(t, line, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionClientDenied) wantWouldAction(t, line, requestlog.ActionDenied)
} }
func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) { func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) {
@@ -725,9 +725,9 @@ func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) {
} }
lines := s.out.requestLines(t, 2) lines := s.out.requestLines(t, 2)
if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitMinuteRefusals { if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitErrorBurst {
t.Errorf("log lines have limit_hit %q and %q, want none and %q", t.Errorf("log lines have limit_hit %q and %q, want none and %q",
lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitMinuteRefusals) lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitErrorBurst)
} }
} }
@@ -744,9 +744,9 @@ func TestDeniedQuestionCountsForTheErrorBurst(t *testing.T) {
} }
lines := s.out.requestLines(t, 2) lines := s.out.requestLines(t, 2)
if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitMinuteRefusals { if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitErrorBurst {
t.Errorf("log lines have limit_hit %q and %q, want none and %q", t.Errorf("log lines have limit_hit %q and %q, want none and %q",
lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitMinuteRefusals) lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitErrorBurst)
} }
} }
@@ -894,14 +894,14 @@ func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) {
t.Errorf("history is %+v, want the refused request alone", history) t.Errorf("history is %+v, want the refused request alone", history)
} }
const series = `sww_requests_total{action="client_is_rate_limited",instance="` + const series = `sww_requests_total{action="rate_limited",instance="` +
alertInstance + `",status_class="4xx"}` alertInstance + `",status_class="4xx"}`
wantMetric(t, s.metrics(), series, 1) wantMetric(t, s.metrics(), series, 1)
got := s.report(discriminator.Report{ got := s.report(discriminator.Report{
RequestID: denied.Header.Get(requestIDHeader), Status: http.StatusForbidden, RequestID: denied.Header.Get(requestIDHeader), Status: http.StatusForbidden,
ResponseBytes: 1000, DurationTotalMS: 2, ResponseBytes: 1000, DurationTotal: 2,
}) })
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
@@ -935,7 +935,7 @@ func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) {
got := s.report(discriminator.Report{ got := s.report(discriminator.Report{
RequestID: id, Status: http.StatusBadGateway, RequestID: id, Status: http.StatusBadGateway,
RequestBytes: bodyBytes, ResponseBytes: answerBytes, RequestBytes: bodyBytes, ResponseBytes: answerBytes,
DurationTotalMS: 5, DurationUpstreamTotalMS: 4, DurationTotal: 5, DurationUpstreamTotal: 4,
}) })
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
@@ -951,8 +951,7 @@ func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) {
unscraped := startDiscriminator(t, "", map[string]string{metricsToken: token}) unscraped := startDiscriminator(t, "", map[string]string{metricsToken: token})
got = unscraped.report(discriminator.Report{ got = unscraped.report(discriminator.Report{
RequestID: unscraped.allowed(client), Status: http.StatusBadGateway, RequestID: unscraped.allowed(client), Status: http.StatusBadGateway, DurationTotal: 5,
DurationTotalMS: 5,
}) })
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
wantMetric(t, unscraped.metrics(), wantMetric(t, unscraped.metrics(),
@@ -1190,8 +1189,8 @@ func wantReportsFields(t *testing.T, line logLine) {
if line.DiscriminatorLine != requestlog.DiscriminatorLineReport || if line.DiscriminatorLine != requestlog.DiscriminatorLineReport ||
line.Status != http.StatusCreated || line.Status != http.StatusCreated ||
line.RequestBytes != bodyBytes || line.ResponseBytes != answerBytes || line.RequestBytes != bodyBytes || line.ResponseBytes != answerBytes ||
line.DurationTotalMS != 12.5 || line.DurationUpstreamTotalMS == nil || line.DurationTotal != 12.5 || line.DurationUpstreamTotal == nil ||
*line.DurationUpstreamTotalMS != 10.25 { *line.DurationUpstreamTotal != 10.25 {
t.Errorf("the report's line lacks the report's fields: %+v", line.Line) t.Errorf("the report's line lacks the report's fields: %+v", line.Line)
} }
} }
@@ -1206,8 +1205,8 @@ func wantSameDecision(t *testing.T, reported, decided logLine) {
same.Status = decided.Status same.Status = decided.Status
same.RequestBytes = decided.RequestBytes same.RequestBytes = decided.RequestBytes
same.ResponseBytes = decided.ResponseBytes same.ResponseBytes = decided.ResponseBytes
same.DurationTotalMS = decided.DurationTotalMS same.DurationTotal = decided.DurationTotal
same.DurationUpstreamTotalMS = decided.DurationUpstreamTotalMS same.DurationUpstreamTotal = decided.DurationUpstreamTotal
same.Counts.MinuteBytes = decided.Counts.MinuteBytes same.Counts.MinuteBytes = decided.Counts.MinuteBytes
same.Counts.HourBytes = decided.Counts.HourBytes same.Counts.HourBytes = decided.Counts.HourBytes
same.Counts.DayBytes = decided.Counts.DayBytes same.Counts.DayBytes = decided.Counts.DayBytes
@@ -1225,7 +1224,7 @@ func wantDecisionsFields(t *testing.T, line logLine, id, query, accept string) {
if line.DiscriminatorLine != requestlog.DiscriminatorLineDecision || if line.DiscriminatorLine != requestlog.DiscriminatorLineDecision ||
line.Status != http.StatusOK || line.ResponseBytes != 0 || line.Status != http.StatusOK || line.ResponseBytes != 0 ||
line.DurationUpstreamTotalMS != nil || line.DurationChecksMS == nil || line.DurationUpstreamTotal != nil || line.DurationChecks == nil ||
line.RequestID != id || line.Query != query || line.RequestID != id || line.Query != query ||
!line.HasAuthorization || !line.HasCookie || !line.HasAuthorization || !line.HasCookie ||
line.RequestHeaders["accept"] != accept || len(line.RuleIDs) != 1 || line.RequestHeaders["accept"] != accept || len(line.RuleIDs) != 1 ||
@@ -1261,7 +1260,7 @@ func TestReportWritesTheLastLineWithTheDecisionsAndTheReportsFields(t *testing.T
got := s.report(discriminator.Report{ got := s.report(discriminator.Report{
RequestID: id, Status: http.StatusCreated, RequestID: id, Status: http.StatusCreated,
RequestBytes: bodyBytes, ResponseBytes: answerBytes, RequestBytes: bodyBytes, ResponseBytes: answerBytes,
DurationTotalMS: 12.5, DurationUpstreamTotalMS: 10.25, DurationTotal: 12.5, DurationUpstreamTotal: 10.25,
}) })
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
@@ -1299,14 +1298,14 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
}{ }{
{ {
"deny", map[string]string{denyNets: client}, "/", http.StatusForbidden, "deny", map[string]string{denyNets: client}, "/", http.StatusForbidden,
requestlog.ActionClientDenied, requestlog.ActionDenied,
}, },
{ {
"close", map[string]string{denyNets: client, banResponse: banResponseClose}, "/", "close", map[string]string{denyNets: client, banResponse: banResponseClose}, "/",
0, requestlog.ActionClientDenied, 0, requestlog.ActionDenied,
}, },
{"own prefix", nil, discriminator.HealthPath, http.StatusNotFound, {"own prefix", nil, discriminator.HealthPath, http.StatusNotFound,
requestlog.ActionOwnEndpoint}, requestlog.ActionAdmin},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1318,7 +1317,7 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
do(t, req) do(t, req)
got := s.report(discriminator.Report{ got := s.report(discriminator.Report{
RequestID: id, Status: tc.status, ResponseBytes: 8, DurationTotalMS: 3, RequestID: id, Status: tc.status, ResponseBytes: 8, DurationTotal: 3,
}) })
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
@@ -1326,8 +1325,8 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
wantLine(t, line, tc.status, tc.action) wantLine(t, line, tc.status, tc.action)
if line.DiscriminatorLine != requestlog.DiscriminatorLineReport || if line.DiscriminatorLine != requestlog.DiscriminatorLineReport ||
line.RequestID != id || line.ResponseBytes != 8 || line.DurationTotalMS != 3 || line.RequestID != id || line.ResponseBytes != 8 || line.DurationTotal != 3 ||
line.DurationUpstreamTotalMS != nil { line.DurationUpstreamTotal != nil {
t.Errorf("the report's line is %+v", line.Line) t.Errorf("the report's line is %+v", line.Line)
} }
}) })
@@ -1584,10 +1583,9 @@ func TestCoreRuleSetAlertNamesTheSite(t *testing.T) {
wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny)
waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventCoreRuleSetMatch || if len(waiting) != 1 || waiting[0].Event != alerts.EventWAFBlock ||
waiting[0].Host != siteA { waiting[0].Host != siteA {
t.Errorf("alerts waiting are %+v, want a core_rule_set_match naming %s", t.Errorf("alerts waiting are %+v, want a waf_block naming %s", waiting, siteA)
waiting, siteA)
} }
} }
+1 -1
View File
@@ -37,7 +37,7 @@ func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
} }
return &verdict{ return &verdict{
action: requestlog.ActionClientDenied, action: requestlog.ActionDenied,
figures: map[string]any{"zones": listedBy}, figures: map[string]any{"zones": listedBy},
} }
} }
+1 -1
View File
@@ -30,7 +30,7 @@ func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict
} }
return &verdict{ return &verdict{
action: requestlog.ActionRequestHeaderTooLarge, action: requestlog.ActionTooLarge,
figures: map[string]any{ figures: map[string]any{
"fields": fields, "fields_limit": maxHeaderFields, "fields": fields, "fields_limit": maxHeaderFields,
"cookies": cookies, "cookies_limit": maxCookies, "cookies": cookies, "cookies_limit": maxCookies,
+1 -2
View File
@@ -21,8 +21,7 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
) )
for range healthChecks { for range healthChecks {
got := s.ownEndpointRequest("", http.MethodGet, discriminator.HealthPath, "", got := s.adminRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK)
http.StatusOK)
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType { if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+ t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
-1
View File
@@ -44,7 +44,6 @@ const (
func beforeLookup() []heuristic { func beforeLookup() []heuristic {
return []heuristic{ return []heuristic{
tagLists{}, tagLists{},
routeWatchTags{},
denyNets{}, denyNets{},
activeBan{}, activeBan{},
} }
+4 -4
View File
@@ -31,9 +31,9 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
clk.advance(time.Second) clk.advance(time.Second)
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
clk.advance(time.Second) clk.advance(time.Second)
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
want := ratelimit.History{ want := ratelimit.History{
FirstSeen: start, FirstSeen: start,
@@ -100,8 +100,8 @@ func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token}) s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
s.ownEndpointRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK) s.adminRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK)
s.ownEndpointRequest("", http.MethodGet, discriminator.MetricsPath, "", s.adminRequest("", http.MethodGet, discriminator.MetricsPath, "",
http.StatusUnauthorized) http.StatusUnauthorized)
s.scrape() s.scrape()
+10 -10
View File
@@ -40,7 +40,7 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T)
// comes too. None waits for GeoJS. // comes too. None waits for GeoJS.
for _, line := range []logLine{ for _, line := range []logLine{
s.get(fromDE, http.StatusOK, requestlog.ActionForward), s.get(fromDE, http.StatusOK, requestlog.ActionForward),
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited), s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited),
s.get(fromKP, http.StatusOK, requestlog.ActionForward), s.get(fromKP, http.StatusOK, requestlog.ActionForward),
} { } {
got := asnAndCountry{line.ASN, line.ASName, line.Country} got := asnAndCountry{line.ASN, line.ASName, line.Country}
@@ -179,7 +179,7 @@ func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.
for _, line := range []logLine{ for _, line := range []logLine{
s.get(fromDE, http.StatusOK, requestlog.ActionForward), s.get(fromDE, http.StatusOK, requestlog.ActionForward),
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited), s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited),
} { } {
if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want { if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want {
t.Errorf("log line has %+v, want %+v", got, want) t.Errorf("log line has %+v, want %+v", got, want)
@@ -264,7 +264,7 @@ func TestClientsAreLookedUpInTheLookupDatabaseAndGeoJSIsNotAsked(t *testing.T) {
want asnAndCountry want asnAndCountry
}{ }{
{s.get(fromDE, http.StatusOK, requestlog.ActionForward), de}, {s.get(fromDE, http.StatusOK, requestlog.ActionForward), de},
{s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited), de}, {s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited), de},
{ {
s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied), s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
asnAndCountry{asnKP, asNameKP, "KP"}, asnAndCountry{asnKP, asNameKP, "KP"},
@@ -385,7 +385,7 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
// is never reached. // is never reached.
const timeout = 3 * time.Second const timeout = 3 * time.Second
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now, server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
map[string]string{ map[string]string{
lookupTimeout: timeout.String(), lookupTimeout: timeout.String(),
allowedCountries: "DE", allowedCountries: "DE",
@@ -405,25 +405,25 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
}) })
} }
// silentGeoJSURL is where a GeoJS that never answers is asked: a // unansweredGeoJSURL is where a GeoJS that never answers is asked: a
// request to it waits, without the network, until it is abandoned. // request to it waits, without the network, until it is abandoned.
// TestMain registers it with Go's default transport, through which GeoJS // TestMain registers it with Go's default transport, through which GeoJS
// is asked. // is asked.
const silentGeoJSURL = "silent://geojs/v1/ip/geo.json" const unansweredGeoJSURL = "unanswered://geojs/v1/ip/geo.json"
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
transport, _ := http.DefaultTransport.(*http.Transport) transport, _ := http.DefaultTransport.(*http.Transport)
transport.RegisterProtocol("silent", silentGeoJS{}) transport.RegisterProtocol("unanswered", unansweredGeoJS{})
transport.RegisterProtocol("abuseipdb", abuseIPDBStandIn{}) transport.RegisterProtocol("abuseipdb", abuseIPDBStandIn{})
m.Run() m.Run()
} }
// silentGeoJS is the GeoJS at silentGeoJSURL. // unansweredGeoJS is the GeoJS at unansweredGeoJSURL.
type silentGeoJS struct{} type unansweredGeoJS struct{}
// RoundTrip waits until req is abandoned. // RoundTrip waits until req is abandoned.
func (silentGeoJS) RoundTrip(req *http.Request) (*http.Response, error) { func (unansweredGeoJS) RoundTrip(req *http.Request) (*http.Response, error) {
<-req.Context().Done() <-req.Context().Done()
return nil, req.Context().Err() return nil, req.Context().Err()
+10 -10
View File
@@ -32,7 +32,7 @@ func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) {
// An empty token does not match the unset one either. // An empty token does not match the unset one either.
for _, authorization := range []string{bearer, "Bearer ", ""} { for _, authorization := range []string{bearer, "Bearer ", ""} {
s.ownEndpointRequest(authorization, http.MethodGet, discriminator.MetricsPath, "", s.adminRequest(authorization, http.MethodGet, discriminator.MetricsPath, "",
http.StatusNotFound) http.StatusNotFound)
} }
} }
@@ -60,7 +60,7 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
{http.MethodGet, "/_sww/bans", bearer, http.StatusNotFound}, {http.MethodGet, "/_sww/bans", bearer, http.StatusNotFound},
{http.MethodPost, discriminator.HealthPath, "", http.StatusNotFound}, {http.MethodPost, discriminator.HealthPath, "", http.StatusNotFound},
} { } {
got := s.ownEndpointRequest(tc.authorization, tc.method, tc.path, "", tc.status) got := s.adminRequest(tc.authorization, tc.method, tc.path, "", tc.status)
if tc.status == http.StatusUnauthorized && if tc.status == http.StatusUnauthorized &&
got.header.Get("WWW-Authenticate") != "Bearer" { got.header.Get("WWW-Authenticate") != "Bearer" {
@@ -91,7 +91,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
out.endedLines(t, 2) out.endedLines(t, 2)
forward := `{action="forward",instance="app",status_class="2xx"}` forward := `{action="forward",instance="app",status_class="2xx"}`
notFound := `{action="own_endpoint",instance="app",status_class="4xx"}` notFound := `{action="admin",instance="app",status_class="4xx"}`
// The request for the metrics is itself under way. // The request for the metrics is itself under way.
metrics := scrape(t, server.Addr) metrics := scrape(t, server.Addr)
@@ -125,15 +125,15 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
}) })
// SWW_BAN_RESPONSE=close sends no status at all. // SWW_BAN_RESPONSE=close sends no status at all.
s.get(denied, 0, requestlog.ActionClientDenied) s.get(denied, 0, requestlog.ActionDenied)
// A first broken limit bans for an hour. // A first broken limit bans for an hour.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, 0, requestlog.ActionClientIsRateLimited) s.get(client, 0, requestlog.ActionRateLimited)
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`sww_requests_total{action="client_denied",instance="app",status_class="none"}`, 1) `sww_requests_total{action="denied",instance="app",status_class="none"}`, 1)
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+ wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
`kind="requests",window="minute"}`, 1) `kind="requests",window="minute"}`, 1)
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1) wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
@@ -147,7 +147,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
// A limit broken again right after would ban for three hours, longer // A limit broken again right after would ban for three hours, longer
// than SWW_MAX_BAN_DURATION, so the ban is permanent. // than SWW_MAX_BAN_DURATION, so the ban is permanent.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, 0, requestlog.ActionClientIsRateLimited) s.get(client, 0, requestlog.ActionRateLimited)
metrics = s.scrape() metrics = s.scrape()
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+ wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
@@ -347,7 +347,7 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
wantMetric(t, metrics, `sww_geojs_requests_total{instance="app"}`, 1) wantMetric(t, metrics, `sww_geojs_requests_total{instance="app"}`, 1)
wantMetric(t, metrics, `sww_geojs_failures_total{instance="app"}`, 1) wantMetric(t, metrics, `sww_geojs_failures_total{instance="app"}`, 1)
wantMetric(t, metrics, `sww_requests_without_geojs_answer_total{instance="app"}`, 1) wantMetric(t, metrics, `sww_geojs_unanswered_total{instance="app"}`, 1)
} }
// keptAnswer returns GeoJS's answer that the client at addr is in // keptAnswer returns GeoJS's answer that the client at addr is in
@@ -381,8 +381,8 @@ func scrape(t *testing.T, addr string) string {
func (s *sender) scrape() string { func (s *sender) scrape() string {
s.t.Helper() s.t.Helper()
return string(s.ownEndpointRequest(bearer, http.MethodGet, discriminator.MetricsPath, return string(s.adminRequest(bearer, http.MethodGet, discriminator.MetricsPath, "",
"", http.StatusOK).body) http.StatusOK).body)
} }
// metric returns the value of series in metrics, which are in the // metric returns the value of series in metrics, which are in the
+7 -95
View File
@@ -7,7 +7,6 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
@@ -15,93 +14,6 @@ import (
// observe is the value of SWW_MODE for observe mode. // observe is the value of SWW_MODE for observe mode.
const observe = "observe" const observe = "observe"
func TestBanActionSaysWhetherTheBanRunsOut(t *testing.T) {
t.Parallel()
const trapPath = "/wp-login.php"
for _, tc := range []struct {
name string
cause string
// endsIn is how long after the request the ban ends: negative for a
// ban that has ended, and zero for a permanent ban.
endsIn time.Duration
path string
want string
}{
{
"temporary ban", bans.CauseAdmin, time.Hour, "/",
requestlog.ActionTemporarilyBanned,
},
{"permanent ban", bans.CauseAdmin, 0, "/", requestlog.ActionPermanentlyBanned},
{
"request that makes a ban permanent", bans.CauseAttack, time.Hour, "/",
requestlog.ActionPermanentlyBanned,
},
{
"trap path after an attack ban ended", bans.CauseAttack, -time.Hour,
trapPath, requestlog.ActionPermanentlyBanned,
},
{
"first trap path after another ban ended", bans.CauseAdmin, -time.Hour,
trapPath, requestlog.ActionTemporarilyBanned,
},
} {
for _, modeValue := range []string{"enforce", observe} {
t.Run(tc.name+" in "+modeValue, func(t *testing.T) {
t.Parallel()
s, clk, server := startWithClock(t, "", map[string]string{
trapPaths: trapPathList, mode: modeValue,
})
ban := bans.Ban{
Netblock: netip.MustParsePrefix(client + "/32"),
Start: clk.Now().Add(-2 * time.Hour),
Cause: tc.cause,
}
if tc.endsIn != 0 {
ban.Expires = clk.Now().Add(tc.endsIn)
}
server.Ledger.Load([]bans.Ban{ban})
if modeValue == observe {
line := s.request(client, tc.path, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, tc.want)
return
}
s.request(client, tc.path, http.StatusForbidden, tc.want)
})
}
}
}
func TestObserveModeNamesAPermanentBanWhoseAlertWouldBeSent(t *testing.T) {
t.Parallel()
s, clk, server, queue := startWithAlerts(t, map[string]string{
trapPaths: trapPathList, mode: observe,
})
server.Ledger.Load([]bans.Ban{{
Netblock: netip.MustParsePrefix(client + "/32"),
Start: clk.Now().Add(-2 * time.Hour),
Expires: clk.Now().Add(-time.Hour),
Cause: bans.CauseAttack,
}})
line := s.request(client, "/wp-login.php", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionPermanentlyBanned)
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventPermanentBan {
t.Errorf("alerts waiting are %+v, want one %s", waiting, alerts.EventPermanentBan)
}
}
func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) { func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
t.Parallel() t.Parallel()
@@ -110,7 +22,7 @@ func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
}) })
line := s.get(client, http.StatusOK, requestlog.ActionForward) line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionClientDenied) wantWouldAction(t, line, requestlog.ActionDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`) wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`)
} }
@@ -157,10 +69,10 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
for _, sent := range []struct{ from, refusal string }{ for _, sent := range []struct{ from, refusal string }{
{denied, requestlog.ActionClientDenied}, {denied, requestlog.ActionDenied},
{banned, requestlog.ActionTemporarilyBanned}, {banned, requestlog.ActionBanned},
{fromKP, requestlog.ActionClientDenied}, {fromKP, requestlog.ActionDenied},
{fromDE, requestlog.ActionClientIsRateLimited}, {fromDE, requestlog.ActionRateLimited},
} { } {
if !tc.observe { if !tc.observe {
line := s.get(sent.from, http.StatusForbidden, sent.refusal) line := s.get(sent.from, http.StatusForbidden, sent.refusal)
@@ -198,7 +110,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
for range 2 { for range 2 {
line := s.get(client, http.StatusOK, requestlog.ActionForward) line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionClientIsRateLimited) wantWouldAction(t, line, requestlog.ActionRateLimited)
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit || if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
line.BanExpires != "" { line.BanExpires != "" {
@@ -211,7 +123,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
// The ban read from bans.json refuses nothing, and so counts no // The ban read from bans.json refuses nothing, and so counts no
// refusal in its notes, but is kept. // refusal in its notes, but is kept.
line := s.get(otherClient, http.StatusOK, requestlog.ActionForward) line := s.get(otherClient, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned) wantWouldAction(t, line, requestlog.ActionBanned)
if line.BanExpires != requestlog.FormatTime(kept.Expires) { if line.BanExpires != requestlog.FormatTime(kept.Expires) {
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires, t.Errorf("log line has ban_expires %q, want %s", line.BanExpires,
+14 -22
View File
@@ -16,18 +16,17 @@ import (
"sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/metrics" "sneak.berlin/go/smallwebwaf/internal/metrics"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/routewatch"
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/waf"
) )
// ownEndpointPrefix starts the path of each of discriminator's own endpoints. The app // adminPrefix starts the path of each of discriminator's own endpoints. The app never
// never sees a path under it. // sees a path under it.
const ownEndpointPrefix = "/_sww/" const adminPrefix = "/_sww/"
// HealthPath is the health endpoint of discriminator and of rproxy, which the // HealthPath is the health endpoint of discriminator and of rproxy, which the
// container's health check asks. // container's health check asks.
@@ -65,7 +64,7 @@ type Params struct {
LookupFile *lookup.File LookupFile *lookup.File
// CoreRuleSet is what NewCoreRuleSet returns: what inspects requests // CoreRuleSet is what NewCoreRuleSet returns: what inspects requests
// while SWW_WAF_MODE is not off, and nil while it is. // while SWW_WAF_MODE is not off, and nil while it is.
CoreRuleSet *coreruleset.CoreRuleSet CoreRuleSet *waf.CoreRuleSet
// Now tells the time by which requests are counted for the rate // Now tells the time by which requests are counted for the rate
// limits, bans are made and run out, and GeoJS's answers are kept, // limits, bans are made and run out, and GeoJS's answers are kept,
// normally time.Now in UTC, the time the state files give. // normally time.Now in UTC, the time the state files give.
@@ -86,8 +85,7 @@ type Params struct {
// Server is the server discriminator runs, with the parts whose state the state // Server is the server discriminator runs, with the parts whose state the state
// files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the // files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts, // lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
// AbuseIPDB's scores and checks spent, and the metrics. RouteWatch, nil while // AbuseIPDB's scores and checks spent, and the metrics.
// SWW_ROUTEWATCH_URL is unset, keeps its answers in memory only.
type Server struct { type Server struct {
*http.Server *http.Server
@@ -99,7 +97,6 @@ type Server struct {
Lists *reputation.Lists Lists *reputation.Lists
DNSBL *reputation.DNSBL DNSBL *reputation.DNSBL
AbuseIPDB *reputation.AbuseIPDB AbuseIPDB *reputation.AbuseIPDB
RouteWatch *routewatch.Client
Metrics *metrics.Metrics Metrics *metrics.Metrics
} }
@@ -129,7 +126,7 @@ func newHandler(params Params) *handler {
BytesPerHour: params.Config.BytesLimitPerHour, BytesPerHour: params.Config.BytesLimitPerHour,
BytesPerDay: params.Config.BytesLimitPerDay, BytesPerDay: params.Config.BytesLimitPerDay,
}, params.Config.MaxTrackedClients), }, params.Config.MaxTrackedClients),
ledger: bans.New(bans.Params{ ledger: bans.New(bans.Rules{
LimitBanDuration: params.Config.LimitBanDuration, LimitBanDuration: params.Config.LimitBanDuration,
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow, LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
MaxBanDuration: params.Config.MaxBanDuration, MaxBanDuration: params.Config.MaxBanDuration,
@@ -155,12 +152,6 @@ func newHandler(params Params) *handler {
coreRuleSet: params.CoreRuleSet, coreRuleSet: params.CoreRuleSet,
alerts: params.Alerts, alerts: params.Alerts,
} }
if params.Config.RouteWatchURL != "" {
h.routeWatch = routewatch.New(routewatch.Params{
URL: params.Config.RouteWatchURL, Now: params.Now, ProcessLog: params.ProcessLog,
})
}
h.geojs = lookup.New(lookup.Params{ h.geojs = lookup.New(lookup.Params{
URL: params.GeoJSURL, URL: params.GeoJSURL,
Timeout: params.Config.LookupTimeout, Timeout: params.Config.LookupTimeout,
@@ -221,21 +212,23 @@ func newReputation(
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL, // NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies // without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
// up to SWW_WAF_BODY_LIMIT, with the exclusions in // up to SWW_WAF_BODY_LIMIT, with the exclusions in
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It // SWW_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_APPS,
// or nil while SWW_WAF_MODE is off. It
// reads at most QuestionBodyMaxBytes less one byte: a question's body of // reads at most QuestionBodyMaxBytes less one byte: a question's body of
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a // QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is // JSON or XML body over its limit. An exclusions file that waf.New refuses is
// an error in every mode. While the mode is off, a set file is loaded with the // an error in every mode. While the mode is off, a set file is loaded with the
// Core Rule Set only to check it. // Core Rule Set only to check it.
func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) { func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) {
if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" { if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
} }
coreRuleSet, err := coreruleset.New(coreruleset.Params{ coreRuleSet, err := waf.New(waf.Params{
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules, ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1), BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile, ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
Apps: cfg.Apps,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err) return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
@@ -264,9 +257,8 @@ type handler struct {
lists *reputation.Lists lists *reputation.Lists
dnsbl *reputation.DNSBL dnsbl *reputation.DNSBL
abuseIPDB *reputation.AbuseIPDB abuseIPDB *reputation.AbuseIPDB
routeWatch *routewatch.Client
rules *rules.Files rules *rules.Files
coreRuleSet *coreruleset.CoreRuleSet coreRuleSet *waf.CoreRuleSet
alerts *alerts.Queue alerts *alerts.Queue
// awaitingReport holds the decision on each answered question by its id // awaitingReport holds the decision on each answered question by its id
// until its report comes, and nil for a question still being handled. // until its report comes, and nil for a question still being handled.
+5 -9
View File
@@ -28,14 +28,12 @@ func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict
return nil return nil
} }
rq.rateLimitPercent, rq.bytesLimitPercent = rq.limitPercentages() rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
rq.line.RateLimitPercent, rq.line.RateLimitPercentSetting = rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
rq.rateLimitPercent.logged() rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
rq.line.BytesLimitPercent, rq.line.BytesLimitPercentSetting =
rq.bytesLimitPercent.logged()
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now, counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
rq.rateLimitPercent.percent) rq.limitPercent.percent)
rq.line.Counts = counts rq.line.Counts = counts
if !over { if !over {
@@ -44,9 +42,7 @@ func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict
rq.banForLimit(now, hit, rq.h.config.BanResponse) rq.banForLimit(now, hit, rq.h.config.BanResponse)
return &verdict{ return &verdict{action: requestlog.ActionRateLimited, figures: limitFigures(hit)}
action: requestlog.ActionClientIsRateLimited, figures: limitFigures(hit),
}
} }
// overAWholeRateLimit reports whether the heuristic rate_limits will refuse the // overAWholeRateLimit reports whether the heuristic rate_limits will refuse the
+8 -11
View File
@@ -59,7 +59,7 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
if sent.want == http.StatusOK { if sent.want == http.StatusOK {
wantLine(t, line, http.StatusOK, requestlog.ActionForward) wantLine(t, line, http.StatusOK, requestlog.ActionForward)
} else { } else {
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited) wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited)
if line.LimitHit != minute { if line.LimitHit != minute {
t.Errorf("log line has limit_hit %q, want minute", line.LimitHit) t.Errorf("log line has limit_hit %q, want minute", line.LimitHit)
@@ -79,7 +79,7 @@ func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited) line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`) wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`)
} }
@@ -102,7 +102,7 @@ func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
status int status int
action string action string
}{ }{
{rateLimitPerMinute, "1", http.StatusForbidden, requestlog.ActionClientIsRateLimited}, {rateLimitPerMinute, "1", http.StatusForbidden, requestlog.ActionRateLimited},
{bytesLimitPerMinute, byteLimit, http.StatusOK, requestlog.ActionForward}, {bytesLimitPerMinute, byteLimit, http.StatusOK, requestlog.ActionForward},
} { } {
t.Run(tc.setting, func(t *testing.T) { t.Run(tc.setting, func(t *testing.T) {
@@ -160,17 +160,14 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
// A path outside every prefix is counted: /assets is not under // A path outside every prefix is counted: /assets is not under
// /assets/, and breaks the limit. // /assets/, and breaks the limit.
s.request(client, "/assets", http.StatusForbidden, s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
requestlog.ActionClientIsRateLimited)
// A ban, SWW_DENY_NETS and SWW_TAG_DENY still refuse a path under a // A ban, SWW_DENY_NETS and SWW_TAG_DENY still refuse a path under a
// prefix. // prefix.
s.request(client, "/assets/app.js", http.StatusForbidden, s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
requestlog.ActionTemporarilyBanned) s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
s.request(denied, "/assets/app.js", http.StatusForbidden,
requestlog.ActionClientDenied)
s.request(fromKP, "/assets/app.js", s.request(fromKP, "/assets/app.js",
http.StatusForbidden, requestlog.ActionClientDenied) http.StatusForbidden, requestlog.ActionDenied)
} }
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) { func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
@@ -214,7 +211,7 @@ func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
// Counted, the second request breaks the limit of one request // Counted, the second request breaks the limit of one request
// a minute. // a minute.
s.request(client, sent, http.StatusOK, requestlog.ActionForward) s.request(client, sent, http.StatusOK, requestlog.ActionForward)
s.request(client, sent, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.request(client, sent, http.StatusForbidden, requestlog.ActionRateLimited)
}) })
} }
} }
+4 -4
View File
@@ -28,7 +28,7 @@ func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdic
return nil return nil
} }
if !rq.attack && !rq.blockedByBlockRule && !rq.blockedByCoreRuleSet { if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
return nil return nil
} }
@@ -42,13 +42,13 @@ func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdic
status := rq.out.status status := rq.out.status
switch rq.line.WouldAction { switch rq.line.WouldAction {
case requestlog.ActionBlockedByBlockRule, requestlog.ActionBlockedByCoreRuleSet: case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
status = http.StatusForbidden status = http.StatusForbidden
case requestlog.ActionTemporarilyBanned, requestlog.ActionPermanentlyBanned: case requestlog.ActionBanned:
status = cfg.BanResponse status = cfg.BanResponse
} }
rq.banForLimit(now, hit, status) rq.banForLimit(now, hit, status)
return &verdict{figures: limitFigures(hit)} return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
} }
+17 -30
View File
@@ -37,10 +37,9 @@ func (r refused) send(s *sender, from string) logLine {
switch r { switch r {
case blockRule: case blockRule:
return s.request(from, blockedPath, http.StatusForbidden, return s.request(from, blockedPath, http.StatusForbidden,
requestlog.ActionBlockedByBlockRule) requestlog.ActionRuleBlocked)
case banRule: case banRule:
return s.request(from, probePath, http.StatusForbidden, return s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
requestlog.ActionTemporarilyBanned)
} }
s.t.Fatalf("no request for the refusal %d", r) s.t.Fatalf("no request for the refusal %d", r)
@@ -72,20 +71,9 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
name string name string
// refusals are four, one over the threshold of three. // refusals are four, one over the threshold of three.
refusals []refused refusals []refused
// nextAction is the action of the client's next request: the next
// request after a ban for a ban rule makes it permanent.
nextAction string
}{ }{
{ {"block rule", []refused{blockRule, blockRule, blockRule, blockRule}},
"block rule", {"a ban rule last", []refused{blockRule, blockRule, blockRule, banRule}},
[]refused{blockRule, blockRule, blockRule, blockRule},
requestlog.ActionTemporarilyBanned,
},
{
"a ban rule last",
[]refused{blockRule, blockRule, blockRule, banRule},
requestlog.ActionPermanentlyBanned,
},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -107,13 +95,13 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
// The fourth is answered as the others were, breaks the error // The fourth is answered as the others were, breaks the error
// burst, and bans the client. // burst, and bans the client.
line := tc.refusals[3].send(s, client) line := tc.refusals[3].send(s, client)
if line.LimitHit != requestlog.LimitHitMinuteRefusals || if line.LimitHit != requestlog.LimitHitErrorBurst ||
line.Offence != requestlog.OffenceLimit { line.Offence != requestlog.OffenceLimit {
t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals "+ t.Errorf("log line has limit_hit %q and offence %q, want error_burst "+
"and limit", line.LimitHit, line.Offence) "and limit", line.LimitHit, line.Offence)
} }
s.get(client, http.StatusForbidden, tc.nextAction) s.get(client, http.StatusForbidden, requestlog.ActionBanned)
}) })
} }
} }
@@ -181,7 +169,7 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
t.Fatalf("bans\n%+v\nwant\n%+v", got, want) t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
} }
wantOffences := ratelimit.Offences{Limit: 1, BlockedByBlockRule: 3} wantOffences := ratelimit.Offences{Limit: 1, RuleBlocked: 3}
if offences := historyOf(t, server, client).Offences; offences != wantOffences { if offences := historyOf(t, server, client).Offences; offences != wantOffences {
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences) t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
} }
@@ -191,7 +179,7 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
`kind="refusals",window="minute"}`, 1) `kind="refusals",window="minute"}`, 1)
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1) wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
wantMetric(t, metrics, wantMetric(t, metrics,
`sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 3) `sww_offences_total{instance="app",kind="rule_blocked"}`, 3)
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1) wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
} }
@@ -277,8 +265,7 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
line = blockRule.send(s, client) line = blockRule.send(s, client)
} }
broken := line.LimitHit == requestlog.LimitHitMinuteRefusals if broken := line.LimitHit == requestlog.LimitHitErrorBurst; broken != tc.broken {
if broken != tc.broken {
t.Errorf("the 31st refusal broke the error burst: %t, want %t", t.Errorf("the 31st refusal broke the error burst: %t, want %t",
broken, tc.broken) broken, tc.broken)
} }
@@ -307,11 +294,11 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
blockRule.send(s, exempt) blockRule.send(s, exempt)
line := blockRule.send(s, exempt) line := blockRule.send(s, exempt)
if line.LimitHit != requestlog.LimitHitMinuteRefusals { if line.LimitHit != requestlog.LimitHitErrorBurst {
t.Errorf("log line has limit_hit %q, want minute_refusals", line.LimitHit) t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit)
} }
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(exempt, http.StatusForbidden, requestlog.ActionBanned)
} }
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) { func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
@@ -354,7 +341,7 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
for range 2 { for range 2 {
line := s.request(otherClient, blockedPath, http.StatusOK, line := s.request(otherClient, blockedPath, http.StatusOK,
requestlog.ActionForward) requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned) wantWouldAction(t, line, requestlog.ActionBanned)
if line.LimitHit != "" { if line.LimitHit != "" {
t.Errorf("log line has limit_hit %q, want none", line.LimitHit) t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
@@ -366,10 +353,10 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward) s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward) line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule) wantWouldAction(t, line, requestlog.ActionRuleBlocked)
if line.LimitHit != requestlog.LimitHitMinuteRefusals || line.BanExpires != "" { if line.LimitHit != requestlog.LimitHitErrorBurst || line.BanExpires != "" {
t.Errorf("log line has limit_hit %q and ban_expires %q, want minute_refusals "+ t.Errorf("log line has limit_hit %q and ban_expires %q, want error_burst "+
"and none", line.LimitHit, line.BanExpires) "and none", line.LimitHit, line.BanExpires)
} }
+46 -56
View File
@@ -49,12 +49,12 @@ const (
func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) { func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
t.Parallel() t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied forward, denied := requestlog.ActionForward, requestlog.ActionDenied
for _, tc := range []struct { for _, tc := range []struct {
action string action string
// statuses and actions are those of a listed client's three // statuses and actions are those of a listed client's three
// requests, and percent their rate_limit_percent, as percentText gives it. // requests, and percent their limit_percent, as percentText gives it.
statuses []int statuses []int
actions []string actions []string
percent string percent string
@@ -66,7 +66,7 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
{ {
// Half of 4 requests a minute: the third breaks the limit. // Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden}, limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionClientIsRateLimited}, []string{forward, forward, requestlog.ActionRateLimited},
"50 from " + blocklistAction, "50 from " + blocklistAction,
}, },
{ {
@@ -90,8 +90,8 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
for i := range 3 { for i := range 3 {
line := s.get(from, tc.statuses[i], tc.actions[i]) line := s.get(from, tc.statuses[i], tc.actions[i])
wantReputation(t, line, dropURL) wantReputation(t, line, dropURL)
wantPercent(t, "rate_limit_percent", line.RateLimitPercent, wantPercent(t, "limit_percent", line.LimitPercent,
line.RateLimitPercentSetting, tc.percent) line.LimitPercentSetting, tc.percent)
// A request refused for the list is not counted. // A request refused for the list is not counted.
counted := line.fields["counts"] != nil counted := line.fields["counts"] != nil
@@ -106,8 +106,7 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
for range 3 { for range 3 {
line := s.get(unplaced, http.StatusOK, forward) line := s.get(unplaced, http.StatusOK, forward)
wantReputation(t, line) wantReputation(t, line)
wantPercent(t, "rate_limit_percent", wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
line.RateLimitPercent, line.RateLimitPercentSetting,
none) none)
} }
@@ -163,7 +162,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T)
s, server, _ := startWithLookups(t, tc.env) s, server, _ := startWithLookups(t, tc.env)
tc.load(t, server) tc.load(t, server)
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied) line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, tc.heuristic, tc.figures) wantHeuristic(t, line, tc.heuristic, tc.figures)
}) })
} }
@@ -194,7 +193,7 @@ func TestObserveModeForwardsAClientABlocklistDeniesAndAlertsIt(t *testing.T) {
loadLists(t, server, map[string][]string{dropURL: {fromDE}}) loadLists(t, server, map[string][]string{dropURL: {fromDE}})
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward) line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionClientDenied) wantWouldAction(t, line, requestlog.ActionDenied)
wantReputation(t, line, dropURL) wantReputation(t, line, dropURL)
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
@@ -208,7 +207,7 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
for _, tc := range []struct { for _, tc := range []struct {
action, asnPercent string action, asnPercent string
// want is the upload's rate_limit_percent and bytes_limit_percent, as // want is the upload's limit_percent and bytes_percent, as
// percentText gives them, and limitHit its limit_hit. // percentText gives them, and limitHit its limit_hit.
want, limitHit string want, limitHit string
}{ }{
@@ -230,11 +229,9 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
// The upload's 100 bytes are over 49, a quarter of 199, and 99, // The upload's 100 bytes are over 49, a quarter of 199, and 99,
// half of it, and within 199. // half of it, and within 199.
line := s.uploadFrom(fromDE) line := s.uploadFrom(fromDE)
wantPercent(t, "rate_limit_percent", wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.want) tc.want)
wantPercent(t, "bytes_limit_percent", wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
line.BytesLimitPercent, line.BytesLimitPercentSetting,
tc.want) tc.want)
if line.LimitHit != tc.limitHit { if line.LimitHit != tc.limitHit {
@@ -256,9 +253,9 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
name string name string
env map[string]string env map[string]string
file string file string
// rateLimitPercent and bytesLimitPercent are the upload's, as // limitPercent and bytesPercent are the upload's, as percentText
// percentText gives them. // gives them.
rateLimitPercent, bytesLimitPercent string limitPercent, bytesPercent string
}{ }{
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL}, {"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
{ {
@@ -291,12 +288,10 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
loadLists(t, server, map[string][]string{asnURL: {"# by AS number", tc.file}}) loadLists(t, server, map[string][]string{asnURL: {"# by AS number", tc.file}})
line := s.uploadFrom(fromDE) line := s.uploadFrom(fromDE)
wantPercent(t, "rate_limit_percent", wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
line.RateLimitPercent, line.RateLimitPercentSetting, tc.limitPercent)
tc.rateLimitPercent) wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
wantPercent(t, "bytes_limit_percent", tc.bytesPercent)
line.BytesLimitPercent, line.BytesLimitPercentSetting,
tc.bytesLimitPercent)
}) })
} }
} }
@@ -385,12 +380,12 @@ const (
func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) { func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
t.Parallel() t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied forward, denied := requestlog.ActionForward, requestlog.ActionDenied
for _, tc := range []struct { for _, tc := range []struct {
action string action string
// statuses and actions are those of a listed client's three // statuses and actions are those of a listed client's three
// requests, and percent their rate_limit_percent, as percentText gives it. // requests, and percent their limit_percent, as percentText gives it.
statuses []int statuses []int
actions []string actions []string
percent string percent string
@@ -402,7 +397,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
{ {
// Half of 4 requests a minute: the third breaks the limit. // Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden}, limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionClientIsRateLimited}, []string{forward, forward, requestlog.ActionRateLimited},
"50 from " + reputationAction, "50 from " + reputationAction,
}, },
{ {
@@ -427,8 +422,8 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
line := s.get(from, tc.statuses[i], tc.actions[i]) line := s.get(from, tc.statuses[i], tc.actions[i])
// In the order SWW_DNSBL_ZONES names them. // In the order SWW_DNSBL_ZONES names them.
wantReputation(t, line, listedBy[from]...) wantReputation(t, line, listedBy[from]...)
wantPercent(t, "rate_limit_percent", line.RateLimitPercent, wantPercent(t, "limit_percent", line.LimitPercent,
line.RateLimitPercentSetting, tc.percent) line.LimitPercentSetting, tc.percent)
// A request refused for the verdict is not counted. // A request refused for the verdict is not counted.
counted := line.fields["counts"] != nil counted := line.fields["counts"] != nil
@@ -443,8 +438,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
for range 3 { for range 3 {
line := s.get(unplaced, http.StatusOK, forward) line := s.get(unplaced, http.StatusOK, forward)
wantReputation(t, line) wantReputation(t, line)
wantPercent(t, "rate_limit_percent", wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
line.RateLimitPercent, line.RateLimitPercentSetting,
none) none)
} }
@@ -474,7 +468,7 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
// The blocklist refuses fromKP before its verdict is looked at, and // The blocklist refuses fromKP before its verdict is looked at, and
// fromDE, in SWW_ALLOW_NETS, is not checked at all: neither is noted // fromDE, in SWW_ALLOW_NETS, is not checked at all: neither is noted
// for the zone, nor alerted, nor asked about. // for the zone, nor alerted, nor asked about.
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied), wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
dropURL) dropURL)
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward)) wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
@@ -538,7 +532,7 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}}) loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward) line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionClientDenied) wantWouldAction(t, line, requestlog.ActionDenied)
wantReputation(t, line, dnsblZone) wantReputation(t, line, dnsblZone)
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
@@ -552,7 +546,7 @@ func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
for _, tc := range []struct { for _, tc := range []struct {
blocklistAction, reputationAction string blocklistAction, reputationAction string
// want is the request's rate_limit_percent and bytes_limit_percent, as // want is the request's limit_percent and bytes_percent, as
// percentText gives them. // percentText gives them.
want string want string
}{ }{
@@ -577,11 +571,9 @@ func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
// Named by the blocklist, then by the zone. // Named by the blocklist, then by the zone.
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward) line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantReputation(t, line, dropURL, dnsblZone) wantReputation(t, line, dropURL, dnsblZone)
wantPercent(t, "rate_limit_percent", wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.want) tc.want)
wantPercent(t, "bytes_limit_percent", wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
line.BytesLimitPercent, line.BytesLimitPercentSetting,
tc.want) tc.want)
}) })
} }
@@ -749,8 +741,8 @@ func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(t *testing.T
s.get(fromDE, http.StatusOK, forward) s.get(fromDE, http.StatusOK, forward)
s.get(fromDE, http.StatusOK, forward) s.get(fromDE, http.StatusOK, forward)
s.get(fromKP, http.StatusOK, forward) s.get(fromKP, http.StatusOK, forward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
wantAbuseIPDBChecks(t, server, 0) wantAbuseIPDBChecks(t, server, 0)
// Once the ban has ended, fromDE's first request has it checked in the // Once the ban has ended, fromDE's first request has it checked in the
@@ -789,7 +781,7 @@ func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing
s.get(addresses[0], http.StatusOK, forward) s.get(addresses[0], http.StatusOK, forward)
} }
s.get(addresses[0], http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(addresses[0], http.StatusForbidden, requestlog.ActionRateLimited)
clk.advance(time.Hour) clk.advance(time.Hour)
// Once the ban has ended, which set its counters back to zero, a // Once the ban has ended, which set its counters back to zero, a
@@ -822,20 +814,20 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
want ratelimit.Offences want ratelimit.Offences
}{ }{
{ {
"a block rule", blockedPath, http.StatusForbidden, "a block rule", blockedPath, http.StatusForbidden, requestlog.ActionRuleBlocked,
requestlog.ActionBlockedByBlockRule, ratelimit.Offences{BlockedByBlockRule: 1}, ratelimit.Offences{RuleBlocked: 1},
}, },
{ {
"the Core Rule Set", sqlInjection, http.StatusForbidden, "the Core Rule Set", sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet, ratelimit.Offences{BlockedByCoreRuleSet: 1}, requestlog.ActionWAFBlocked, ratelimit.Offences{WAFBlocked: 1},
}, },
{ {
"a ban rule", probePath, http.StatusForbidden, requestlog.ActionTemporarilyBanned, "a ban rule", probePath, http.StatusForbidden, requestlog.ActionBanned,
ratelimit.Offences{Attack: 1}, ratelimit.Offences{Attack: 1},
}, },
{ {
"a trap path", "/xmlrpc.php", http.StatusForbidden, "a trap path", "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned,
requestlog.ActionTemporarilyBanned, ratelimit.Offences{Attack: 1}, ratelimit.Offences{Attack: 1},
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -866,12 +858,12 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) { func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) {
t.Parallel() t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied forward, denied := requestlog.ActionForward, requestlog.ActionDenied
for _, tc := range []struct { for _, tc := range []struct {
action string action string
// statuses and actions are those of fromDE's three requests, and // statuses and actions are those of fromDE's three requests, and
// percent their rate_limit_percent, as percentText gives it. // percent their limit_percent, as percentText gives it.
statuses []int statuses []int
actions []string actions []string
percent string percent string
@@ -883,7 +875,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
{ {
// Half of 4 requests a minute: the third breaks the limit. // Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden}, limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionClientIsRateLimited}, []string{forward, forward, requestlog.ActionRateLimited},
"50 from " + reputationAction, "50 from " + reputationAction,
}, },
{ {
@@ -904,8 +896,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
for i := range 3 { for i := range 3 {
line := s.get(fromDE, tc.statuses[i], tc.actions[i]) line := s.get(fromDE, tc.statuses[i], tc.actions[i])
wantReputation(t, line, abuseipdb) wantReputation(t, line, abuseipdb)
wantPercent(t, "rate_limit_percent", wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.percent) tc.percent)
// A request refused for the score is not counted. // A request refused for the score is not counted.
@@ -919,8 +910,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
for range 3 { for range 3 {
line := s.get(fromKP, http.StatusOK, forward) line := s.get(fromKP, http.StatusOK, forward)
wantReputation(t, line) wantReputation(t, line)
wantPercent(t, "rate_limit_percent", wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
line.RateLimitPercent, line.RateLimitPercentSetting,
none) none)
} }
@@ -982,7 +972,7 @@ func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
// fromDE's score is not used, and once it has committed an offence it // fromDE's score is not used, and once it has committed an offence it
// is not checked either. // is not checked either.
wantReputation(t, s.get(fromDE, http.StatusOK, forward)) wantReputation(t, s.get(fromDE, http.StatusOK, forward))
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
clk.advance(time.Hour) clk.advance(time.Hour)
wantReputation(t, s.get(fromDE, http.StatusOK, forward)) wantReputation(t, s.get(fromDE, http.StatusOK, forward))
wantAbuseIPDBChecks(t, server, 0) wantAbuseIPDBChecks(t, server, 0)
@@ -1006,10 +996,10 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
}{ }{
{"for a broken rate limit", func(s *sender, from string) { {"for a broken rate limit", func(s *sender, from string) {
s.get(from, http.StatusOK, requestlog.ActionForward) s.get(from, http.StatusOK, requestlog.ActionForward)
s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(from, http.StatusForbidden, requestlog.ActionRateLimited)
}}, }},
{"for a clear sign of attack", func(s *sender, from string) { {"for a clear sign of attack", func(s *sender, from string) {
s.request(from, probePath, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
}}, }},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
+13 -14
View File
@@ -48,16 +48,16 @@ type decision struct {
// so that not waiting never lowers its client's limits. // so that not waiting never lowers its client's limits.
skippedLookupWait bool skippedLookupWait bool
// counted is true for a request the rate limits counted, whose bytes // counted is true for a request the rate limits counted, whose bytes
// the byte limits count once it has ended. rateLimitPercent and // the byte limits count once it has ended. limitPercent and
// bytesLimitPercent are then its client's limit percentages for the rate // bytesPercent are then its client's limit percentages for the rate
// limits and for the byte limits. // limits and for the byte limits.
counted bool counted bool
rateLimitPercent, bytesLimitPercent percentage limitPercent, bytesPercent percentage
// attack is true for a request that matched a ban rule or asked for a // attack is true for a request that matched a ban rule or asked for a
// trap path, blockedByBlockRule for one a block rule refused, and // trap path, ruleBlocked for one a block rule refused, and wafBlocked for
// blockedByCoreRuleSet for one the Core Rule Set refused, each an offence // one the Core Rule Set refused, each an offence its client's history
// its client's history counts. // counts.
attack, blockedByBlockRule, blockedByCoreRuleSet bool attack, ruleBlocked, wafBlocked bool
// reputation is the reputation sources that list the client, for the // reputation is the reputation sources that list the client, for the
// notes of a ban the request makes. // notes of a ban the request makes.
reputation []bans.ReputationHit reputation []bans.ReputationHit
@@ -187,10 +187,9 @@ func (rq *request) check(ctx context.Context) *refusal {
rq.line.WouldAction = action rq.line.WouldAction = action
return nil return nil
case action == requestlog.ActionBlockedByBlockRule || case action == requestlog.ActionRuleBlocked || action == requestlog.ActionWAFBlocked:
action == requestlog.ActionBlockedByCoreRuleSet:
return &refusal{status: http.StatusForbidden, action: action} return &refusal{status: http.StatusForbidden, action: action}
case action == requestlog.ActionRequestHeaderTooLarge: case action == requestlog.ActionTooLarge:
return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action} return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action}
default: default:
return rq.banResponse(action) return rq.banResponse(action)
@@ -262,8 +261,8 @@ func (rq *request) finish() {
} }
duration := time.Since(rq.start) duration := time.Since(rq.start)
line.DurationTotalMS = requestlog.Milliseconds(duration) line.DurationTotal = requestlog.Milliseconds(duration)
line.DurationChecksMS = timing(rq.start, rq.checked) line.DurationChecks = timing(rq.start, rq.checked)
// Counted before the log line is written, so that the metrics count // Counted before the log line is written, so that the metrics count
// every request whose line is out. An allowed request is the exception: // every request whose line is out. An allowed request is the exception:
@@ -311,8 +310,8 @@ func (rq *request) addToHistory() {
ResponseBytes: rq.out.bytes, ResponseBytes: rq.out.bytes,
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit, BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
Attack: rq.attack, Attack: rq.attack,
BlockedByBlockRule: rq.blockedByBlockRule, RuleBlocked: rq.ruleBlocked,
BlockedByCoreRuleSet: rq.blockedByCoreRuleSet, WAFBlocked: rq.wafBlocked,
} }
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request) rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request)
+12 -12
View File
@@ -156,9 +156,9 @@ func wantBareLine(t *testing.T, line logLine) {
// withTimings returns want with the timings of line. // withTimings returns want with the timings of line.
func withTimings(line logLine, want requestlog.Line) requestlog.Line { func withTimings(line logLine, want requestlog.Line) requestlog.Line {
want.DurationTotalMS = line.DurationTotalMS want.DurationTotal = line.DurationTotal
want.DurationChecksMS = line.DurationChecksMS want.DurationChecks = line.DurationChecks
want.DurationUpstreamTotalMS = line.DurationUpstreamTotalMS want.DurationUpstreamTotal = line.DurationUpstreamTotal
return want return want
} }
@@ -226,10 +226,10 @@ func TestTimingsAreInOrder(t *testing.T) {
// A request passed to the app has every timing; one refused, none of // A request passed to the app has every timing; one refused, none of
// the app's; the health check, which runs no check, only the total. // the app's; the health check, which runs no check, only the total.
wantTimings(t, forwarded, "duration_total_ms", "duration_checks_ms", wantTimings(t, forwarded, "duration_total", "duration_checks",
"duration_upstream_total_ms") "duration_upstream_total")
wantTimings(t, refused, "duration_total_ms", "duration_checks_ms") wantTimings(t, refused, "duration_total", "duration_checks")
wantTimings(t, health, "duration_total_ms") wantTimings(t, health, "duration_total")
if t.Failed() { if t.Failed() {
return return
@@ -237,9 +237,9 @@ func TestTimingsAreInOrder(t *testing.T) {
// In whole microseconds, as they are logged, so that the sum below is // In whole microseconds, as they are logged, so that the sum below is
// exact. // exact.
total := microseconds(forwarded.DurationTotalMS) total := microseconds(forwarded.DurationTotal)
checks := microseconds(*forwarded.DurationChecksMS) checks := microseconds(*forwarded.DurationChecks)
upstream := microseconds(*forwarded.DurationUpstreamTotalMS) upstream := microseconds(*forwarded.DurationUpstreamTotal)
// The checks end before the request is handed to the app. // The checks end before the request is handed to the app.
if checks+upstream > total { if checks+upstream > total {
@@ -247,9 +247,9 @@ func TestTimingsAreInOrder(t *testing.T) {
total, checks, upstream) total, checks, upstream)
} }
if *refused.DurationChecksMS > refused.DurationTotalMS { if *refused.DurationChecks > refused.DurationTotal {
t.Errorf("refused request's checks took %v of %v milliseconds", t.Errorf("refused request's checks took %v of %v milliseconds",
*refused.DurationChecksMS, refused.DurationTotalMS) *refused.DurationChecks, refused.DurationTotal)
} }
} }
-42
View File
@@ -1,42 +0,0 @@
package discriminator
// The heuristic routewatch_tags. It reads SWW_ROUTEWATCH_URL.
import (
"context"
"strconv"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
)
// routeWatchTags adds to the request the tags asn=<number> and prefix=<netblock>
// from what routewatch answered about the client. It refuses nothing, and the
// request does not wait for routewatch. A request that gets no tags from it has
// the reason in the log line's routewatch_tags_missing. While SWW_ROUTEWATCH_URL
// is unset it does nothing. Answers are kept by client and asked for by the
// request's address, and a route without an origin AS gives only the prefix= tag.
type routeWatchTags struct{}
func (routeWatchTags) name() string { return "routewatch_tags" }
func (routeWatchTags) check(ctx context.Context, rq *request, _ time.Time) *verdict {
if rq.h.routeWatch == nil {
return nil
}
route, missing := rq.h.routeWatch.Route(ctx, rq.h.clientGroup(rq.client), rq.client)
if missing != "" {
rq.line.RouteWatchTagsMissing = string(missing)
return nil
}
rq.addTag(config.PrefixTagPrefix + route.Netblock.String())
if route.ASN != 0 {
rq.addTag(config.ASNTagPrefix + strconv.FormatUint(uint64(route.ASN), 10))
}
return nil
}
@@ -1,245 +0,0 @@
package discriminator_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// routeWatchURL is SWW_ROUTEWATCH_URL, and tagsMissing the log line's field
// that says why a request got no tags from routewatch.
const (
routeWatchURL = "SWW_ROUTEWATCH_URL"
tagsMissing = "routewatch_tags_missing"
fromV6 = "2001:db8:1::5"
prefixV6 = "2001:db8::/32"
prefixDE = "203.0.113.0/24"
prefixKP = "198.51.100.0/24"
)
// standInRoute is what the stand-in for routewatch answers about an address.
type standInRoute struct {
Netblock string `json:"netblock"`
ASN int `json:"asn"`
}
// startRouteWatch starts a stand-in for routewatch's IP info API that knows the
// routes given by address, and returns its URL.
func startRouteWatch(t *testing.T, routes map[string]standInRoute) string {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
address := strings.TrimPrefix(r.URL.Path, "/api/v1/ip/")
result := map[string]any{"ip": address}
if route, known := routes[address]; known {
result["netblock"], result["asn"] = route.Netblock, route.ASN
}
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"data": map[string]any{"results": []any{result}},
})
}))
t.Cleanup(server.Close)
return server.URL
}
// waitForRouteWatch waits until routewatch's answer about from, in the client
// group of the given length, is kept, so that the next request gets its tags.
func waitForRouteWatch(
t *testing.T, server *discriminator.Server, from string, bits int,
) {
t.Helper()
addr := netip.MustParseAddr(from)
client := netip.PrefixFrom(addr, bits).Masked()
deadline := time.Now().Add(5 * time.Second)
for {
_, missing := server.RouteWatch.Route(context.Background(), client, addr)
if missing == "" {
return
}
if time.Now().After(deadline) {
t.Fatalf("routewatch's answer about %s not kept: %q", from, missing)
}
time.Sleep(time.Millisecond)
}
}
func wantTagsMissing(t *testing.T, line logLine, want string) {
t.Helper()
got, _ := line.fields[tagsMissing].(string)
if got != want {
t.Errorf("log line has %s %q, want %q", tagsMissing, got, want)
}
}
func TestRouteWatchTagsAreOnTheLogLineWithTheLookupsTags(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 64496}})
s, server, _ := startWithLookups(t, map[string]string{routeWatchURL: url})
waitForRouteWatch(t, server, fromDE, 32)
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantTags(t, line, "asn=64496", "country=DE", "prefix="+prefixDE)
wantTagsMissing(t, line, "")
}
func TestRouteWatchTagsAreOnALineDenyNetsRefused(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromKP: {prefixKP, 64511}})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url, denyNets: fromKP,
})
waitForRouteWatch(t, server, fromKP, 32)
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+fromKP+`/32"}`)
wantTags(t, line, "asn=64511", "prefix="+prefixKP)
}
func TestRouteWithoutAnOriginASGivesOnlyThePrefixTag(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 0}})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url,
})
waitForRouteWatch(t, server, fromDE, 32)
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
"prefix="+prefixDE)
}
func TestUnsetRouteWatchChangesNothing(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{lookupSource: off})
if server.RouteWatch != nil {
t.Error("RouteWatch is set while SWW_ROUTEWATCH_URL is unset")
}
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
if _, present := line.fields[tagsMissing]; present {
t.Errorf("log line has %s", tagsMissing)
}
if _, present := line.fields["tags"]; present {
t.Errorf("log line has tags %v", line.fields["tags"])
}
}
func TestUnreachableRouteWatchLetsTheRequestThroughAndSaysWhy(t *testing.T) {
t.Parallel()
// A stand-in that is closed at once, so that the port refuses connections.
dead := httptest.NewServer(http.NotFoundHandler())
url := dead.URL
dead.Close()
s, _, _ := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url,
})
wantTagsMissing(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
"no_answer_yet")
deadline := time.Now().Add(5 * time.Second)
for {
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
missing, _ := line.fields[tagsMissing].(string)
if missing == "request_failed" {
return
}
if time.Now().After(deadline) {
t.Fatalf("still %q, want request_failed", missing)
}
time.Sleep(time.Millisecond)
}
}
func TestAddressWithoutARouteHasNoTagsAndTheLineSaysSo(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: startRouteWatch(t, nil),
})
deadline := time.Now().Add(5 * time.Second)
for {
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
missing, _ := line.fields[tagsMissing].(string)
if missing == "no_route" {
return
}
if time.Now().After(deadline) {
t.Fatalf("still %q, want no_route", missing)
}
time.Sleep(time.Millisecond)
}
}
func TestTagDenyRefusesOnARouteWatchPrefix(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{
fromDE: {prefixDE, 64496}, fromKP: {prefixKP, 64511},
})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url, tagDeny: "prefix=" + prefixKP,
})
waitForRouteWatch(t, server, fromKP, 32)
waitForRouteWatch(t, server, fromDE, 32)
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "prefix=`+prefixKP+`"}`)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
func TestTagLimitPercentWorksWithAnIPv6Prefix(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromV6: {prefixV6, 64496}})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url, rateLimitPerMinute: "2",
tagLimitPercent: "prefix=" + prefixV6 + ":50",
})
waitForRouteWatch(t, server, fromV6, 64)
// Half of two is one: the second request breaks the limit.
line := s.get(fromV6, http.StatusOK, requestlog.ActionForward)
wantTags(t, line, "asn=64496", "prefix="+prefixV6)
s.get(fromV6, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
}
+7 -10
View File
@@ -16,10 +16,9 @@ import (
// ruleFiles checks the request against the rules of the rule files at now, // ruleFiles checks the request against the rules of the rule files at now,
// notes the ids of those it matches in the log line, adds the tag // notes the ids of those it matches in the log line, adds the tag
// exploitsignature when it matches any, and refuses the request // exploitsignature when it matches any, and refuses the request
// when the last rule it matches refuses it: with ActionBlockedByBlockRule for a // when the last rule it matches refuses it: with ActionRuleBlocked for a block
// block rule, and for a ban rule with the action for the ban it makes. A ban // rule, and ActionBanned for a ban rule. A ban rule bans the client's netblock
// rule bans the client's netblock for a clear sign of attack, or in observe // for a clear sign of attack, or in observe mode raises the alert for the ban
// mode raises the alert for the ban
// it would have made. Either rule's match is noted as an offence, for the // it would have made. Either rule's match is noted as an offence, for the
// client's history. Its figures are the rule's id and action. // client's history. Its figures are the rule's id and action.
type ruleFiles struct{} type ruleFiles struct{}
@@ -46,16 +45,14 @@ func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict {
switch last.Action { switch last.Action {
case rules.ActionBlock: case rules.ActionBlock:
rq.blockedByBlockRule = true rq.ruleBlocked = true
return &verdict{action: requestlog.ActionBlockedByBlockRule, figures: figures} return &verdict{action: requestlog.ActionRuleBlocked, figures: figures}
case rules.ActionBan: case rules.ActionBan:
rq.attack = true rq.attack = true
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
return &verdict{ return &verdict{action: requestlog.ActionBanned, figures: figures}
action: rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target}),
figures: figures,
}
default: default:
return nil return nil
} }
+18 -24
View File
@@ -37,14 +37,13 @@ func TestEachRuleAction(t *testing.T) {
// A block rule refuses with 403, whatever SWW_BAN_RESPONSE is, and // A block rule refuses with 403, whatever SWW_BAN_RESPONSE is, and
// bans no one. // bans no one.
line = s.request(client, "/blocked", http.StatusForbidden, line = s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionBlockedByBlockRule) requestlog.ActionRuleBlocked)
wantRuleIDs(t, line, "noted", "blocked") wantRuleIDs(t, line, "noted", "blocked")
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
// A ban rule refuses with SWW_BAN_RESPONSE, and bans the client for // A ban rule refuses with SWW_BAN_RESPONSE, and bans the client for
// seven days, the default. // seven days, the default.
line = s.request(client, "/.env", http.StatusTooManyRequests, line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned)
requestlog.ActionTemporarilyBanned)
wantRuleIDs(t, line, "noted", "probe") wantRuleIDs(t, line, "noted", "probe")
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) { if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
@@ -83,7 +82,7 @@ func TestEachRuleAction(t *testing.T) {
// against the rules, and makes the ban permanent. // against the rules, and makes the ban permanent.
clk.advance(time.Hour) clk.advance(time.Hour)
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned) line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
wantRuleIDs(t, line) wantRuleIDs(t, line)
if line.BanExpires != permanent { if line.BanExpires != permanent {
@@ -91,7 +90,7 @@ func TestEachRuleAction(t *testing.T) {
} }
clk.advance(365 * 24 * time.Hour) clk.advance(365 * 24 * time.Hour)
s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned) s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
} }
func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) { func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
@@ -104,7 +103,7 @@ func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward), wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward),
"exploitsignature") "exploitsignature")
wantTags(t, s.request(client, "/blocked", http.StatusForbidden, wantTags(t, s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionBlockedByBlockRule), "exploitsignature") requestlog.ActionRuleBlocked), "exploitsignature")
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward)) wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
} }
@@ -116,11 +115,10 @@ func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
}) })
line := s.request(client, "/blocked", http.StatusForbidden, line := s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionBlockedByBlockRule) requestlog.ActionRuleBlocked)
wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`) wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`)
line = s.request(client, "/.env", http.StatusForbidden, line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
requestlog.ActionTemporarilyBanned)
wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`) wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`)
} }
@@ -133,8 +131,7 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
}) })
// The first probe bans for SWW_ATTACK_BAN_DURATION. // The first probe bans for SWW_ATTACK_BAN_DURATION.
line := s.request(client, "/.env", http.StatusForbidden, line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
requestlog.ActionTemporarilyBanned)
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) { if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires) t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
} }
@@ -144,8 +141,7 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
clk.advance(time.Hour) clk.advance(time.Hour)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
line = s.request(client, "/.env", http.StatusForbidden, line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
requestlog.ActionPermanentlyBanned)
if line.BanExpires != permanent { if line.BanExpires != permanent {
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires) t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
} }
@@ -173,8 +169,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
// A probe over the rate limit breaks the limit before any rule sees // A probe over the rate limit breaks the limit before any rule sees
// it. // it.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
line = s.request(client, "/.env", http.StatusForbidden, line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionRateLimited)
requestlog.ActionClientIsRateLimited)
wantRuleIDs(t, line) wantRuleIDs(t, line)
limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32")) limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
@@ -184,7 +179,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
// A client the rate limits do not apply to is still checked. // A client the rate limits do not apply to is still checked.
s.get(exempt, http.StatusOK, requestlog.ActionForward) s.get(exempt, http.StatusOK, requestlog.ActionForward)
s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionBanned)
} }
func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) { func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
@@ -196,11 +191,11 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
}) })
line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward) line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule) wantWouldAction(t, line, requestlog.ActionRuleBlocked)
wantRuleIDs(t, line, "blocked") wantRuleIDs(t, line, "blocked")
line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward) line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned) wantWouldAction(t, line, requestlog.ActionBanned)
wantRuleIDs(t, line, "probe") wantRuleIDs(t, line, "probe")
if line.BanExpires != "" { if line.BanExpires != "" {
@@ -224,10 +219,9 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
metricsToken: token, metricsToken: token,
}) })
s.request(client, "/blocked", http.StatusForbidden, s.request(client, "/blocked", http.StatusForbidden, requestlog.ActionRuleBlocked)
requestlog.ActionBlockedByBlockRule) s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(client, http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
@@ -235,10 +229,10 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
wantMetric(t, metrics, wantMetric(t, metrics,
`sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1) `sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2) wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2)
wantMetric(t, metrics, `sww_requests_total{action="blocked_by_block_rule",`+ wantMetric(t, metrics, `sww_requests_total{action="rule_blocked",`+
`instance="app",status_class="4xx"}`, 1) `instance="app",status_class="4xx"}`, 1)
wantMetric(t, metrics, wantMetric(t, metrics,
`sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 1) `sww_offences_total{instance="app",kind="rule_blocked"}`, 1)
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1) wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1)
wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1) wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1)
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0) wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0)
+8 -8
View File
@@ -68,7 +68,7 @@ func TestRequestFromAllowNetsIsNotCounted(t *testing.T) {
{listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward},
{listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusOK, requestlog.ActionForward}, {unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited}, {unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
}) })
} }
@@ -92,7 +92,7 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
wantStatus(t, do(t, req), http.StatusForbidden) wantStatus(t, do(t, req), http.StatusForbidden)
line := out.endedLine(t) line := out.endedLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientDenied) wantLine(t, line, http.StatusForbidden, requestlog.ActionDenied)
if len(asked()) != 0 { if len(asked()) != 0 {
t.Errorf("GeoJS was asked about %v, want nothing", asked()) t.Errorf("GeoJS was asked about %v, want nothing", asked())
@@ -108,7 +108,7 @@ func TestDenyNetsRefusalNamesItsHeuristicAndNetblock(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet}) s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet})
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied) line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`) wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`)
} }
@@ -125,10 +125,10 @@ func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
// listedAddr's refused requests are not counted, so the first request // listedAddr's refused requests are not counted, so the first request
// from unlistedAddr is within the limit of one a minute. // from unlistedAddr is within the limit of one a minute.
wantAnswers(t, addr, out, []sentRequest{ wantAnswers(t, addr, out, []sentRequest{
{listedAddr, http.StatusForbidden, requestlog.ActionClientDenied}, {listedAddr, http.StatusForbidden, requestlog.ActionDenied},
{listedAddr, http.StatusForbidden, requestlog.ActionClientDenied}, {listedAddr, http.StatusForbidden, requestlog.ActionDenied},
{unlistedAddr, http.StatusOK, requestlog.ActionForward}, {unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited}, {unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
}) })
} }
@@ -152,8 +152,8 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
{listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward},
{listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusOK, requestlog.ActionForward}, {unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited}, {unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
{fromKP, http.StatusForbidden, requestlog.ActionClientDenied}, {fromKP, http.StatusForbidden, requestlog.ActionDenied},
}) })
} }
+1 -1
View File
@@ -20,7 +20,7 @@ func (tagDeny) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, tag := range rq.line.Tags { for _, tag := range rq.line.Tags {
if slices.Contains(rq.h.config.TagDeny, tag) { if slices.Contains(rq.h.config.TagDeny, tag) {
return &verdict{ return &verdict{
action: requestlog.ActionClientDenied, action: requestlog.ActionDenied,
figures: map[string]any{"tag": tag}, figures: map[string]any{"tag": tag},
} }
} }
+9 -12
View File
@@ -14,7 +14,7 @@ func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
// in capitals. // in capitals.
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"}) s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied) line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`) wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`)
wantTags(t, line, "asn=64511", "country=KP") wantTags(t, line, "asn=64511", "country=KP")
@@ -26,7 +26,7 @@ func TestTagDenyRefusesAnASNTag(t *testing.T) {
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"}) s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"})
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied), wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
"tag_deny", `{"tag": "asn=64511"}`) "tag_deny", `{"tag": "asn=64511"}`)
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
} }
@@ -41,7 +41,7 @@ func TestTagDenyRefusesAListTagBeforeTheRateLimitsCount(t *testing.T) {
// Each is refused for its tag: none is counted to break the limit of one. // Each is refused for its tag: none is counted to break the limit of one.
for range 3 { for range 3 {
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied), wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionDenied),
"tag_deny", `{"tag": "torexit"}`) "tag_deny", `{"tag": "torexit"}`)
} }
} }
@@ -58,7 +58,7 @@ func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
}) })
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}}) loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied), wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionDenied),
"tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`) "tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`)
} }
@@ -69,7 +69,7 @@ func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature", rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature",
}) })
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied) line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`) wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
wantTags(t, line, "exploitsignature") wantTags(t, line, "exploitsignature")
} }
@@ -87,9 +87,8 @@ func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
// A quarter of 4 requests a minute: the second breaks the limit. // A quarter of 4 requests a minute: the second breaks the limit.
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited) line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
wantPercent(t, "rate_limit_percent", wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
line.RateLimitPercent, line.RateLimitPercentSetting,
"25 from "+tagLimitPercent) "25 from "+tagLimitPercent)
} }
@@ -109,8 +108,7 @@ func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) {
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes) t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
} }
wantPercent(t, "bytes_limit_percent", wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
line.BytesLimitPercent, line.BytesLimitPercentSetting,
"50 from "+tagLimitPercent) "50 from "+tagLimitPercent)
// fromKP, which the list does not hold, has the whole limit. // fromKP, which the list does not hold, has the whole limit.
@@ -119,6 +117,5 @@ func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) {
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit) t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
} }
wantPercent(t, "bytes_limit_percent", wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, none)
line.BytesLimitPercent, line.BytesLimitPercentSetting, none)
} }
+3 -3
View File
@@ -80,7 +80,7 @@ func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
}) })
loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}}) loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied) line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
if line.Heuristic != "deny_nets" { if line.Heuristic != "deny_nets" {
t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic) t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic)
} }
@@ -89,9 +89,9 @@ func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
// The second request breaks the limit of one a minute, which bans the client. // The second request breaks the limit of one a minute, which bans the client.
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
line = s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned) line = s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
if line.Heuristic != "active_ban" { if line.Heuristic != "active_ban" {
t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic) t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic)
} }
+3 -1
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
) )
@@ -29,9 +30,10 @@ func (trapPaths) check(_ context.Context, rq *request, now time.Time) *verdict {
} }
rq.attack = true rq.attack = true
rq.banForAttack(now, bans.Notes{TrapPath: path})
return &verdict{ return &verdict{
action: rq.banForAttack(now, bans.Notes{TrapPath: path}), action: requestlog.ActionBanned,
figures: map[string]any{"path": path}, figures: map[string]any{"path": path},
} }
} }
+6 -8
View File
@@ -43,7 +43,7 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
// The query is not part of the path. // The query is not part of the path.
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusTooManyRequests, line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusTooManyRequests,
requestlog.ActionTemporarilyBanned) requestlog.ActionBanned)
wantRuleIDs(t, line) wantRuleIDs(t, line)
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) { if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
@@ -77,7 +77,7 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
} }
// The next request is refused under the ban, and makes it permanent. // The next request is refused under the ban, and makes it permanent.
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned) line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
if line.BanExpires != permanent { if line.BanExpires != permanent {
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires) t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
} }
@@ -89,7 +89,7 @@ func TestTrapPathRefusalNamesItsHeuristicAndPath(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList}) s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden, line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden,
requestlog.ActionTemporarilyBanned) requestlog.ActionBanned)
wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`) wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`)
} }
@@ -100,8 +100,7 @@ func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
// %2577 decodes once to %77, not to w. // %2577 decodes once to %77, not to w.
s.request(otherClient, "/%2577p-login.php", http.StatusOK, requestlog.ActionForward) s.request(otherClient, "/%2577p-login.php", http.StatusOK, requestlog.ActionForward)
s.request(client, "/%77p-login.php", http.StatusForbidden, s.request(client, "/%77p-login.php", http.StatusForbidden, requestlog.ActionBanned)
requestlog.ActionTemporarilyBanned)
// The ban's notes give the path as the client sent it. // The ban's notes give the path as the client sent it.
got := server.Ledger.Bans(netip.MustParsePrefix(client + "/32")) got := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
@@ -118,8 +117,7 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
"SWW_RULES_ENABLED": "false", "SWW_RULES_ENABLED": "false",
}) })
s.request(client, "/xmlrpc.php", http.StatusForbidden, s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned)
requestlog.ActionTemporarilyBanned)
} }
func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) { func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
@@ -131,7 +129,7 @@ func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
}) })
line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward) line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned) wantWouldAction(t, line, requestlog.ActionBanned)
// No ban was made. // No ban was made.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
+3 -3
View File
@@ -198,7 +198,7 @@ func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
answer, found := g.kept(client) answer, found := g.kept(client)
if !found { if !found {
g.metrics.RequestsWithoutGeoJSAnswer.Inc() g.metrics.GeoJSUnanswered.Inc()
} }
w, waiting := g.waiting[client] w, waiting := g.waiting[client]
@@ -296,7 +296,7 @@ func (g *GeoJS) answerOrWait(
} }
if w == nil { if w == nil {
g.metrics.RequestsWithoutGeoJSAnswer.Inc() g.metrics.GeoJSUnanswered.Inc()
return Answer{}, nil // too many clients wait already return Answer{}, nil // too many clients wait already
} }
@@ -307,7 +307,7 @@ func (g *GeoJS) answerOrWait(
} }
if w.late { if w.late {
g.metrics.RequestsWithoutGeoJSAnswer.Inc() g.metrics.GeoJSUnanswered.Inc()
return Answer{}, nil return Answer{}, nil
} }
+8 -8
View File
@@ -362,7 +362,7 @@ func TestWithoutWaitTheRequestGoesOnAtOnceAndTheAnswerIsGivenWhenItComes(
mu.Unlock() mu.Unlock()
wantCountry(t, g, client, germany) wantCountry(t, g, client, germany)
wantRequestsWithoutGeoJSAnswer(t, m, 0) wantUnanswered(t, m, 0)
}) })
} }
@@ -596,7 +596,7 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
// GeoJS fails, so the first client goes without an answer, and GeoJS // GeoJS fails, so the first client goes without an answer, and GeoJS
// is left alone for a second, which does not pass in this test. // is left alone for a second, which does not pass in this test.
wantCountry(t, g, clients(), "") wantCountry(t, g, clients(), "")
wantRequestsWithoutGeoJSAnswer(t, m, 1) wantUnanswered(t, m, 1)
// Meanwhile each new client goes without one at once, while there is // Meanwhile each new client goes without one at once, while there is
// room for it among the 10,000 that may wait. // room for it among the 10,000 that may wait.
@@ -604,11 +604,11 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
wantCountry(t, g, clients(), "") wantCountry(t, g, clients(), "")
} }
wantRequestsWithoutGeoJSAnswer(t, m, 10000) wantUnanswered(t, m, 10000)
// One more, for which there is no room, goes without one too. // One more, for which there is no room, goes without one too.
wantCountry(t, g, clients(), "") wantCountry(t, g, clients(), "")
wantRequestsWithoutGeoJSAnswer(t, m, 10001) wantUnanswered(t, m, 10001)
}) })
} }
@@ -841,12 +841,12 @@ func wantAsked(t *testing.T, geojs *standIn, i int, want ...string) {
} }
} }
// wantRequestsWithoutGeoJSAnswer checks how many requests m counts as having // wantUnanswered checks how many requests m counts as having gone without
// gone without an answer from GeoJS. // an answer from GeoJS.
func wantRequestsWithoutGeoJSAnswer(t *testing.T, m *metrics.Metrics, want float64) { func wantUnanswered(t *testing.T, m *metrics.Metrics, want float64) {
t.Helper() t.Helper()
got := testutil.ToFloat64(m.RequestsWithoutGeoJSAnswer) got := testutil.ToFloat64(m.GeoJSUnanswered)
if got != want { if got != want {
t.Errorf("%v requests went without an answer, want %v", got, want) t.Errorf("%v requests went without an answer, want %v", got, want)
} }
+18 -18
View File
@@ -36,7 +36,7 @@ type Metrics struct {
upstreamDuration prometheus.Histogram upstreamDuration prometheus.Histogram
rateLimitHits *prometheus.CounterVec rateLimitHits *prometheus.CounterVec
offences *prometheus.CounterVec offences *prometheus.CounterVec
coreRuleSetMatches *prometheus.CounterVec wafMatches *prometheus.CounterVec
// ruleMatches are made by AddRules, and reputationHits by // ruleMatches are made by AddRules, and reputationHits by
// AddReputation. // AddReputation.
ruleMatches *prometheus.CounterVec ruleMatches *prometheus.CounterVec
@@ -45,12 +45,12 @@ type Metrics struct {
asns *busiest asns *busiest
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those // GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
// that failed. RequestsWithoutGeoJSAnswer are the requests that needed // that failed. GeoJSUnanswered are the requests that needed their
// their client's answer, for a setting that acts on it, and went on // client's answer, for a setting that acts on it, and went on without
// without it because GeoJS had not given it in time. // it because GeoJS had not given it in time.
GeoJSRequests prometheus.Counter GeoJSRequests prometheus.Counter
GeoJSFailures prometheus.Counter GeoJSFailures prometheus.Counter
RequestsWithoutGeoJSAnswer prometheus.Counter GeoJSUnanswered prometheus.Counter
stateFileWrites *prometheus.CounterVec stateFileWrites *prometheus.CounterVec
stateFileWriteFailures *prometheus.CounterVec stateFileWriteFailures *prometheus.CounterVec
@@ -101,7 +101,7 @@ func New(topN int, instanceName string) *Metrics {
[]string{"window", "kind"}), []string{"window", "kind"}),
offences: counterVec("sww_offences_total", offences: counterVec("sww_offences_total",
"Offences, by kind.", []string{"kind"}), "Offences, by kind.", []string{"kind"}),
coreRuleSetMatches: counterVec("sww_core_rule_set_matches_total", wafMatches: counterVec("sww_waf_matches_total",
"Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+ "Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+
"and the rule's id.", []string{"mode", "rule_id"}), "and the rule's id.", []string{"mode", "rule_id"}),
countries: newCountries(topN), countries: newCountries(topN),
@@ -114,8 +114,8 @@ func New(topN int, instanceName string) *Metrics {
Name: "sww_geojs_failures_total", Name: "sww_geojs_failures_total",
Help: "Requests to GeoJS that failed.", Help: "Requests to GeoJS that failed.",
}), }),
RequestsWithoutGeoJSAnswer: prometheus.NewCounter(prometheus.CounterOpts{ GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
Name: "sww_requests_without_geojs_answer_total", Name: "sww_geojs_unanswered_total",
Help: "Requests that needed their client's answer from GeoJS and " + Help: "Requests that needed their client's answer from GeoJS and " +
"went on without it, because GeoJS had not given it in time.", "went on without it, because GeoJS had not given it in time.",
}), }),
@@ -139,9 +139,9 @@ func New(topN int, instanceName string) *Metrics {
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
m.inFlight, m.requests, m.requestBytes, m.responseBytes, m.inFlight, m.requests, m.requestBytes, m.responseBytes,
m.requestDuration, m.upstreamDuration, m.requestDuration, m.upstreamDuration,
m.rateLimitHits, m.offences, m.coreRuleSetMatches, m.rateLimitHits, m.offences, m.wafMatches,
m.countries, m.asns, m.countries, m.asns,
m.GeoJSRequests, m.GeoJSFailures, m.RequestsWithoutGeoJSAnswer, m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
m.stateFileWrites, m.stateFileWriteFailures, m.stateFileWrites, m.stateFileWriteFailures,
m.stateFileLastWrite, m.stateFileSize, m.stateFileLastWrite, m.stateFileSize,
m.stateFileEditsTakenIn, m.stateFileEditsSetAside, m.stateFileEditsTakenIn, m.stateFileEditsSetAside,
@@ -232,7 +232,7 @@ func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
return float64(remote.Dropped()) return float64(remote.Dropped())
}), }),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "sww_remote_log_lines_waiting", Name: "sww_remote_log_buffer_depth",
Help: "Log lines in the buffer, waiting to be sent.", Help: "Log lines in the buffer, waiting to be sent.",
}, func() float64 { }, func() float64 {
return float64(remote.Depth()) return float64(remote.Depth())
@@ -355,11 +355,11 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
return float64(queue.Counts(name).Sent) return float64(queue.Counts(name).Sent)
}), }),
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "sww_alert_send_failures_total", Name: "sww_alerts_failed_total",
Help: "Requests to the destination that failed.", Help: "Requests to the destination that failed.",
ConstLabels: destination, ConstLabels: destination,
}, func() float64 { }, func() float64 {
return float64(queue.Counts(name).SendFailures) return float64(queue.Counts(name).Failed)
}), }),
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "sww_alerts_suppressed_total", Name: "sww_alerts_suppressed_total",
@@ -426,8 +426,8 @@ func (m *Metrics) Offences(r ratelimit.Request) {
for kind, committed := range map[string]bool{ for kind, committed := range map[string]bool{
"limit": r.BrokeLimit, "limit": r.BrokeLimit,
"attack": r.Attack, "attack": r.Attack,
"blocked_by_block_rule": r.BlockedByBlockRule, "rule_blocked": r.RuleBlocked,
"blocked_by_core_rule_set": r.BlockedByCoreRuleSet, "waf_blocked": r.WAFBlocked,
} { } {
if committed { if committed {
m.offences.WithLabelValues(kind).Inc() m.offences.WithLabelValues(kind).Inc()
@@ -441,10 +441,10 @@ func (m *Metrics) RuleMatched(id, action string) {
m.ruleMatches.WithLabelValues(id, action).Inc() m.ruleMatches.WithLabelValues(id, action).Inc()
} }
// CoreRuleSetMatched counts a request that matched the Core Rule Set's rule id, // WAFMatched counts a request that matched the Core Rule Set's rule id,
// with SWW_WAF_MODE at mode. // with SWW_WAF_MODE at mode.
func (m *Metrics) CoreRuleSetMatched(mode string, id int) { func (m *Metrics) WAFMatched(mode string, id int) {
m.coreRuleSetMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc() m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
} }
// StateFileWritten counts a write of the state file name, of size bytes, // StateFileWritten counts a write of the state file name, of size bytes,
+12 -13
View File
@@ -122,13 +122,12 @@ type Responses struct {
type Offences struct { type Offences struct {
// Limit is its requests that broke a rate limit, a byte limit or the // Limit is its requests that broke a rate limit, a byte limit or the
// error burst, Attack those that were a clear sign of attack, a match // error burst, Attack those that were a clear sign of attack, a match
// of a ban rule or a request for a trap path, BlockedByBlockRule those a // of a ban rule or a request for a trap path, RuleBlocked those a block
// block rule refused, and BlockedByCoreRuleSet those the Core Rule Set // rule refused, and WAFBlocked those the Core Rule Set refused.
// refused.
Limit int64 `json:"limit"` Limit int64 `json:"limit"`
Attack int64 `json:"attack"` Attack int64 `json:"attack"`
BlockedByBlockRule int64 `json:"blocked_by_block_rule"` RuleBlocked int64 `json:"rule_blocked"`
BlockedByCoreRuleSet int64 `json:"blocked_by_core_rule_set"` WAFBlocked int64 `json:"waf_blocked"`
// TokenRefused is what a clients.json written by an earlier version gives, // TokenRefused is what a clients.json written by an earlier version gives,
// which must still load. Nothing adds to it. // which must still load. Nothing adds to it.
TokenRefused int64 `json:"token_refused,omitempty"` TokenRefused int64 `json:"token_refused,omitempty"`
@@ -148,12 +147,12 @@ type Request struct {
ResponseBytes int64 ResponseBytes int64
// BrokeLimit is true for a request that broke a rate limit, a byte // BrokeLimit is true for a request that broke a rate limit, a byte
// limit or the error burst, Attack for one that matched a ban rule or // limit or the error burst, Attack for one that matched a ban rule or
// asked for a trap path, BlockedByBlockRule for one a block rule refused, // asked for a trap path, RuleBlocked for one a block rule refused, and
// and BlockedByCoreRuleSet for one the Core Rule Set refused. // WAFBlocked for one the Core Rule Set refused.
BrokeLimit bool BrokeLimit bool
Attack bool Attack bool
BlockedByBlockRule bool RuleBlocked bool
BlockedByCoreRuleSet bool WAFBlocked bool
} }
// New returns a Limiter for limits, with no client counted yet, whose // New returns a Limiter for limits, with no client counted yet, whose
@@ -324,12 +323,12 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
h.Offences.Attack++ h.Offences.Attack++
} }
if r.BlockedByBlockRule { if r.RuleBlocked {
h.Offences.BlockedByBlockRule++ h.Offences.RuleBlocked++
} }
if r.BlockedByCoreRuleSet { if r.WAFBlocked {
h.Offences.BlockedByCoreRuleSet++ h.Offences.WAFBlocked++
} }
} }
+54 -64
View File
@@ -20,44 +20,39 @@ const (
// the app. // the app.
ActionForward = "forward" ActionForward = "forward"
// ActionRequestHeaderTooLarge is a request over the header limits. // ActionTooLarge is a request over the header limits.
ActionRequestHeaderTooLarge = "request_header_too_large" ActionTooLarge = "too_large"
// ActionClientIsRateLimited is a request refused because it took its // ActionRateLimited is a request refused because it took its client
// client over a rate limit, which bans the client. // over a rate limit, which bans the client.
ActionClientIsRateLimited = "client_is_rate_limited" ActionRateLimited = "rate_limited"
// ActionTemporarilyBanned is a request refused because a ban that runs // ActionBanned is a request refused because a ban covers its client,
// out covers its client, or because it matched a ban rule, asked for a // or because it matched a ban rule, asked for a trap path or the
// trap path or the CrowdSec decision list lists its client, and so made // CrowdSec decision list lists its client, each of which bans the
// such a ban. // client.
ActionTemporarilyBanned = "temporarily_banned" ActionBanned = "banned"
// ActionPermanentlyBanned is a request refused because a ban that never // ActionRuleBlocked is a request refused because it matched a block
// runs out covers its client, or because the request made such a ban or // rule.
// made its client's ban permanent. ActionRuleBlocked = "rule_blocked"
ActionPermanentlyBanned = "permanently_banned"
// ActionBlockedByBlockRule is a request refused because it matched a // ActionWAFBlocked is a request refused because the Core Rule Set
// block rule. // scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
ActionBlockedByBlockRule = "blocked_by_block_rule" ActionWAFBlocked = "waf_blocked"
// ActionBlockedByCoreRuleSet is a request refused because the Core Rule // ActionDenied is a request refused because its client is in
// Set scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set"
// ActionClientDenied is a request refused because its client is in
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny, // SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while // or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY. // SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
ActionClientDenied = "client_denied" ActionDenied = "denied"
// ActionCountryDenied is a request refused because its client's country // ActionCountryDenied is a request refused because its client's country
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES. // is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
ActionCountryDenied = "country_denied" ActionCountryDenied = "country_denied"
// ActionOwnEndpoint is a request discriminator answered at one of its own // ActionAdmin is a request discriminator answered at one of its own
// endpoints, or a question about a path under /_sww/. // endpoints, or a question about a path under /_sww/.
ActionOwnEndpoint = "own_endpoint" ActionAdmin = "admin"
) )
// OffenceLimit is the offence a request line names for a request that // OffenceLimit is the offence a request line names for a request that
@@ -65,9 +60,9 @@ const (
// limit. // limit.
const OffenceLimit = "limit" const OffenceLimit = "limit"
// LimitHitMinuteRefusals is the limit_hit a request line names for a request // LimitHitErrorBurst is the limit_hit a request line names for a request
// that broke the error burst. // that broke the error burst.
const LimitHitMinuteRefusals = "minute_refusals" const LimitHitErrorBurst = "error_burst"
// The discriminator_line a request line names, which says which of // The discriminator_line a request line names, which says which of
// discriminator's two lines about a question it is. // discriminator's two lines about a question it is.
@@ -139,11 +134,10 @@ type Line struct {
// The decision. // The decision.
Action string `json:"action"` Action string `json:"action"`
// WouldAction is, in observe mode, the action enforce mode would have // WouldAction is, in observe mode, the action enforce mode would have
// taken with a request it would have refused: ActionClientDenied, // taken with a request it would have refused: ActionDenied,
// ActionTemporarilyBanned, ActionPermanentlyBanned, ActionCountryDenied, // ActionBanned, ActionCountryDenied, ActionRateLimited,
// ActionClientIsRateLimited, ActionBlockedByBlockRule, // ActionRuleBlocked, ActionWAFBlocked, or, in discriminator, ActionTooLarge
// ActionBlockedByCoreRuleSet, or, in discriminator, // for the header limits.
// ActionRequestHeaderTooLarge for the header limits.
WouldAction string `json:"would_action,omitempty"` WouldAction string `json:"would_action,omitempty"`
// Heuristic is the name of the heuristic that refused the request, in // Heuristic is the name of the heuristic that refused the request, in
// observe mode too, and HeuristicFigures are the figures it acted on, by // observe mode too, and HeuristicFigures are the figures it acted on, by
@@ -155,15 +149,15 @@ type Line struct {
// request. // request.
AfterAnswerHeuristic string `json:"after_answer_heuristic,omitempty"` AfterAnswerHeuristic string `json:"after_answer_heuristic,omitempty"`
AfterAnswerHeuristicFigures map[string]any `json:"after_answer_heuristic_figures,omitempty"` //nolint:lll // a struct tag cannot be split AfterAnswerHeuristicFigures map[string]any `json:"after_answer_heuristic_figures,omitempty"` //nolint:lll // a struct tag cannot be split
// RateLimitPercent and RateLimitPercentSetting are, for a request the // LimitPercent and LimitPercentSetting are, for a request the rate
// rate limits counted whose client a biased threshold gives a percentage // limits counted whose client a biased threshold gives a percentage of
// of the rate limits below 100, that percentage and the setting that gave // the rate limits below 100, that percentage and the setting that gave
// it. BytesLimitPercent and BytesLimitPercentSetting are the same for the // it. BytesPercent and BytesPercentSetting are the same for the byte
// byte limits. // limits.
RateLimitPercent *int64 `json:"rate_limit_percent,omitempty"` LimitPercent *int64 `json:"limit_percent,omitempty"`
RateLimitPercentSetting string `json:"rate_limit_percent_setting,omitempty"` LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
BytesLimitPercent *int64 `json:"bytes_limit_percent,omitempty"` BytesPercent *int64 `json:"bytes_percent,omitempty"`
BytesLimitPercentSetting string `json:"bytes_limit_percent_setting,omitempty"` BytesPercentSetting string `json:"bytes_percent_setting,omitempty"`
// Counts are, for a request the rate limits counted, the client's // Counts are, for a request the rate limits counted, the client's
// requests as they counted them with this one, and its bytes as the // requests as they counted them with this one, and its bytes as the
// byte limits counted them, with this request's once it has ended if // byte limits counted them, with this request's once it has ended if
@@ -171,15 +165,15 @@ type Line struct {
Counts ratelimit.Counts `json:"counts,omitzero"` Counts ratelimit.Counts `json:"counts,omitzero"`
// RuleIDs are the ids of the rule file rules the request matched. // RuleIDs are the ids of the rule file rules the request matched.
RuleIDs []string `json:"rule_ids,omitempty"` RuleIDs []string `json:"rule_ids,omitempty"`
// CoreRuleSetRuleIDs are the ids of the Core Rule Set's rules the request // WAFRuleIDs are the ids of the Core Rule Set's rules the request
// matched, and CoreRuleSetScore its anomaly score, nil for a request the // matched, and WAFScore its anomaly score, nil for a request the Core
// Core Rule Set did not inspect. // Rule Set did not inspect.
CoreRuleSetRuleIDs []int `json:"core_rule_set_rule_ids,omitempty"` WAFRuleIDs []int `json:"waf_rule_ids,omitempty"`
CoreRuleSetScore *int `json:"core_rule_set_score,omitempty"` WAFScore *int `json:"waf_score,omitempty"`
// LimitHit is the window whose limit the request went over, named as // LimitHit is the window whose limit the request went over, named as
// Counts names its count: minute, hour or day for a rate limit, and // Counts names its count: minute, hour or day for a rate limit, and
// minute_bytes, hour_bytes or day_bytes for a byte limit; or // minute_bytes, hour_bytes or day_bytes for a byte limit; or
// LimitHitMinuteRefusals for the error burst. // LimitHitErrorBurst for the error burst.
LimitHit string `json:"limit_hit,omitempty"` LimitHit string `json:"limit_hit,omitempty"`
// Reputation are the URLs of the blocklists that list the client, then // Reputation are the URLs of the blocklists that list the client, then
// that of the CrowdSec decision list when it does, then the DNSBL zones // that of the CrowdSec decision list when it does, then the DNSBL zones
@@ -188,25 +182,21 @@ type Line struct {
Reputation []string `json:"reputation,omitempty"` Reputation []string `json:"reputation,omitempty"`
// Tags are the request's tags, sorted. // Tags are the request's tags, sorted.
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
// RouteWatchTagsMissing, while SWW_ROUTEWATCH_URL is set, says why the
// request got no tags from routewatch: no_answer_yet, request_failed or
// no_route.
RouteWatchTagsMissing string `json:"routewatch_tags_missing,omitempty"`
// Offence is the offence the request was held as, OffenceLimit. // Offence is the offence the request was held as, OffenceLimit.
Offence string `json:"offence,omitempty"` Offence string `json:"offence,omitempty"`
// BanExpires is when the ban the request made, or was refused under, // BanExpires is when the ban the request made, or was refused under,
// ends: a time, or "permanent". // ends: a time, or "permanent".
BanExpires string `json:"ban_expires,omitempty"` BanExpires string `json:"ban_expires,omitempty"`
// The timings. DurationChecksMS is the time until the checks were done, and // The timings, in milliseconds. DurationChecks is the time until the
// DurationCoreRuleSetMS the part of it the Core Rule Set took. // checks were done, and DurationWAF the part of it the Core Rule Set
// DurationUpstreamTotalMS is what the report of an allowed request gives, // took. DurationUpstreamTotal is what the report of an allowed request gives,
// from the hand-over to the app until the end of its answer. Each but // from the hand-over to the app until the end of its answer. Each but
// DurationTotalMS is nil for a request that did not get that far. // DurationTotal is nil for a request that did not get that far.
DurationTotalMS float64 `json:"duration_total_ms"` DurationTotal float64 `json:"duration_total"`
DurationChecksMS *float64 `json:"duration_checks_ms,omitempty"` DurationChecks *float64 `json:"duration_checks,omitempty"`
DurationCoreRuleSetMS *float64 `json:"duration_core_rule_set_ms,omitempty"` DurationWAF *float64 `json:"duration_waf,omitempty"`
DurationUpstreamTotalMS *float64 `json:"duration_upstream_total_ms,omitempty"` DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"`
} }
// RProxyLine is rproxy's short line in the request log about one request. The // RProxyLine is rproxy's short line in the request log about one request. The
@@ -226,11 +216,11 @@ type RProxyLine struct {
Status int `json:"status"` Status int `json:"status"`
RequestBytes int64 `json:"request_bytes"` RequestBytes int64 `json:"request_bytes"`
ResponseBytes int64 `json:"response_bytes"` ResponseBytes int64 `json:"response_bytes"`
// DurationRequestMS runs from the request's headers being read until the app // DurationRequest runs in milliseconds from the request's headers being read
// has been sent the whole request, or the answer has started if that is // until the app has been sent the whole request, or the answer has started
// sooner. DurationResponseMS runs from then until the answer ends. // if that is sooner. DurationResponse runs from then until the answer ends.
DurationRequestMS float64 `json:"duration_request_ms"` DurationRequest float64 `json:"duration_request"`
DurationResponseMS float64 `json:"duration_response_ms"` DurationResponse float64 `json:"duration_response"`
// Disposition is the valid disposition discriminator answered or, while // Disposition is the valid disposition discriminator answered or, while
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is "" // DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is ""
// when neither gave one. // when neither gave one.
+5 -5
View File
@@ -22,7 +22,7 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
ClientIP: "203.0.113.9", ClientIP: "203.0.113.9",
Status: 200, Status: 200,
Action: requestlog.ActionForward, Action: requestlog.ActionForward,
DurationTotalMS: requestlog.Milliseconds(1500 * time.Microsecond), DurationTotal: requestlog.Milliseconds(1500 * time.Microsecond),
}) })
if err != nil { if err != nil {
t.Fatalf("write: %v", err) t.Fatalf("write: %v", err)
@@ -43,7 +43,7 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
want := map[string]any{ want := map[string]any{
"type": "request", "time": "2026-10-03T12:00:00.000Z", "type": "request", "time": "2026-10-03T12:00:00.000Z",
"client_ip": "203.0.113.9", "status": 200.0, "action": "forward", "client_ip": "203.0.113.9", "status": 200.0, "action": "forward",
"duration_total_ms": 1.5, "duration_total": 1.5,
} }
for name, value := range want { for name, value := range want {
if fields[name] != value { if fields[name] != value {
@@ -55,9 +55,9 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
"forwarded_for", "content_type", "content_length", "request_headers", "forwarded_for", "content_type", "content_length", "request_headers",
"has_authorization", "has_cookie", "websocket", "response_content_type", "has_authorization", "has_cookie", "websocket", "response_content_type",
"upstream_status", "cache_control", "location", "aborted", "counts", "upstream_status", "cache_control", "location", "aborted", "counts",
"limit_hit", "offence", "ban_expires", "duration_checks_ms", "limit_hit", "offence", "ban_expires", "duration_checks",
"duration_upstream_connect", "duration_upstream_first_byte", "duration_upstream_connect", "duration_upstream_first_byte",
"duration_upstream_total_ms", "duration_upstream_total",
} }
for _, name := range unset { for _, name := range unset {
_, present := fields[name] _, present := fields[name]
@@ -82,7 +82,7 @@ func TestWriteRProxyLineWritesOneJSONLineWithOnlyItsFields(t *testing.T) {
const want = `{"type":"request","time":"","request_id":"an-id","client_ip":"",` + const want = `{"type":"request","time":"","request_id":"an-id","client_ip":"",` +
`"host":"","method":"","path":"","query":"","status":0,"request_bytes":0,` + `"host":"","method":"","path":"","query":"","status":0,"request_bytes":0,` +
`"response_bytes":0,"duration_request_ms":0,"duration_response_ms":0,` + `"response_bytes":0,"duration_request":0,"duration_response":0,` +
`"disposition":"allow"}` + "\n" `"disposition":"allow"}` + "\n"
if out.String() != want { if out.String() != want {
t.Errorf("wrote %s, want %s", out.String(), want) t.Errorf("wrote %s, want %s", out.String(), want)
-243
View File
@@ -1,243 +0,0 @@
// Package routewatch asks a routewatch instance about client addresses, in the
// background, and keeps its answers in memory. A routewatch instance knows, for
// an address, the netblock it is announced in and the AS that originates it.
package routewatch
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/netip"
"net/url"
"sync"
"time"
"github.com/hashicorp/golang-lru/v2/simplelru"
)
const (
// answerKept is how long an answer is used after it came.
answerKept = time.Hour
// failureKept is how long routewatch is not asked again after a query
// fails, so that a dead routewatch is not asked on every request.
failureKept = time.Minute
// queryTimeout is how long one query may take. routewatch answers only
// after a reverse DNS lookup it allows 2 seconds, so this is longer.
queryTimeout = 5 * time.Second
// maxAnswers is how many answers are kept. Past it, the one that came
// longest ago is dropped.
maxAnswers = 100000
// maxQueries is how many queries may be under way at once. Past it, a
// client is not asked about until its next request, so that a swarm of
// new addresses cannot fill the memory.
maxQueries = 100
// maxAnswerBytes is the most of an answer that is read.
maxAnswerBytes = 64 << 10
)
// Missing is why Route gives no route. The request log's field
// routewatch_tags_missing gives these values.
type Missing string
const (
// NoAnswerYet is a client routewatch has not answered about yet.
NoAnswerYet Missing = "no_answer_yet"
// RequestFailed is no answer because a recent query failed.
RequestFailed Missing = "request_failed"
// NoRoute is routewatch knowing no live route for the address.
NoRoute Missing = "no_route"
)
var (
errStatus = errors.New("routewatch answered")
errNoResult = errors.New("the answer holds no result")
errNotNetblock = errors.New("the answer's netblock is not a netblock")
)
// Route is what routewatch knows of an address: the netblock it is announced
// in, and the AS that originates it.
type Route struct {
Netblock netip.Prefix
ASN uint32
}
// Params are what New needs.
type Params struct {
// URL is where routewatch is (SWW_ROUTEWATCH_URL).
URL string
// Now tells the time, normally time.Now in UTC.
Now func() time.Time
// ProcessLog receives the query that fails, and why.
ProcessLog *slog.Logger
}
// Client asks a routewatch instance about clients, in the background, and keeps
// its answers. It is safe for concurrent use.
type Client struct {
params Params
httpClient *http.Client
mu sync.Mutex
// answers are by client. Each is added as it comes and never moved up,
// so that the one that came longest ago is the first dropped.
answers *simplelru.LRU[netip.Prefix, answer]
// asking are the clients whose query is under way.
asking map[netip.Prefix]bool
// retryAt is when routewatch may be asked again after a query failed.
retryAt time.Time
}
// answer is one kept answer: the route, or why there is none, and when it came.
type answer struct {
route Route
missing Missing
came time.Time
}
// New returns a Client with no answer yet.
func New(params Params) *Client {
answers, err := simplelru.NewLRU[netip.Prefix, answer](maxAnswers, nil)
if err != nil {
panic(err) // NewLRU fails only for a size below one
}
return &Client{
params: params,
httpClient: &http.Client{},
answers: answers,
asking: map[netip.Prefix]bool{},
}
}
// Route returns what routewatch answered about client, an IPv4 address or an
// IPv6 group, or why it has no route. It never waits for a query. A client
// without an answer is asked about in the background, by addr, the address its
// request came from, unless its query is under way, a query failed less than
// failureKept ago, or maxQueries are under way. ctx is the context of the
// client's request, and a query goes on after the request ends.
func (c *Client) Route(
ctx context.Context, client netip.Prefix, addr netip.Addr,
) (Route, Missing) {
c.mu.Lock()
defer c.mu.Unlock()
now := c.params.Now()
kept, found := c.answers.Peek(client)
if found && now.Sub(kept.came) < answerKept {
return kept.route, kept.missing
}
if now.Before(c.retryAt) {
return Route{}, RequestFailed
}
if !c.asking[client] && len(c.asking) < maxQueries {
c.asking[client] = true
go c.ask(context.WithoutCancel(ctx), client, addr)
}
return Route{}, NoAnswerYet
}
// ask asks routewatch about addr, keeps the answer as client's, and notes the
// query as no longer under way. A query that fails gives no answer, and
// routewatch is not asked again for failureKept. Only the first failure of
// those under way is logged.
func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) {
route, missing, err := c.query(ctx, addr)
now := c.params.Now()
c.mu.Lock()
delete(c.asking, client)
firstFailure := err != nil && !now.Before(c.retryAt)
if err == nil {
c.answers.Add(client, answer{route: route, missing: missing, came: now})
} else {
c.retryAt = now.Add(failureKept)
}
c.mu.Unlock()
if firstFailure {
c.params.ProcessLog.Warn("asking routewatch failed", "error", err.Error())
}
}
// query asks routewatch's IP info API about addr. An answer other than 200, one
// that cannot be read, and none within queryTimeout, fail. A result with no
// netblock is an address without a live route.
func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, Missing, error) {
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
endpoint, err := url.JoinPath(c.params.URL, "api/v1/ip", addr.String())
if err != nil {
return Route{}, "", fmt.Errorf("make the URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
if err != nil {
return Route{}, "", fmt.Errorf("make the request: %w", err)
}
req.Header.Set("Accept", "application/json")
res, err := c.httpClient.Do(req)
if err != nil {
// Do's error names the URL, which holds the client's address, which
// is not to be logged: only what went wrong is kept.
return Route{}, "", fmt.Errorf("ask routewatch: %w", errors.Unwrap(err))
}
defer func() {
_ = res.Body.Close()
}()
if res.StatusCode != http.StatusOK {
return Route{}, "", fmt.Errorf("%w %s", errStatus, res.Status)
}
return readAnswer(io.LimitReader(res.Body, maxAnswerBytes))
}
// readAnswer reads routewatch's answer about one address.
func readAnswer(body io.Reader) (Route, Missing, error) {
var decoded struct {
Data struct {
Results []struct {
Netblock string `json:"netblock"`
ASN uint32 `json:"asn"`
} `json:"results"`
} `json:"data"`
}
err := json.NewDecoder(body).Decode(&decoded)
if err != nil {
return Route{}, "", fmt.Errorf("read the answer: %w", err)
}
if len(decoded.Data.Results) == 0 {
return Route{}, "", errNoResult
}
result := decoded.Data.Results[0]
if result.Netblock == "" {
return Route{}, NoRoute, nil
}
netblock, err := netip.ParsePrefix(result.Netblock)
if err != nil {
return Route{}, "", fmt.Errorf("%w: %q", errNotNetblock, result.Netblock)
}
return Route{Netblock: netblock.Masked(), ASN: result.ASN}, "", nil
}
-333
View File
@@ -1,333 +0,0 @@
package routewatch_test
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/routewatch"
)
const (
clientAddr = "192.0.2.1"
routedAnswer = `{"status":"ok","data":{"results":[` +
`{"ip":"192.0.2.1","netblock":"192.0.2.0/24","asn":64496}]}}`
noRouteAnswer = `{"status":"ok","data":{"results":[{"ip":"192.0.2.1"}]}}`
// slowAnswer is longer than routewatch allows a reverse DNS lookup.
slowAnswer = 2500 * time.Millisecond
// waitLimit is longer than the Client allows a query.
waitLimit = 10 * time.Second
)
// clock is a time the tests move by hand.
type clock struct {
mu sync.Mutex
now time.Time
}
func (c *clock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *clock) advance(by time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(by)
}
// fixture is a Client asking a stand-in for routewatch. queries counts what the
// stand-in was asked, paths holds the last path it was asked for, and log holds
// the process log.
type fixture struct {
t *testing.T
server *httptest.Server
client *routewatch.Client
clk *clock
queries *atomic.Int32
path *atomic.Value
log *syncBuffer
}
// syncBuffer is a buffer the background queries can log to while a test reads it.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
func newFixture(t *testing.T, standIn http.HandlerFunc) *fixture {
t.Helper()
f := &fixture{
t: t,
clk: &clock{now: time.Date(2026, 11, 9, 0, 0, 0, 0, time.UTC)},
queries: &atomic.Int32{},
path: &atomic.Value{},
log: &syncBuffer{},
}
f.server = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
f.queries.Add(1)
f.path.Store(r.URL.Path)
standIn(w, r)
}))
t.Cleanup(f.server.Close)
f.client = routewatch.New(routewatch.Params{
URL: f.server.URL + "/", Now: f.clk.Now,
ProcessLog: slog.New(slog.NewTextHandler(f.log, nil)),
})
return f
}
func answering(body string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(body))
}
}
func (f *fixture) route() (routewatch.Route, routewatch.Missing) {
addr := netip.MustParseAddr(clientAddr)
client := netip.PrefixFrom(addr, addr.BitLen())
return f.client.Route(context.Background(), client, addr)
}
// waitFor asks until the answer is why, or, while why is "", until there is a
// route, and returns the route.
func (f *fixture) waitFor(why routewatch.Missing) routewatch.Route {
f.t.Helper()
deadline := time.Now().Add(waitLimit)
for {
route, missing := f.route()
if missing == why {
return route
}
if time.Now().After(deadline) {
f.t.Fatalf("still %q after %s, want %q", missing, waitLimit, why)
}
time.Sleep(time.Millisecond)
}
}
// wantFailureLoggedWithoutTheClient waits for the process log to name a failed
// query, and checks that it does not name the client.
func (f *fixture) wantFailureLoggedWithoutTheClient() {
f.t.Helper()
// The failure is noted before it is logged.
deadline := time.Now().Add(waitLimit)
for !strings.Contains(f.log.String(), "asking routewatch failed") {
if time.Now().After(deadline) {
f.t.Fatalf("process log %q names no failure", f.log.String())
}
time.Sleep(time.Millisecond)
}
if strings.Contains(f.log.String(), clientAddr) {
f.t.Errorf("process log %q names the client", f.log.String())
}
}
func TestRouteGivesRoutewatchsAnswerAndAsksByAddress(t *testing.T) {
t.Parallel()
f := newFixture(t, answering(routedAnswer))
if _, missing := f.route(); missing != routewatch.NoAnswerYet {
t.Errorf("before the answer: %q, want %q", missing, routewatch.NoAnswerYet)
}
route := f.waitFor("")
want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496}
if route != want {
t.Errorf("route %+v, want %+v", route, want)
}
if got := f.path.Load(); got != "/api/v1/ip/"+clientAddr {
t.Errorf("asked for path %v", got)
}
}
func TestAnswerAfterASlowReverseDNSLookupIsKept(t *testing.T) {
t.Parallel()
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(slowAnswer)
_, _ = w.Write([]byte(routedAnswer))
})
f.route()
route := f.waitFor("")
want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496}
if route != want {
t.Errorf("route %+v, want %+v", route, want)
}
}
func TestAddressWithoutARouteIsKeptAsNoRoute(t *testing.T) {
t.Parallel()
f := newFixture(t, answering(noRouteAnswer))
f.route()
f.waitFor(routewatch.NoRoute)
if got := f.queries.Load(); got != 1 {
t.Errorf("%d queries, want 1", got)
}
}
func TestAnswerRoutewatchCannotBeTakenFromFails(t *testing.T) {
t.Parallel()
tests := map[string]http.HandlerFunc{
"500": func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
},
"not JSON": answering("no"),
"no results": answering(`{"status":"ok","data":{"results":[]}}`),
"bad netblock": answering(
`{"status":"ok","data":{"results":[{"netblock":"nowhere","asn":1}]}}`),
}
for name, standIn := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
f := newFixture(t, standIn)
f.route()
f.waitFor(routewatch.RequestFailed)
f.wantFailureLoggedWithoutTheClient()
})
}
}
func TestRefusedConnectionFailsTheQueryAndTheLogOmitsTheClient(t *testing.T) {
t.Parallel()
f := newFixture(t, answering(routedAnswer))
f.server.Close()
f.route()
f.waitFor(routewatch.RequestFailed)
f.wantFailureLoggedWithoutTheClient()
}
func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest(t *testing.T) {
t.Parallel()
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
f.route()
f.waitFor(routewatch.RequestFailed)
f.route()
if got := f.queries.Load(); got != 1 {
t.Errorf("%d queries while the failure is kept, want 1", got)
}
f.clk.advance(time.Minute)
f.route()
deadline := time.Now().Add(waitLimit)
for f.queries.Load() != 2 {
if time.Now().After(deadline) {
t.Fatalf("%d queries after the failure expired, want 2", f.queries.Load())
}
time.Sleep(time.Millisecond)
}
}
func TestHangingRoutewatchNeverDelaysAndFailsTheQuery(t *testing.T) {
t.Parallel()
f := newFixture(t, func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
})
start := time.Now()
f.route()
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("Route took %s while routewatch hangs", elapsed)
}
f.waitFor(routewatch.RequestFailed)
f.wantFailureLoggedWithoutTheClient()
}
func TestRepeatedRequestsMakeOneQueryAndTheAnswerExpires(t *testing.T) {
t.Parallel()
release := make(chan struct{})
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
<-release
_, _ = w.Write([]byte(routedAnswer))
})
for range 50 {
f.route()
}
close(release)
f.waitFor("")
for range 50 {
f.route()
}
if got := f.queries.Load(); got != 1 {
t.Errorf("%d queries for repeated requests, want 1", got)
}
f.clk.advance(time.Hour)
f.waitFor(routewatch.NoAnswerYet)
f.waitFor("")
if got := f.queries.Load(); got != 2 {
t.Errorf("%d queries after the answer expired, want 2", got)
}
}
+4 -4
View File
@@ -371,8 +371,8 @@ func (rq *request) finish(ctx context.Context) {
Status: rq.out.status, Status: rq.out.status,
RequestBytes: requestBytes, RequestBytes: requestBytes,
ResponseBytes: responseBytes, ResponseBytes: responseBytes,
DurationRequestMS: requestlog.Milliseconds(requestEnd.Sub(rq.start)), DurationRequest: requestlog.Milliseconds(requestEnd.Sub(rq.start)),
DurationResponseMS: requestlog.Milliseconds(end.Sub(requestEnd)), DurationResponse: requestlog.Milliseconds(end.Sub(requestEnd)),
Disposition: rq.disposition, Disposition: rq.disposition,
DefaultDisposition: rq.byDefault, DefaultDisposition: rq.byDefault,
}) })
@@ -389,10 +389,10 @@ func (rq *request) finish(ctx context.Context) {
Status: min(rq.out.status, reportStatusMax), Status: min(rq.out.status, reportStatusMax),
RequestBytes: requestBytes, RequestBytes: requestBytes,
ResponseBytes: responseBytes, ResponseBytes: responseBytes,
DurationTotalMS: requestlog.Milliseconds(end.Sub(rq.start)), DurationTotal: requestlog.Milliseconds(end.Sub(rq.start)),
} }
if !rq.upstreamStart.IsZero() { if !rq.upstreamStart.IsZero() {
report.DurationUpstreamTotalMS = requestlog.Milliseconds(end.Sub(rq.upstreamStart)) report.DurationUpstreamTotal = requestlog.Milliseconds(end.Sub(rq.upstreamStart))
} }
rq.h.sendReport(ctx, report) rq.h.sendReport(ctx, report)
+5 -5
View File
@@ -45,9 +45,9 @@ const (
idleTimeout = 120 * time.Second idleTimeout = 120 * time.Second
) )
// ownEndpointPrefix starts the path of every request for sww itself, which // adminPrefix starts the path of every request for sww itself, which
// never reaches the app. // never reaches the app.
const ownEndpointPrefix = "/_sww/" const adminPrefix = "/_sww/"
// allowedMethods lists the methods rproxy passes on, as an Allow header gives // allowedMethods lists the methods rproxy passes on, as an Allow header gives
// them. It answers any other method 405 itself. // them. It answers any other method 405 itself.
@@ -211,8 +211,8 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
// passToApp passes the request on to the app, unless a limit or its path keeps // passToApp passes the request on to the app, unless a limit or its path keeps
// it from the app. discriminator answers a path under ownEndpointPrefix itself, so // it from the app. discriminator answers a path under adminPrefix itself, so such a
// such a request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the // request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
// request's own context. // request's own context.
func (rq *request) passToApp(ctx context.Context) { func (rq *request) passToApp(ctx context.Context) {
maxBytes := rq.h.config.MaxRequestSize maxBytes := rq.h.config.MaxRequestSize
@@ -220,7 +220,7 @@ func (rq *request) passToApp(ctx context.Context) {
switch { switch {
case rq.refused.Load() != 0 || (maxBytes > 0 && rq.in.ContentLength > maxBytes): case rq.refused.Load() != 0 || (maxBytes > 0 && rq.in.ContentLength > maxBytes):
rq.answer(http.StatusRequestEntityTooLarge) rq.answer(http.StatusRequestEntityTooLarge)
case strings.HasPrefix(rq.in.URL.Path, ownEndpointPrefix): case strings.HasPrefix(rq.in.URL.Path, adminPrefix):
rq.answer(http.StatusNotFound) rq.answer(http.StatusNotFound)
default: default:
rq.forward(ctx) rq.forward(ctx)
+6 -6
View File
@@ -727,7 +727,7 @@ func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
report := standIn.report(t) report := standIn.report(t)
if report.Status != http.StatusForbidden || if report.Status != http.StatusForbidden ||
report.ResponseBytes != int64(len(deniedBody)) || report.ResponseBytes != int64(len(deniedBody)) ||
report.DurationUpstreamTotalMS != 0 { report.DurationUpstreamTotal != 0 {
t.Errorf("report %+v, of a request denied", report) t.Errorf("report %+v, of a request denied", report)
} }
@@ -871,9 +871,9 @@ func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
line := out.requestLine(t) line := out.requestLine(t)
duration, ok := line["duration_response_ms"].(float64) duration, ok := line["duration_response"].(float64)
if !ok || duration < 0 { if !ok || duration < 0 {
t.Errorf("line %v, want a duration_response_ms that is not negative", line) t.Errorf("line %v, want a duration_response that is not negative", line)
} }
} }
} }
@@ -1428,8 +1428,8 @@ func TestReportGivesWhatHappened(t *testing.T) {
if report.RequestID == "" || report.Status != http.StatusOK || if report.RequestID == "" || report.Status != http.StatusOK ||
report.RequestBytes != int64(len(body)) || report.RequestBytes != int64(len(body)) ||
report.ResponseBytes != int64(len(greeting)) || report.ResponseBytes != int64(len(greeting)) ||
report.DurationTotalMS <= 0 || report.DurationUpstreamTotalMS <= 0 || report.DurationTotal <= 0 || report.DurationUpstreamTotal <= 0 ||
report.DurationUpstreamTotalMS > report.DurationTotalMS { report.DurationUpstreamTotal > report.DurationTotal {
t.Errorf("report %+v, of %d bytes up and %d back", report, len(body), len(greeting)) t.Errorf("report %+v, of %d bytes up and %d back", report, len(body), len(greeting))
} }
@@ -1748,7 +1748,7 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
t.Errorf("default_disposition is %v, want it left out", line["default_disposition"]) t.Errorf("default_disposition is %v, want it left out", line["default_disposition"])
} }
for _, name := range []string{"duration_request_ms", "duration_response_ms"} { for _, name := range []string{"duration_request", "duration_response"} {
duration, ok := line[name].(float64) duration, ok := line[name].(float64)
if !ok || duration <= 0 { if !ok || duration <= 0 {
t.Errorf("%s is %v, want a time", name, line[name]) t.Errorf("%s is %v, want a time", name, line[name])
+7 -7
View File
@@ -64,7 +64,7 @@ var (
errCause = errors.New("is not limit, attack, admin or crowdsec") errCause = errors.New("is not limit, attack, admin or crowdsec")
errExpires = errors.New("is not after its start") errExpires = errors.New("is not after its start")
errDestination = errors.New("is not webhook, slack or ntfy") errDestination = errors.New("is not webhook, slack or ntfy")
errScope = errors.New("is not client, net, asn, total or watched_net") errScope = errors.New("is not client, net, asn, total or watch")
errWaitingList = errors.New(`waiting is a list, but now lists the alerts by ` + errWaitingList = errors.New(`waiting is a list, but now lists the alerts by ` +
`destination: put the list under "webhook", as "waiting": {"webhook": [...]}, ` + `destination: put the list under "webhook", as "waiting": {"webhook": [...]}, ` +
`or remove the file`) `or remove the file`)
@@ -858,8 +858,8 @@ func (f *alertsFile) check([]byte) error {
} }
// checkAnomalyCounters refuses an anomaly counter whose scope is not // checkAnomalyCounters refuses an anomaly counter whose scope is not
// client, net, asn, total or watched_net, most likely misspelt, and one // client, net, asn, total or watch, most likely misspelt, and one without
// without a field it needs, as missingFromCounter tells. // a field it needs, as missingFromCounter tells.
func checkAnomalyCounters(counters []anomaly.Counter) error { func checkAnomalyCounters(counters []anomaly.Counter) error {
for i, counter := range counters { for i, counter := range counters {
if !slices.Contains(anomaly.Scopes(), counter.Scope) { if !slices.Contains(anomaly.Scopes(), counter.Scope) {
@@ -879,9 +879,9 @@ func checkAnomalyCounters(counters []anomaly.Counter) error {
// missingFromCounter returns the first field counter, an anomaly counter, // missingFromCounter returns the first field counter, an anomaly counter,
// needs and has not, or "" when it has them all: what tells it from the // needs and has not, or "" when it has them all: what tells it from the
// others in its scope, without which it would never be counted again, the // others in its scope, without which it would never be counted again, the
// netblock of a client, net or watched_net counter, the AS number of an // netblock of a client, net or watch counter, the AS number of an asn one
// asn one and the name of a watched_net one; and the start of a window in // and the name of a watch one; and the start of a window in which it has
// which it has requests or bytes, without which they would be dropped. // requests or bytes, without which they would be dropped.
func missingFromCounter(counter anomaly.Counter) string { func missingFromCounter(counter anomaly.Counter) string {
scope := counter.Scope scope := counter.Scope
@@ -891,7 +891,7 @@ func missingFromCounter(counter anomaly.Counter) string {
return "netblock" return "netblock"
case scope == anomaly.ScopeASN && counter.ASN == "": case scope == anomaly.ScopeASN && counter.ASN == "":
return "asn" return "asn"
case scope == anomaly.ScopeWatchedNet && counter.Name == "": case scope == anomaly.ScopeWatch && counter.Name == "":
return "name" return "name"
case countsWithoutStart(counter.Minute): case countsWithoutStart(counter.Minute):
return "minute.start" return "minute.start"
+5 -5
View File
@@ -208,7 +208,7 @@ const filledAlertsJSON = `{
} }
}, },
{ {
"scope": "watched_net", "scope": "watch",
"netblock": "203.0.113.0/24", "netblock": "203.0.113.0/24",
"name": "office", "name": "office",
"hour": { "hour": {
@@ -534,7 +534,7 @@ func TestFileThatDoesNotParseStopsTheStart(t *testing.T) {
`{"version": 1, "anomaly_counters": [{"scope": "total"}, ` + `{"version": 1, "anomaly_counters": [{"scope": "total"}, ` +
`{"scope": "nett", "netblock": "203.0.113.0/24"}]}`, `{"scope": "nett", "netblock": "203.0.113.0/24"}]}`,
`: anomaly_counters entry 2's scope "nett" is not client, net, asn, total ` + `: anomaly_counters entry 2's scope "nett" is not client, net, asn, total ` +
`or watched_net`, `or watch`,
}, },
{ {
"a copy of a list with a line that does not read", reputationJSON, "a copy of a list with a line that does not read", reputationJSON,
@@ -831,12 +831,12 @@ func TestAlertsJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
{ {
"an anomaly counter of a named netblock without its name", "an anomaly counter of a named netblock without its name",
`{"version": 1, "anomaly_counters": [` + `{"version": 1, "anomaly_counters": [` +
`{"scope": "watched_net", "netblock": "203.0.113.0/24"}]}`, `{"scope": "watch", "netblock": "203.0.113.0/24"}]}`,
`: anomaly_counters entry 1 has no "name"`, `: anomaly_counters entry 1 has no "name"`,
}, },
{ {
"an anomaly counter of a named netblock without its netblock", "an anomaly counter of a named netblock without its netblock",
`{"version": 1, "anomaly_counters": [{"scope": "watched_net", "name": "office"}]}`, `{"version": 1, "anomaly_counters": [{"scope": "watch", "name": "office"}]}`,
`: anomaly_counters entry 1 has no "netblock"`, `: anomaly_counters entry 1 has no "netblock"`,
}, },
{ {
@@ -1729,7 +1729,7 @@ func newParams(dir string) state.Params {
Dir: dir, Dir: dir,
WriteDelay: time.Hour, WriteDelay: time.Hour,
CounterInterval: time.Hour, CounterInterval: time.Hour,
Ledger: bans.New(bans.Params{ Ledger: bans.New(bans.Rules{
LimitBanDuration: time.Hour, LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour, LimitBanRepeatWindow: 24 * time.Hour,
MaxBanDuration: 7 * 24 * time.Hour, MaxBanDuration: 7 * 24 * time.Hour,
+2 -2
View File
@@ -22,7 +22,6 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
"sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/remotelog" "sneak.berlin/go/smallwebwaf/internal/remotelog"
@@ -30,6 +29,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/state" "sneak.berlin/go/smallwebwaf/internal/state"
"sneak.berlin/go/smallwebwaf/internal/waf"
) )
// shutdownTimeout is how long requests in progress may take to finish // shutdownTimeout is how long requests in progress may take to finish
@@ -197,7 +197,7 @@ func RunDiscriminator(ctx context.Context, params Params) int {
func newServer( func newServer(
cfg *config.Config, stdout io.Writer, processLog *slog.Logger, cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue, now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
crs *coreruleset.CoreRuleSet, crs *waf.CoreRuleSet,
) (*discriminator.Server, error) { ) (*discriminator.Server, error) {
var lookupFile *lookup.File var lookupFile *lookup.File
+8 -13
View File
@@ -373,13 +373,13 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
out := runUntilStopped(t, env, func(url string) { out := runUntilStopped(t, env, func(url string) {
wantRefused(t, url) wantRefused(t, url)
}) })
out.line(t, "action", "client_is_rate_limited") out.line(t, "action", "rate_limited")
// After another, the ban still refuses it. // After another, the ban still refuses it.
out = runUntilStopped(t, env, func(url string) { out = runUntilStopped(t, env, func(url string) {
wantRefused(t, url) wantRefused(t, url)
}) })
out.line(t, "action", "temporarily_banned") out.line(t, "action", "banned")
} }
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) { func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
@@ -526,7 +526,7 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
time.Sleep(pollInterval) time.Sleep(pollInterval)
} }
}) })
out.line(t, "action", "blocked_by_block_rule") out.line(t, "action", "rule_blocked")
} }
func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) { func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) {
@@ -740,7 +740,7 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
time.Sleep(pollInterval) time.Sleep(pollInterval)
} }
}) })
wantDeniedByList(t, out.line(t, "action", "client_denied"), dropURL) wantDeniedByList(t, out.line(t, "action", "denied"), dropURL)
if fetches := torFetches.Load(); fetches != 1 { if fetches := torFetches.Load(); fetches != 1 {
t.Errorf("tor.txt fetched %d times, want once, before the restart", fetches) t.Errorf("tor.txt fetched %d times, want once, before the restart", fetches)
@@ -843,8 +843,7 @@ func wantDeniedByList(t *testing.T, line map[string]any, listURL string) {
t.Helper() t.Helper()
reputation, _ := line["reputation"].([]any) reputation, _ := line["reputation"].([]any)
if line["action"] != "client_denied" || len(reputation) != 1 || if line["action"] != "denied" || len(reputation) != 1 || reputation[0] != listURL {
reputation[0] != listURL {
t.Errorf("request log line %v, want one denied for %s", line, listURL) t.Errorf("request log line %v, want one denied for %s", line, listURL)
} }
} }
@@ -962,7 +961,7 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
metrics := metricsText(t, url+"_sww/metrics", token) metrics := metricsText(t, url+"_sww/metrics", token)
for _, series := range []string{ for _, series := range []string{
`sww_remote_log_lines_sent_total{instance="fsn1app1/blog"} 0`, `sww_remote_log_lines_sent_total{instance="fsn1app1/blog"} 0`,
`sww_remote_log_lines_waiting{instance="fsn1app1/blog"} 1`, `sww_remote_log_buffer_depth{instance="fsn1app1/blog"} 1`,
} { } {
if !strings.Contains(metrics, "\n"+series+"\n") { if !strings.Contains(metrics, "\n"+series+"\n") {
t.Errorf("no %q in the metrics:\n%s", series, metrics) t.Errorf("no %q in the metrics:\n%s", series, metrics)
@@ -1068,12 +1067,8 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
metrics := metricsWith(t, url+"_sww/metrics", token, metrics := metricsWith(t, url+"_sww/metrics", token,
"\nsww_alerts_sent_total"+ofWebhook+" 1\n") "\nsww_alerts_sent_total"+ofWebhook+" 1\n")
for _, series := range []string{ for _, series := range []string{"failed", "suppressed", "dropped"} {
"sww_alert_send_failures_total", zero := "\nsww_alerts_" + series + "_total" + ofWebhook + " 0\n"
"sww_alerts_suppressed_total",
"sww_alerts_dropped_total",
} {
zero := "\n" + series + ofWebhook + " 0\n"
if !strings.Contains(metrics, zero) { if !strings.Contains(metrics, zero) {
t.Errorf("no %q in the metrics:\n%s", zero, metrics) t.Errorf("no %q in the metrics:\n%s", zero, metrics)
} }
@@ -1,8 +1,51 @@
# The eighth change: on gitea's paths, some fields skip the rules that refused # gitea's fixes: some fields skip the rules that refused ordinary gitea requests.
# ordinary gitea requests there. They are the fields where gitea sends a search, # They are the fields where gitea sends a search, a file name or a branch name.
# a file name or a branch name. Each exclusion runs with the request, before the # Each exclusion runs with the request, before the rules it switches off, those
# rules it switches off, those of phase 1 included, so this file is loaded before # of phase 1 included, so this file is loaded before the Core Rule Set's rules.
# the Core Rule Set's rules. Each comment names a request the rule refused. # Each comment names a request the rule refused.
# A request whose hostname SWW_APPS does not list gitea for skips every rule
# below. Inspect sets tx.app_gitea on the others.
SecRule &TX:app_gitea "@eq 0" \
"id:900590,phase:1,pass,nolog,skipAfter:END_GITEA"
# The query parameters in which gitea sends names within a repository or its
# own records, or a page of its own site, are not checked against the lists of
# system files, shell paths and command names, on any path:
# 930120: .gitignore
# 932160: bin/docker-entrypoint
# 932260: docker-build
SecAction "id:900591,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:path,\
ctl:ruleRemoveTargetById=930120;ARGS:files,\
ctl:ruleRemoveTargetById=930120;ARGS:skip-to,\
ctl:ruleRemoveTargetById=930120;ARGS:sub_path,\
ctl:ruleRemoveTargetById=930120;ARGS:ref,\
ctl:ruleRemoveTargetById=930120;ARGS:sha,\
ctl:ruleRemoveTargetById=930120;ARGS:branch,\
ctl:ruleRemoveTargetById=930120;ARGS:workflow,\
ctl:ruleRemoveTargetById=930120;ARGS:artifactName,\
ctl:ruleRemoveTargetById=930120;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=932160;ARGS:path,\
ctl:ruleRemoveTargetById=932160;ARGS:files,\
ctl:ruleRemoveTargetById=932160;ARGS:skip-to,\
ctl:ruleRemoveTargetById=932160;ARGS:sub_path,\
ctl:ruleRemoveTargetById=932160;ARGS:ref,\
ctl:ruleRemoveTargetById=932160;ARGS:sha,\
ctl:ruleRemoveTargetById=932160;ARGS:branch,\
ctl:ruleRemoveTargetById=932160;ARGS:workflow,\
ctl:ruleRemoveTargetById=932160;ARGS:artifactName,\
ctl:ruleRemoveTargetById=932160;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=932260;ARGS:path,\
ctl:ruleRemoveTargetById=932260;ARGS:files,\
ctl:ruleRemoveTargetById=932260;ARGS:skip-to,\
ctl:ruleRemoveTargetById=932260;ARGS:sub_path,\
ctl:ruleRemoveTargetById=932260;ARGS:ref,\
ctl:ruleRemoveTargetById=932260;ARGS:sha,\
ctl:ruleRemoveTargetById=932260;ARGS:branch,\
ctl:ruleRemoveTargetById=932260;ARGS:workflow,\
ctl:ruleRemoveTargetById=932260;ARGS:artifactName,\
ctl:ruleRemoveTargetById=932260;ARGS:redirect_to"
# gitea's searches of code, of issues and pull requests, and of commits send the # gitea's searches of code, of issues and pull requests, and of commits send the
# text searched for in q, which skips these rules there: # text searched for in q, which skips these rules there:
@@ -155,8 +198,8 @@ SecRule REQUEST_FILENAME \
# 942140: information_schema.sql # 942140: information_schema.sql
# 944110: java/lang/Runtime.java # 944110: java/lang/Runtime.java
# 944130: java.lang.String.md # 944130: java.lang.String.md
# Each request skips the rules that refused it. The fourth change already has # Each request skips the rules that refused it. Rule 900591 already has path and
# path and redirect_to skip 930120, 932160 and 932260 on every path. # redirect_to skip 930120, 932160 and 932260 on every path.
# A file's commit graph, in file. # A file's commit graph, in file.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \ SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
@@ -281,3 +324,5 @@ SecRule REQUEST_FILENAME \
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \ "@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
"id:900640,phase:1,pass,nolog,\ "id:900640,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range" ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
SecMarker END_GITEA
@@ -1,6 +1,6 @@
# Ordinary gitea 1.27 requests that the Core Rule Set must let through at the # Ordinary gitea 1.27 requests that the Core Rule Set must let through at the
# defaults. Each line is a path and query, and may end with one header after a # defaults. Each line is a path and query, and may end with one header after a
# space. The tests of internal/coreruleset inspect each as a GET. The Core Rule Set reads # space. The tests of internal/waf inspect each as a GET. The Core Rule Set reads
# the query and the headers the same way for any method. # the query and the headers the same way for any method.
# Searches with ordinary words and symbols. # Searches with ordinary words and symbols.
@@ -1,21 +1,23 @@
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the // Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
// method, the URL with its query and the headers of a request, and on its // method, the URL with its query and the headers of a request, and on its
// body while SWW_WAF_BODY_LIMIT is set, with the eight changes sww // body while SWW_WAF_BODY_LIMIT is set, with the six changes sww
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as // makes to it, the fixes for the apps SWW_APPS names, and the exclusions in
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no // SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings"
// response. // in SPEC.md describes them. It reads no response.
// //
// sww writes only to its state directory, so Coraza is built with // sww writes only to its state directory, so Coraza is built with
// its no_fs_access tag, as the Dockerfile and script/build build it: of a // its no_fs_access tag, as the Dockerfile and script/build build it: of a
// file in a multipart body, Coraza then counts the bytes instead of // file in a multipart body, Coraza then counts the bytes instead of
// writing them to the system's temporary directory. // writing them to the system's temporary directory.
package coreruleset package waf
import ( import (
_ "embed" _ "embed"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"net"
"net/http" "net/http"
"net/netip" "net/netip"
"os" "os"
@@ -32,8 +34,8 @@ import (
// directives are the Core Rule Set as sww runs it, with the // directives are the Core Rule Set as sww runs it, with the
// paranoia level for %d, bodyDirectives for the first %s while // paranoia level for %d, bodyDirectives for the first %s while
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and // SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and the
// giteaExclusions for the third. // directives of every app in apps for the third.
// Each rule sww adds has an id from 900000 to 900999, the ids the // Each rule sww adds has an id from 900000 to 900999, the ids the
// Core Rule Set keeps for the rules that set it up, which // Core Rule Set keeps for the rules that set it up, which
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no // SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
@@ -81,44 +83,28 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
# before the rules of phase 1 too. # before the rules of phase 1 too.
%s %s
# The eighth change comes before the rules for the same reason. # The apps' fixes come before the rules for the same reason.
%s %s
# The sixth: only the rules for requests are loaded, and no response is # The fifth: only the rules for requests are loaded, and no response is
# inspected. # inspected.
Include @owasp_crs/REQUEST-*.conf Include @owasp_crs/REQUEST-*.conf
# The third: redirect_uri is not checked for a URL naming an IP address or # The third: redirect_uri is not checked for a URL naming an IP address or
# localhost. Coraza matches a parameter name here, and in the fourth, # localhost. Coraza matches a parameter name here without regard to case.
# without regard to case. ARGS holds the fields of a form data or multipart # ARGS holds the fields of a form data or multipart body Coraza reads as
# body Coraza reads as well as the query parameters, so a field of one of # well as the query parameters, so a field of this name is left out too.
# these names is left out too.
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri" SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri" SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
# The fourth: the query parameters in which gitea sends names within a # The fourth: Referer is not checked for a Unix command without arguments,
# repository or its own records, or a page of its own site, are not # for Java starting a process, or for a Java class name. 944130 refused
# checked against the lists of system files, shell paths and command # every page opened from the page of java.lang.String.md.
# names. Coraza takes one rule id per directive.
SecRuleUpdateTargetById 930120 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
SecRuleUpdateTargetById 932160 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
# The fifth, for Referer: it is not checked for a Unix command without
# arguments, for Java starting a process, or for a Java class name. 944130
# refused every page opened from the page of java.lang.String.md. The
# cookies are left out in Inspect.
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
# The seventh: a container image push that reuses a layer names the image # The sixth: a container image push that reuses a layer names the image
# it takes the layer from in the query parameter from, which is not # it takes the layer from in the query parameter from, which is not
# checked for command names. 932260 refused the push of a layer from # checked for command names. 932260 refused the push of a layer from
# owner/python3-app. # owner/python3-app.
@@ -168,18 +154,36 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'" severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
` `
// giteaExclusions are the eighth change: the fields of gitea's own // app is an app whose ordinary requests the Core Rule Set would refuse,
// requests that skip the rules that refused them there, as Coraza // with the fixes that let them pass.
// directives. type app struct {
// // directives are Coraza exclusions, loaded before the Core Rule Set's
//go:embed gitea.conf // rules. Their first rule skips the rest unless Inspect sets the
var giteaExclusions string // transaction variable app_ and the app's name, and they end with the
// marker it skips to.
directives string
// cookies are the cookies the Core Rule Set reads a request without.
cookies []string
}
// cookiesNotRead are the cookies the Core Rule Set reads a request //go:embed gitea.conf
// without, the rest of the fifth change. var giteaDirectives string
// apps are the apps whose fixes sww has, by the name SWW_APPS gives each.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a map
var cookiesNotRead = []string{"gitea_flash", "redirect_to"} var apps = map[string]app{
"gitea": {
directives: giteaDirectives,
cookies: []string{"gitea_flash", "redirect_to"},
},
}
// AppNames returns the names of the apps whose fixes sww has, in
// alphabetical order.
func AppNames() []string {
return slices.Sorted(maps.Keys(apps))
}
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
@@ -195,6 +199,10 @@ type Params struct {
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while // Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
// there is none. // there is none.
ExclusionsFile string ExclusionsFile string
// Apps are the names of the apps each hostname serves, by hostname in
// lowercase (SWW_APPS). A request gets the fixes of its hostname's
// apps only, or of every app while Apps is nil.
Apps map[string][]string
} }
// The ids of sww's own rules, which an exclusions file may not // The ids of sww's own rules, which an exclusions file may not
@@ -284,15 +292,17 @@ func checkExclusions(exclusions string) error {
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe // CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
// for concurrent use. // for concurrent use.
type CoreRuleSet struct { type CoreRuleSet struct {
engine coraza.WAF waf coraza.WAF
bodyLimit int64 bodyLimit int64
apps map[string][]string
} }
// New returns the Core Rule Set with the eight changes, at params' // New returns the Core Rule Set with the six changes and the apps' fixes,
// paranoia level, without the rules it switches off, reading request // at params' paranoia level, without the rules it switches off, reading
// bodies up to params' limit, and with params' exclusions file loaded // request bodies up to params' limit, and with params' exclusions file
// before the Core Rule Set's rules. An exclusions file is an error if it // loaded before the Core Rule Set's rules. An exclusions file is an error
// cannot be read or does not parse, or if checkExclusions refuses it. // if it cannot be read or does not parse, or if checkExclusions refuses
// it.
func New(params Params) (*CoreRuleSet, error) { func New(params Params) (*CoreRuleSet, error) {
body := "" body := ""
if params.BodyLimit > 0 { if params.BodyLimit > 0 {
@@ -315,8 +325,13 @@ func New(params Params) (*CoreRuleSet, error) {
} }
} }
var fixes strings.Builder
for _, name := range AppNames() {
fixes.WriteString(apps[name].directives + "\n")
}
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions, text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
giteaExclusions) fixes.String())
if len(params.DisabledRules) > 0 { if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules)) ids := make([]string, len(params.DisabledRules))
@@ -327,14 +342,14 @@ func New(params Params) (*CoreRuleSet, error) {
text += "SecRuleRemoveById " + strings.Join(ids, " ") + "\n" text += "SecRuleRemoveById " + strings.Join(ids, " ") + "\n"
} }
engine, err := coraza.NewWAF(coraza.NewWAFConfig(). waf, err := coraza.NewWAF(coraza.NewWAFConfig().
WithRootFS(coreruleset.FS). WithRootFS(coreruleset.FS).
WithDirectives(text)) WithDirectives(text))
if err != nil { if err != nil {
return nil, fmt.Errorf("load the Core Rule Set: %w", err) return nil, fmt.Errorf("load the Core Rule Set: %w", err)
} }
return &CoreRuleSet{engine: engine, bodyLimit: params.BodyLimit}, nil return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit, apps: params.Apps}, nil
} }
// Result is what the Core Rule Set found in a request. // Result is what the Core Rule Set found in a request.
@@ -346,25 +361,37 @@ type Result struct {
Score int Score int
} }
// Inspect runs the Core Rule Set on r, a request from client: on its // Inspect runs the Core Rule Set on r, a request from client, with the
// method, its URL with the query, and its headers, the Cookie header // fixes of the apps appsFor names for r's hostname: on its method, its URL
// without the cookies in cookiesNotRead, and on body, r's body as the // with the query, and its headers, the Cookie header without those apps'
// caller has it, as readBody reads it. It returns what it found, what it // cookies, and on body, r's body as the caller has it, as readBody reads
// read of body, and the error that ended the reading early, if one did. // it. It returns what it found, what it read of body, and the error that
// ended the reading early, if one did.
func (c *CoreRuleSet) Inspect( func (c *CoreRuleSet) Inspect(
r *http.Request, client netip.Addr, body io.Reader, r *http.Request, client netip.Addr, body io.Reader,
) (Result, []byte, error) { ) (Result, []byte, error) {
tx := c.engine.NewTransaction() tx := c.waf.NewTransaction()
// Closing would remove the files Coraza wrote, and it writes none. // Closing would remove the files Coraza wrote, and it writes none.
defer func() { _ = tx.Close() }() defer func() { _ = tx.Close() }()
// The transaction's variables are set only through Coraza's interface
// for plugins.
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
var cookiesNotRead []string
for _, name := range c.appsFor(r.Host) {
state.Variables().TX().Set("app_"+name, []string{"1"})
cookiesNotRead = append(cookiesNotRead, apps[name].cookies...)
}
tx.ProcessConnection(client.String(), 0, "", 0) tx.ProcessConnection(client.String(), 0, "", 0)
tx.ProcessURI(r.URL.String(), r.Method, r.Proto) tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
for name, values := range r.Header { for name, values := range r.Header {
for _, value := range values { for _, value := range values {
if name == "Cookie" { if name == "Cookie" {
value = withoutCookiesNotRead(value) value = withoutCookies(value, cookiesNotRead)
if value == "" { if value == "" {
continue // it held those cookies alone continue // it held those cookies alone
} }
@@ -457,9 +484,26 @@ func score(tx types.Transaction) int {
return n return n
} }
// withoutCookiesNotRead returns value, a Cookie header's, without the // appsFor returns the names of the apps whose fixes a request for host,
// cookies in cookiesNotRead. // its Host header, gets: every app while SWW_APPS is unset, and otherwise
func withoutCookiesNotRead(value string) string { // those SWW_APPS lists for host's name, without the port and in
// lowercase.
func (c *CoreRuleSet) appsFor(host string) []string {
if c.apps == nil {
return AppNames()
}
hostname, _, err := net.SplitHostPort(host)
if err != nil {
hostname = host // it has no port
}
return c.apps[strings.ToLower(hostname)]
}
// withoutCookies returns value, a Cookie header's, without the cookies
// named in cookiesNotRead.
func withoutCookies(value string, cookiesNotRead []string) string {
var kept []string var kept []string
for cookie := range strings.SplitSeq(value, ";") { for cookie := range strings.SplitSeq(value, ";") {
@@ -1,4 +1,4 @@
package coreruleset_test package waf_test
import ( import (
"net/http" "net/http"
@@ -12,7 +12,7 @@ import (
"strings" "strings"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset" "sneak.berlin/go/smallwebwaf/internal/waf"
) )
// defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off // defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off
@@ -23,12 +23,10 @@ var defaultDisabledRules = []int{920340, 920420, 920440, 920640, 930130, 930140}
// newCoreRuleSet returns the Core Rule Set at paranoia level level, with // newCoreRuleSet returns the Core Rule Set at paranoia level level, with
// the rules in disabled switched off. // the rules in disabled switched off.
func newCoreRuleSet(t *testing.T, level int, disabled ...int) *coreruleset.CoreRuleSet { func newCoreRuleSet(t *testing.T, level int, disabled ...int) *waf.CoreRuleSet {
t.Helper() t.Helper()
crs, err := coreruleset.New(coreruleset.Params{ crs, err := waf.New(waf.Params{ParanoiaLevel: level, DisabledRules: disabled})
ParanoiaLevel: level, DisabledRules: disabled,
})
if err != nil { if err != nil {
t.Fatalf("load the Core Rule Set: %v", err) t.Fatalf("load the Core Rule Set: %v", err)
} }
@@ -51,7 +49,7 @@ func get(target string, headers ...string) request {
// inspect returns what crs finds in r, sent to git.example by a browser, // inspect returns what crs finds in r, sent to git.example by a browser,
// whose Host, User-Agent and Accept r.headers may replace. // whose Host, User-Agent and Accept r.headers may replace.
func inspect(t *testing.T, crs *coreruleset.CoreRuleSet, r request) coreruleset.Result { func inspect(t *testing.T, crs *waf.CoreRuleSet, r request) waf.Result {
t.Helper() t.Helper()
result, _ := inspectBody(t, crs, r, "") result, _ := inspectBody(t, crs, r, "")
@@ -62,8 +60,8 @@ func inspect(t *testing.T, crs *coreruleset.CoreRuleSet, r request) coreruleset.
// inspectBody is inspect for r with body, which is announced with its // inspectBody is inspect for r with body, which is announced with its
// Content-Length unless it is "", and returns what crs read of body too. // Content-Length unless it is "", and returns what crs read of body too.
func inspectBody( func inspectBody(
t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string, t *testing.T, crs *waf.CoreRuleSet, r request, body string,
) (coreruleset.Result, string) { ) (waf.Result, string) {
t.Helper() t.Helper()
req := httptest.NewRequestWithContext(t.Context(), r.method, req := httptest.NewRequestWithContext(t.Context(), r.method,
@@ -98,9 +96,7 @@ func inspectBody(
} }
// wantResult checks what crs finds in r. // wantResult checks what crs finds in r.
func wantResult( func wantResult(t *testing.T, crs *waf.CoreRuleSet, r request, want waf.Result) {
t *testing.T, crs *coreruleset.CoreRuleSet, r request, want coreruleset.Result,
) {
t.Helper() t.Helper()
if got := inspect(t, crs, r); !reflect.DeepEqual(got, want) { if got := inspect(t, crs, r); !reflect.DeepEqual(got, want) {
@@ -110,29 +106,28 @@ func wantResult(
// matched is the result of a request that the rules ids match, each of // matched is the result of a request that the rules ids match, each of
// them a critical one, which adds 5 to the score. // them a critical one, which adds 5 to the score.
func matched(ids ...int) coreruleset.Result { func matched(ids ...int) waf.Result {
const critical = 5 const critical = 5
return coreruleset.Result{RuleIDs: ids, Score: critical * len(ids)} return waf.Result{RuleIDs: ids, Score: critical * len(ids)}
} }
// atDefaults returns the Core Rule Set as sww runs it by default. // atDefaults returns the Core Rule Set as sww runs it by default.
func atDefaults(t *testing.T) *coreruleset.CoreRuleSet { func atDefaults(t *testing.T) *waf.CoreRuleSet {
t.Helper() t.Helper()
return newCoreRuleSet(t, 1, defaultDisabledRules...) return newCoreRuleSet(t, 1, defaultDisabledRules...)
} }
// wantChange checks that crs lets through passes, a gitea request one of // wantChange checks that crs lets through passes, a gitea request one of
// the eight changes is for, and still finds result in refused, a request // the six changes or gitea's fixes are for, and still finds result in
// like it that the change is not for. // refused, a request like it that they are not for.
func wantChange( func wantChange(
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request, t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
result coreruleset.Result,
) { ) {
t.Helper() t.Helper()
wantResult(t, crs, passes, coreruleset.Result{}) wantResult(t, crs, passes, waf.Result{})
wantResult(t, crs, refused, result) wantResult(t, crs, refused, result)
} }
@@ -167,12 +162,12 @@ func TestExpectAndContentEncodingAreAllowed(t *testing.T) {
wantResult(t, crs, wantResult(t, crs,
request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}}, request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}},
coreruleset.Result{}) waf.Result{})
wantResult(t, crs, wantResult(t, crs,
request{http.MethodPost, fetch, []string{ request{http.MethodPost, fetch, []string{
fetchType, length, "Content-Encoding: gzip", fetchType, length, "Content-Encoding: gzip",
}}, }},
coreruleset.Result{}) waf.Result{})
// Every other header on the Core Rule Set's list stays refused. // Every other header on the Core Rule Set's list stays refused.
for _, header := range []string{ for _, header := range []string{
@@ -201,7 +196,7 @@ func TestTransferEncodingIsRead(t *testing.T) {
"Content-Type: application/x-git-receive-pack-request", "Content-Type: application/x-git-receive-pack-request",
"Transfer-Encoding: chunked", "Transfer-Encoding: chunked",
}}, }},
coreruleset.Result{}) waf.Result{})
} }
func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) { func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) {
@@ -255,7 +250,7 @@ func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testi
for _, value := range []struct { for _, value := range []struct {
name string name string
// result is what a parameter that is not one of gitea's gets. // result is what a parameter that is not one of gitea's gets.
result coreruleset.Result result waf.Result
}{ }{
// A file on the list of system files. // A file on the list of system files.
{".gitignore", matched(930120)}, {".gitignore", matched(930120)},
@@ -276,7 +271,7 @@ func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testi
wantChange(t, crs, get("/?path=|cat%20/etc/passwd"), get("/?q=|cat%20/etc/passwd"), wantChange(t, crs, get("/?path=|cat%20/etc/passwd"), get("/?q=|cat%20/etc/passwd"),
matched(930120, 932160)) matched(930120, 932160))
wantResult(t, crs, get("/?path=../../etc/passwd"), wantResult(t, crs, get("/?path=../../etc/passwd"),
coreruleset.Result{RuleIDs: []int{930100, 930110}, Score: 20}) waf.Result{RuleIDs: []int{930100, 930110}, Score: 20})
wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100)) wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100))
} }
@@ -346,7 +341,7 @@ func TestEmptyHeaderIsRead(t *testing.T) {
// An empty User-Agent is a notice, which adds 2. // An empty User-Agent is a notice, which adds 2.
wantResult(t, atDefaults(t), get("/", "User-Agent: "), wantResult(t, atDefaults(t), get("/", "User-Agent: "),
coreruleset.Result{RuleIDs: []int{920330}, Score: 2}) waf.Result{RuleIDs: []int{920330}, Score: 2})
} }
func TestParanoiaLevel(t *testing.T) { func TestParanoiaLevel(t *testing.T) {
@@ -355,7 +350,7 @@ func TestParanoiaLevel(t *testing.T) {
// Accept-Charset is refused from paranoia level 2. // Accept-Charset is refused from paranoia level 2.
r := get("/", "Accept-Charset: utf-8") r := get("/", "Accept-Charset: utf-8")
wantResult(t, newCoreRuleSet(t, 1), r, coreruleset.Result{}) wantResult(t, newCoreRuleSet(t, 1), r, waf.Result{})
wantResult(t, newCoreRuleSet(t, 2), r, matched(920451)) wantResult(t, newCoreRuleSet(t, 2), r, matched(920451))
} }
@@ -367,8 +362,8 @@ func TestEachDisabledRuleIsSwitchedOff(t *testing.T) {
r := request{http.MethodTrace, "/", []string{"Host: 192.0.2.1"}} r := request{http.MethodTrace, "/", []string{"Host: 192.0.2.1"}}
wantResult(t, newCoreRuleSet(t, 1), r, wantResult(t, newCoreRuleSet(t, 1), r,
coreruleset.Result{RuleIDs: []int{911100, 920350}, Score: 8}) waf.Result{RuleIDs: []int{911100, 920350}, Score: 8})
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, coreruleset.Result{}) wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{})
} }
func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) { func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
@@ -386,10 +381,100 @@ func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
matched(930120, 932160)) matched(930120, 932160))
} }
// giteaFixed are gitea requests that only gitea's fixes let through, one
// for each kind of fix, each with what the Core Rule Set finds in it
// without them.
//
//nolint:gochecknoglobals // a constant cannot be a list
var giteaFixed = []struct {
request request
result waf.Result
}{
// A search of code, on the path of gitea's search.
{get("/explore/code?q=SELECT%20*%20FROM%20users%20WHERE"), matched(942100)},
// A file name, in a parameter gitea sends names in, on any path.
{get("/?path=.gitignore"), matched(930120)},
// A file name, in a cookie of gitea's.
{
get("/owner/repo", "Cookie: gitea_flash=success%3DFile%2Bpackage.json%2Bdeleted"),
matched(930120),
},
}
// The hostnames of the tests of SWW_APPS: the one that serves gitea, and
// one that serves another app.
const (
giteaHost = "git.example"
otherHost = "blog.example"
)
// withHost returns r sent to host.
func withHost(r request, host string) request {
return request{r.method, r.target, append([]string{"Host: " + host}, r.headers...)}
}
// servingApps returns the Core Rule Set at the defaults, with SWW_APPS
// giving apps.
func servingApps(t *testing.T, apps map[string][]string) *waf.CoreRuleSet {
t.Helper()
crs, err := waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, Apps: apps,
})
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
return crs
}
func TestAppsUnsetGivesEveryHostnameEveryAppsFixes(t *testing.T) {
t.Parallel()
crs := servingApps(t, nil)
for _, fixed := range giteaFixed {
for _, host := range []string{giteaHost, otherHost} {
wantResult(t, crs, withHost(fixed.request, host), waf.Result{})
}
}
}
func TestListedHostnameGetsItsAppsFixes(t *testing.T) {
t.Parallel()
crs := servingApps(t, map[string][]string{giteaHost: {"gitea"}})
// The hostname is matched without the port and without regard to case.
for _, host := range []string{giteaHost, "GIT.Example:8443"} {
for _, fixed := range giteaFixed {
wantResult(t, crs, withHost(fixed.request, host), waf.Result{})
}
}
}
func TestUnlistedHostnameGetsTheFullCheck(t *testing.T) {
t.Parallel()
for _, apps := range []map[string][]string{
{giteaHost: {"gitea"}},
// SWW_APPS set empty lists no hostname.
{},
} {
crs := servingApps(t, apps)
for _, host := range []string{otherHost, otherHost + ":8443", "git.example.org"} {
for _, fixed := range giteaFixed {
wantResult(t, crs, withHost(fixed.request, host), fixed.result)
}
}
}
}
// withExclusions returns the Core Rule Set at the defaults, with the // withExclusions returns the Core Rule Set at the defaults, with the
// exclusions file at path. // exclusions file at path.
func withExclusions(path string) (*coreruleset.CoreRuleSet, error) { func withExclusions(path string) (*waf.CoreRuleSet, error) {
return coreruleset.New(coreruleset.Params{ return waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path, ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path,
}) })
} }
@@ -427,7 +512,7 @@ func TestExclusionsFileIsLoadedBeforeTheRules(t *testing.T) {
func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) { func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
t.Parallel() t.Parallel()
crs, err := coreruleset.New(coreruleset.Params{ crs, err := waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: []int{920500}, ParanoiaLevel: 1, DisabledRules: []int{920500},
ExclusionsFile: exclusionsFile(t, backupFileExclusion), ExclusionsFile: exclusionsFile(t, backupFileExclusion),
}) })
@@ -435,7 +520,7 @@ func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
t.Fatalf("load the exclusions: %v", err) t.Fatalf("load the exclusions: %v", err)
} }
wantResult(t, crs, get("/notes.txt~"), coreruleset.Result{}) wantResult(t, crs, get("/notes.txt~"), waf.Result{})
} }
func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) { func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) {
@@ -571,10 +656,10 @@ const injection = "1' OR '1'='1"
// readingBodies returns the Core Rule Set as sww runs it by // readingBodies returns the Core Rule Set as sww runs it by
// default, but reading bodies up to bodyLimit. // default, but reading bodies up to bodyLimit.
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet { func readingBodies(t *testing.T) *waf.CoreRuleSet {
t.Helper() t.Helper()
crs, err := coreruleset.New(coreruleset.Params{ crs, err := waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit, ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
}) })
if err != nil { if err != nil {
@@ -608,8 +693,7 @@ func padded(head, tail string, n int) string {
// wantBody checks what crs finds in r with body, and that what it read of // wantBody checks what crs finds in r with body, and that what it read of
// body is read. // body is read.
func wantBody( func wantBody(
t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string, t *testing.T, crs *waf.CoreRuleSet, r request, body string, want waf.Result,
want coreruleset.Result,
read string, read string,
) { ) {
t.Helper() t.Helper()
@@ -633,7 +717,7 @@ func TestBodiesAreReadOnlyWhileBodyLimitIsSet(t *testing.T) {
{jsonBody, `{"q":"` + injection + `"}`}, {jsonBody, `{"q":"` + injection + `"}`},
{xmlBody, "<q>" + injection + "</q>"}, {xmlBody, "<q>" + injection + "</q>"},
} { } {
wantBody(t, off, post(tc.header), tc.body, coreruleset.Result{}, "") wantBody(t, off, post(tc.header), tc.body, waf.Result{}, "")
wantBody(t, on, post(tc.header), tc.body, matched(942100), tc.body) wantBody(t, on, post(tc.header), tc.body, matched(942100), tc.body)
} }
} }
@@ -656,7 +740,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
} { } {
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100), wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
tc.attackFirst[:bodyLimit+1]) tc.attackFirst[:bodyLimit+1])
wantBody(t, crs, post(tc.header), tc.attackLast, coreruleset.Result{}, wantBody(t, crs, post(tc.header), tc.attackLast, waf.Result{},
tc.attackLast[:bodyLimit+1]) tc.attackLast[:bodyLimit+1])
} }
@@ -666,7 +750,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit) wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit)
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1) pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
wantBody(t, crs, post(formData), pastLimit, coreruleset.Result{}, pastLimit) wantBody(t, crs, post(formData), pastLimit, waf.Result{}, pastLimit)
} }
func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) { func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
@@ -682,7 +766,7 @@ func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
wantBody(t, crs, post(tc.header), fits, matched(942100), fits) wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
larger := padded(tc.head, tc.tail, bodyLimit+1) larger := padded(tc.head, tc.tail, bodyLimit+1)
wantBody(t, crs, post(tc.header), larger, coreruleset.Result{}, larger) wantBody(t, crs, post(tc.header), larger, waf.Result{}, larger)
} }
} }
@@ -700,7 +784,7 @@ func TestOtherBodiesAreNotRead(t *testing.T) {
"Content-Type: text/plain", "Content-Type: text/plain",
"Content-Type: application/x-git-receive-pack-request", "Content-Type: application/x-git-receive-pack-request",
} { } {
wantBody(t, crs, post(header), body, coreruleset.Result{}, "") wantBody(t, crs, post(header), body, waf.Result{}, "")
} }
} }
@@ -728,8 +812,8 @@ func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(t *testing.
// It is allowed on a body of any other kind, and on every body while no // It is allowed on a body of any other kind, and on every body while no
// body is read. // body is read.
fetch := "Content-Type: application/x-git-upload-pack-request" fetch := "Content-Type: application/x-git-upload-pack-request"
wantBody(t, crs, post(fetch, gzip), "a", coreruleset.Result{}, "") wantBody(t, crs, post(fetch, gzip), "a", waf.Result{}, "")
wantBody(t, atDefaults(t), post(formData, gzip), "a", coreruleset.Result{}, "") wantBody(t, atDefaults(t), post(formData, gzip), "a", waf.Result{}, "")
} }
func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) { func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
@@ -740,18 +824,18 @@ func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
body string body string
want coreruleset.Result want waf.Result
}{ }{
{"path=.gitignore", coreruleset.Result{}}, {"path=.gitignore", waf.Result{}},
{"q=.gitignore", matched(930120)}, {"q=.gitignore", matched(930120)},
{"redirect_uri=" + local, coreruleset.Result{}}, {"redirect_uri=" + local, waf.Result{}},
{"next=" + local, matched(931100, 934110)}, {"next=" + local, matched(931100, 934110)},
} { } {
wantBody(t, crs, post(formData), tc.body, tc.want, tc.body) wantBody(t, crs, post(formData), tc.body, tc.want, tc.body)
} }
body := field("path", ".gitignore") + end body := field("path", ".gitignore") + end
wantBody(t, crs, post(multipart), body, coreruleset.Result{}, body) wantBody(t, crs, post(multipart), body, waf.Result{}, body)
body = field("q", ".gitignore") + end body = field("q", ".gitignore") + end
wantBody(t, crs, post(multipart), body, matched(930120), body) wantBody(t, crs, post(multipart), body, matched(930120), body)
@@ -875,7 +959,7 @@ func TestCorazaWritesNoFile(t *testing.T) {
func TestBodyLimitOf1GLoads(t *testing.T) { func TestBodyLimitOf1GLoads(t *testing.T) {
t.Parallel() t.Parallel()
_, err := coreruleset.New(coreruleset.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30}) _, err := waf.New(waf.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30})
if err != nil { if err != nil {
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err) t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
} }
+4 -5
View File
@@ -187,16 +187,15 @@ main() {
echo "example-app: rproxy passes a request to the app and its answer back" echo "example-app: rproxy passes a request to the app and its answer back"
refused || fail "a second request in a minute was not refused" refused || fail "a second request in a minute was not refused"
wait_for "discriminator logged no ban" \ wait_for "discriminator logged no ban" logged "$DISCRIMINATOR" '"action":"rate_limited"'
logged "$DISCRIMINATOR" '"action":"client_is_rate_limited"'
echo "example-app: a second request in a minute bans the client" echo "example-app: a second request in a minute bans the client"
refused_from 203.0.113.9 /.env || fail "a probe for /.env was not refused" refused_from 203.0.113.9 /.env || fail "a probe for /.env was not refused"
wait_for "discriminator logged no ban for the probe" \ wait_for "discriminator logged no ban for the probe" \
logged "$DISCRIMINATOR" '"action":"temporarily_banned"' '"rule_ids":["env-file"]' logged "$DISCRIMINATOR" '"action":"banned"' '"rule_ids":["env-file"]'
refused_from 203.0.113.9 / || fail "the client of the probe was let through" 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" \ wait_for "the client's next request did not make its ban permanent" \
logged "$DISCRIMINATOR" '"action":"permanently_banned"' '"ban_expires":"permanent"' logged "$DISCRIMINATOR" '"ban_expires":"permanent"'
echo "example-app: a probe for /.env bans the client, its next request for good" echo "example-app: a probe for /.env bans the client, its next request for good"
docker exec "$DISCRIMINATOR" sv stop sww >/dev/null || docker exec "$DISCRIMINATOR" sv stop sww >/dev/null ||
@@ -233,7 +232,7 @@ main() {
start_app start_app
refused || fail "the new discriminator container let the banned client through" refused || fail "the new discriminator container let the banned client through"
wait_for "discriminator logged no request refused under the ban" \ wait_for "discriminator logged no request refused under the ban" \
logged "$DISCRIMINATOR" '"action":"temporarily_banned"' logged "$DISCRIMINATOR" '"action":"banned"'
echo "example-app: a new discriminator container on the same volume keeps the ban" echo "example-app: a new discriminator container on the same volume keeps the ban"
docker rm --force "$DISCRIMINATOR" >/dev/null docker rm --force "$DISCRIMINATOR" >/dev/null