Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d5d347f30 |
No files matched your search
+44
-56
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \
|
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
||||||
hooks build run example-app
|
run example-app
|
||||||
|
|
||||||
# Makefile targets are thin shims; the implementations live in script/
|
# Makefile targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
||||||
# which test checks. build and run are for working on the code by hand;
|
# which test checks. build and run are for working on the code by hand;
|
||||||
# example-app checks the image with an app built on it; publish builds
|
# example-app checks the image with an app built on it.
|
||||||
# the image on the Docker host and pushes it to the registry.
|
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@script/bootstrap
|
@script/bootstrap
|
||||||
@@ -35,9 +34,6 @@ check:
|
|||||||
docker:
|
docker:
|
||||||
@script/docker
|
@script/docker
|
||||||
|
|
||||||
publish:
|
|
||||||
@script/publish
|
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
@script/install-precommit
|
@script/install-precommit
|
||||||
|
|
||||||
|
|||||||
@@ -68,9 +68,8 @@ Not built, and not planned:
|
|||||||
|
|
||||||
### The image
|
### The image
|
||||||
|
|
||||||
The image is published at `git.eeqj.de/sneak/smallwebwaf`. You can also build it
|
No image is published. Build it from a clone. `make docker` runs the tests and
|
||||||
from a clone. `make docker` runs the tests and the linter, then builds the
|
the linter, then builds the image, tagged `smallwebwaf`.
|
||||||
image, tagged `smallwebwaf`.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
||||||
@@ -173,7 +172,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,132 +209,131 @@ 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
|
- The byte limits and the anomaly thresholds on bytes count body bytes: the
|
||||||
app's response bodies, the visitors' request bodies, or both, as
|
app's response bodies, the visitors' request bodies, or both, as
|
||||||
`SWW_BYTES_COUNT` says. Headers are never counted. What the visitor sent over
|
`SWW_BYTES_COUNT` says. Headers are never counted.
|
||||||
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 `********`.
|
||||||
|
|
||||||
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. |
|
||||||
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
||||||
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
|
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
|
||||||
| `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` | 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_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`. |
|
||||||
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
||||||
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
||||||
| `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`; 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`; 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`; 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`; 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`; the next request is refused and bans the client. |
|
||||||
| `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 body bytes a client's requests may total in a minute, a size or `off`; the request that takes the total past a byte limit is passed on whole, and bans the client once its response has ended. |
|
||||||
| `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 body bytes a client's requests may total 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 body bytes a client's requests may total 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 and the anomaly thresholds on bytes count: `response`, the app's response bodies; `request`, the visitors' request bodies; 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`; the next such refusal bans the client. |
|
||||||
| `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` (a local database) 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`. |
|
||||||
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
| `SWW_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
|
||||||
| `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_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
||||||
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
| `SWW_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
|
||||||
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
|
| `SWW_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
|
||||||
| `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 `SWW_ASN_LIMIT_PERCENT`. |
|
||||||
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWW_COUNTRY_LIMIT_PERCENT`. |
|
||||||
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
||||||
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). |
|
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
|
||||||
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
||||||
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
||||||
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
||||||
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
||||||
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
||||||
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
||||||
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
|
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
|
||||||
| `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_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`. |
|
||||||
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
||||||
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
||||||
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
||||||
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
||||||
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
||||||
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
|
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
|
||||||
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
|
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
|
||||||
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
||||||
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
||||||
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
||||||
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
||||||
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
||||||
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
||||||
| `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
| `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
||||||
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
|
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
|
||||||
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
||||||
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
|
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
|
||||||
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
||||||
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
||||||
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
||||||
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
||||||
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
||||||
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
||||||
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
||||||
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
||||||
| `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,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_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 body bytes total more 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 body bytes total more 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 body bytes of the netblock around a client total more 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 body bytes of the netblock around a client total more 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 the body bytes of one AS number total more 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 the body bytes of one AS number total more 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 body bytes of the whole service total more 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 body bytes of the whole service total more 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 the body bytes of a watched netblock total more 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 the body bytes of a watched netblock total more 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.
|
||||||
@@ -418,7 +416,8 @@ services:
|
|||||||
SWW_RATE_LIMIT_PER_HOUR: "600"
|
SWW_RATE_LIMIT_PER_HOUR: "600"
|
||||||
SWW_RATE_LIMIT_PER_DAY: "3000"
|
SWW_RATE_LIMIT_PER_DAY: "3000"
|
||||||
# Hosting networks and one country get a share of those limits.
|
# Hosting networks and one country get a share of those limits.
|
||||||
SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25
|
SWW_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
|
||||||
|
SWW_COUNTRY_LIMIT_PERCENT: cn:25
|
||||||
# Paths only scanners ask for; one request bans the client.
|
# Paths only scanners ask for; one request bans the client.
|
||||||
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
||||||
# Answer a refused client with 429 rather than 403.
|
# Answer a refused client with 429 rather than 403.
|
||||||
@@ -510,27 +509,24 @@ of the request reaches the app.
|
|||||||
|
|
||||||
`discriminator` takes the checks:
|
`discriminator` takes the checks:
|
||||||
|
|
||||||
3. The tag lists.
|
3. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
||||||
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
4. The bans.
|
||||||
5. The bans.
|
5. The lookup of the client's AS number and country.
|
||||||
6. The lookup of the client's AS number and country.
|
6. The country lists.
|
||||||
7. The country list.
|
7. The reputation sources.
|
||||||
8. The reputation sources.
|
8. The client's limit percentage, from its AS number, its country or a
|
||||||
9. The tag refusals of `SWW_TAG_DENY`.
|
reputation hit.
|
||||||
10. The client's limit percentage, from its tags, its AS number, its country or
|
9. The rate limits.
|
||||||
a reputation hit.
|
10. The trap paths.
|
||||||
11. The rate limits.
|
11. The rule files.
|
||||||
12. The trap paths.
|
12. The header limits, and then the Core Rule Set.
|
||||||
13. The rule files.
|
13. A path under `/_sww/` is answered `404`, so that the app never sees that
|
||||||
14. The header limits, and then the Core Rule Set.
|
|
||||||
15. The tag refusals again, so that `exploitsignature` can refuse too.
|
|
||||||
16. A path under `/_sww/` is answered `404`, so that the app never sees that
|
|
||||||
prefix.
|
prefix.
|
||||||
|
|
||||||
`rproxy` takes the rest, with `discriminator` counting at the end:
|
`rproxy` takes the rest, with `discriminator` counting at the end:
|
||||||
|
|
||||||
17. The request is passed to the app, streaming.
|
14. The request is passed to the app, streaming.
|
||||||
18. After the answer, `rproxy` sends `discriminator` a report with the status
|
15. After the answer, `rproxy` sends `discriminator` a report with the status
|
||||||
and the body bytes each way. For an allowed request, `discriminator` counts
|
and the body bytes each way. For an allowed request, `discriminator` counts
|
||||||
the bytes `SWW_BYTES_COUNT` names toward the byte limits, bans the client if
|
the bytes `SWW_BYTES_COUNT` names toward the byte limits, bans the client if
|
||||||
they take it past one, and adds the request to the client's history.
|
they take it past one, and adds the request to the client's history.
|
||||||
@@ -538,7 +534,7 @@ of the request reaches the app.
|
|||||||
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
||||||
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
||||||
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
|
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
|
||||||
request over the header limits of step 14 is answered `431`, and bans nobody
|
request over the header limits of step 12 is answered `431`, and bans nobody
|
||||||
either. `rproxy` itself answers `405` to a method it does not pass on, and the
|
either. `rproxy` itself answers `405` to a method it does not pass on, and the
|
||||||
status for a size or time limit, such as `413` for a body too large.
|
status for a size or time limit, such as `413` for a body too large.
|
||||||
|
|
||||||
@@ -557,21 +553,6 @@ status for a size or time limit, such as `413` for a body too large.
|
|||||||
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
|
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
|
||||||
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
|
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
|
||||||
|
|
||||||
### Request tags
|
|
||||||
|
|
||||||
Each request gets tags from what `discriminator` knows of its client, written in
|
|
||||||
the `tags` field of its log line:
|
|
||||||
|
|
||||||
- `asn=<number>` and `country=<code>`, from the lookup;
|
|
||||||
- one tag per list of `SWW_TAG_LISTS` that holds the client, such as `torexit`
|
|
||||||
or `vpn=mullvad`;
|
|
||||||
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
|
|
||||||
the client;
|
|
||||||
- `exploitsignature`, when a rule file or the Core Rule Set matched the request.
|
|
||||||
|
|
||||||
`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a
|
|
||||||
client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
|
||||||
|
|
||||||
### Attack detection
|
### Attack detection
|
||||||
|
|
||||||
- The Core Rule Set reads the method, the URL with its query string, and the
|
- The Core Rule Set reads the method, the URL with its query string, and the
|
||||||
@@ -688,15 +669,6 @@ on the host.
|
|||||||
`make check`.
|
`make check`.
|
||||||
- `script/docker`: builds the image, whose build runs the tests and the linter
|
- `script/docker`: builds the image, whose build runs the tests and the linter
|
||||||
first. `make docker`.
|
first. `make docker`.
|
||||||
- `script/publish`: runs `script/docker`, whose build happens on the Docker host
|
|
||||||
named by `DOCKER_HOST` (`ssh://root@fsn1app1` unless set). It then pushes the
|
|
||||||
image to `PUBLISH_IMAGE` (`git.eeqj.de/sneak/smallwebwaf` unless set). It
|
|
||||||
refuses a working tree with uncommitted changes. `make publish`. It pushes
|
|
||||||
four tags:
|
|
||||||
- the UTC date as `YYYY-MM-DD`;
|
|
||||||
- the short commit hash;
|
|
||||||
- the branch name;
|
|
||||||
- `latest`.
|
|
||||||
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
|
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
|
||||||
image build.
|
image build.
|
||||||
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -119,9 +119,9 @@ to them.
|
|||||||
|
|
||||||
## What happens to a request
|
## What happens to a request
|
||||||
|
|
||||||
`rproxy` runs steps 1, 2 and 17, and sends the report of step 18.
|
`rproxy` runs steps 1, 2 and 14, and sends the report of step 15.
|
||||||
`discriminator` runs steps 3 to 16, and does the counting of step 18. The first
|
`discriminator` runs steps 3 to 13, and does the counting of step 15. The first
|
||||||
step that produces a final answer ends the checks. Step 18 runs for each request
|
step that produces a final answer ends the checks. Step 15 runs for each request
|
||||||
`discriminator` answers with `allow`, `deny` or `close`. `rproxy`'s size and
|
`discriminator` answers with `allow`, `deny` or `close`. `rproxy`'s size and
|
||||||
time limits apply to the whole exchange. Each step names the section that holds
|
time limits apply to the whole exchange. Each step names the section that holds
|
||||||
its rules. A step that names a heuristic in parentheses runs that heuristic. The
|
its rules. A step that names a heuristic in parentheses runs that heuristic. The
|
||||||
@@ -143,54 +143,46 @@ request log's `heuristic` field gives that name. `refusal_limit` and
|
|||||||
- An IPv6 client is its group: the netblock of `SWW_IPV6_GROUP_PREFIX`
|
- An IPv6 client is its group: the netblock of `SWW_IPV6_GROUP_PREFIX`
|
||||||
around its address, a /64 by default, since one abuser usually controls a
|
around its address, a /64 by default, since one abuser usually controls a
|
||||||
whole /64. A client is therefore one IPv4 address or one IPv6 group.
|
whole /64. A client is therefore one IPv4 address or one IPv6 group.
|
||||||
3. The tag lists (`tag_lists`; see "Request tags"). Each list that holds the
|
3. The static lists. A client in `SWW_ALLOW_NETS` skips steps 4 to 12, the byte
|
||||||
client adds its tag. A client in `SWW_ALLOW_NETS` gets no tag.
|
|
||||||
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.
|
||||||
5. Bans. A client under a ban is refused (`active_ban`; see "Bans").
|
4. Bans. A client under a ban is refused (`active_ban`; see "Bans").
|
||||||
6. The lookup, unless `SWW_LOOKUP_SOURCE` is `off` (see "Lookup of AS number and
|
5. The lookup, unless `SWW_LOOKUP_SOURCE` is `off` (see "Lookup of AS number and
|
||||||
country"). The request goes on at once. It waits for the first GeoJS answer,
|
country"). The request goes on at once. It waits for the first GeoJS answer,
|
||||||
up to `SWW_LOOKUP_TIMEOUT`, only when one of these needs it:
|
up to `SWW_LOOKUP_TIMEOUT`, only when one of these needs it:
|
||||||
- the country list;
|
- a country list;
|
||||||
- a biased threshold;
|
- a biased threshold;
|
||||||
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming an `asn=` or `country=`
|
|
||||||
tag;
|
|
||||||
- `SWW_ADD_LOOKUP_HEADERS`.
|
- `SWW_ADD_LOOKUP_HEADERS`.
|
||||||
|
|
||||||
A request that ends before the answer comes is logged without it.
|
A request that ends before the answer comes is logged without it.
|
||||||
|
|
||||||
7. The country list (`countries`; see "Country list").
|
6. The country lists (`countries`; see "Country lists").
|
||||||
8. The reputation sources (see "Reputation"), in this order:
|
7. The reputation sources (see "Reputation"), in this order:
|
||||||
- A client on a fetched blocklist gets `SWW_BLOCKLIST_ACTION`
|
- A client on a fetched blocklist gets `SWW_BLOCKLIST_ACTION`
|
||||||
(`blocklists`).
|
(`blocklists`).
|
||||||
- A client on the CrowdSec decision list is banned (`crowdsec`).
|
- A client on the CrowdSec decision list is banned (`crowdsec`).
|
||||||
- A client listed by a DNS blocklist gets `SWW_REPUTATION_ACTION` (`dnsbl`).
|
- A client listed by a DNS blocklist gets `SWW_REPUTATION_ACTION` (`dnsbl`).
|
||||||
- A client scored at least `SWW_ABUSEIPDB_MIN_SCORE` by AbuseIPDB gets
|
- A client scored at least `SWW_ABUSEIPDB_MIN_SCORE` by AbuseIPDB gets
|
||||||
`SWW_REPUTATION_ACTION` (`abuseipdb`).
|
`SWW_REPUTATION_ACTION` (`abuseipdb`).
|
||||||
9. The tag refusals (`tag_deny`; see "Request tags"). A request carrying a tag
|
8. The client's limit percentage (see "Biased thresholds").
|
||||||
in `SWW_TAG_DENY` is refused.
|
9. Rate limiting (`rate_limits`; see "Request rate limits, per client"). A
|
||||||
10. The client's limit percentage (see "Biased thresholds").
|
request over a limit times the percentage is refused and bans the client.
|
||||||
11. Rate limiting (`rate_limits`; see "Request rate limits, per client"). A
|
10. The trap paths (`trap_paths`; see "Attack detection"). A request for one is
|
||||||
request over a limit times the percentage is refused and bans the client.
|
|
||||||
12. The trap paths (`trap_paths`; see "Attack detection"). A request for one is
|
|
||||||
refused and bans the client.
|
refused and bans the client.
|
||||||
13. The rule files (`rule_files`; see "Rule files"). Matching stops at the first
|
11. The rule files (`rule_files`; see "Rule files"). Matching stops at the first
|
||||||
rule that refuses.
|
rule that refuses.
|
||||||
14. The header limits (`header_limits`; see "Answering in time"), and then the
|
12. The header limits (`header_limits`; see "Answering in time"), and then the
|
||||||
Core Rule Set (`core_rule_set`; see "Attack detection"). A Core Rule Set
|
Core Rule Set (`core_rule_set`; see "Attack detection"). A Core Rule Set
|
||||||
match is refused with `403` while `SWW_WAF_MODE` is `block`, and only logged
|
match is refused with `403` while `SWW_WAF_MODE` is `block`, and only logged
|
||||||
and alerted while it is `detect`.
|
and alerted while it is `detect`.
|
||||||
15. The tag refusals again (`tag_deny`), so that `exploitsignature` can refuse
|
13. A path under `/_sww/` is answered `404`, so that the app never sees that
|
||||||
too.
|
|
||||||
16. A path under `/_sww/` is answered `404`, so that the app never sees that
|
|
||||||
prefix.
|
prefix.
|
||||||
17. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing
|
14. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing
|
||||||
a request to the app"). With `SWW_ADD_LOOKUP_HEADERS`, `X-Client-ASN` and
|
a request to the app"). With `SWW_ADD_LOOKUP_HEADERS`, `X-Client-ASN` and
|
||||||
`X-Client-Country` are added for the app's own logs; a header whose value is
|
`X-Client-Country` are added for the app's own logs; a header whose value is
|
||||||
unknown is left out. Whatever that setting says, any such header the client
|
unknown is left out. Whatever that setting says, any such header the client
|
||||||
sent is removed.
|
sent is removed.
|
||||||
18. After the visitor's answer has ended, `rproxy` sends the report (see "The
|
15. After the visitor's answer has ended, `rproxy` sends the report (see "The
|
||||||
report"). `discriminator` then counts:
|
report"). `discriminator` then counts:
|
||||||
- For a request it allowed, the body bytes of the response, of the request
|
- For a request it allowed, the body bytes of the response, of the request
|
||||||
or of both are added to the byte counters, as `SWW_BYTES_COUNT` says. For
|
or of both are added to the byte counters, as `SWW_BYTES_COUNT` says. For
|
||||||
@@ -260,8 +252,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 request whose body bytes take the client's 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:
|
||||||
@@ -295,8 +286,7 @@ no offence:
|
|||||||
|
|
||||||
- `SWW_DENY_NETS`;
|
- `SWW_DENY_NETS`;
|
||||||
- a blocklist or reputation hit whose action is `deny`;
|
- a blocklist or reputation hit whose action is `deny`;
|
||||||
- the country list;
|
- the country lists.
|
||||||
- `SWW_TAG_DENY`.
|
|
||||||
|
|
||||||
Every ban carries notes with what an admin needs to decide whether to lift it,
|
Every ban carries notes with what an admin needs to decide whether to lift it,
|
||||||
drawn from what `discriminator` already knows. No lookup is made for the notes
|
drawn from what `discriminator` already knows. No lookup is made for the notes
|
||||||
@@ -317,13 +307,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,
|
||||||
@@ -410,7 +393,7 @@ Conventions:
|
|||||||
|
|
||||||
### Static lists
|
### Static lists
|
||||||
|
|
||||||
- `SWW_ALLOW_NETS` skips the checks step 4 names, for monitoring or the owner's
|
- `SWW_ALLOW_NETS` skips the checks step 3 names, for monitoring or the owner's
|
||||||
own networks. `SWW_RATE_LIMIT_EXEMPT_NETS` bypasses the request and byte
|
own networks. `SWW_RATE_LIMIT_EXEMPT_NETS` bypasses the request and byte
|
||||||
limits only; the error burst, attack detection and bans still apply.
|
limits only; the error burst, attack detection and bans still apply.
|
||||||
- Many people can share one address, as in an office, on a mobile carrier or
|
- Many people can share one address, as in an office, on a mobile carrier or
|
||||||
@@ -458,10 +441,8 @@ the same way.
|
|||||||
### Byte limits, per client
|
### Byte limits, per client
|
||||||
|
|
||||||
The byte limits count body bytes: the app's response bodies, the visitors'
|
The byte limits count body bytes: the app's response bodies, the visitors'
|
||||||
request bodies, or both, as `SWW_BYTES_COUNT` says. Headers are never counted.
|
request bodies, or both, as `SWW_BYTES_COUNT` says. Headers are never counted. A
|
||||||
What the visitor sent over a WebSocket counts as request bytes, and what the app
|
request's bytes are counted once its response has ended (see step 15). So the
|
||||||
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 (see step 18). So the
|
|
||||||
request that takes a client past a limit is passed on whole, and the ban refuses
|
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
|
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
|
client can download in a burst at full speed. The hour limit defaults to 50 GiB
|
||||||
@@ -494,12 +475,12 @@ service that keeps its visitors' addresses on its own host.
|
|||||||
dropped from that table. The answer reaches the client's history and ban notes
|
dropped from that table. The answer reaches the client's history and ban notes
|
||||||
when it comes.
|
when it comes.
|
||||||
- `SWW_LOOKUP_TIMEOUT` is how long a visitor's request waits for its client's
|
- `SWW_LOOKUP_TIMEOUT` is how long a visitor's request waits for its client's
|
||||||
first answer, when a setting needs it (see step 6). It also bounds each
|
first answer, when a setting needs it (see step 5). It also bounds each
|
||||||
request to GeoJS. A client whose answer has not come in time counts as unknown
|
request to GeoJS. A client whose answer has not come in time counts as unknown
|
||||||
until it comes, and its later requests do not wait. Its address is asked about
|
until it comes, and its later requests do not wait. Its address is asked about
|
||||||
again in the background.
|
again in the background.
|
||||||
- A client the lookup cannot place has an unknown country and AS number, which
|
- A client the lookup cannot place has an unknown country and AS number, which
|
||||||
the country list and the biased thresholds treat as described there. Nothing
|
the country lists and the biased thresholds treat as described there. Nothing
|
||||||
else treats it differently.
|
else treats it differently.
|
||||||
- GeoJS publishes no rate limit, but its terms forbid "an excessive amount of
|
- GeoJS publishes no rate limit, but its terms forbid "an excessive amount of
|
||||||
API requests", judged by GeoJS alone, and let it block a caller. While GeoJS
|
API requests", judged by GeoJS alone, and let it block a caller. While GeoJS
|
||||||
@@ -519,12 +500,12 @@ service that keeps its visitors' addresses on its own host.
|
|||||||
`file_error` alert and ignored. The database already loaded stays in use. A
|
`file_error` alert and ignored. The database already loaded stays in use. A
|
||||||
client whose address the file does not hold counts as unknown.
|
client whose address the file does not hold counts as unknown.
|
||||||
|
|
||||||
### Country list
|
### Country lists
|
||||||
|
|
||||||
`SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` is empty by default: the defaults judge a
|
Both are empty by default: the defaults judge a client by what it does, not by
|
||||||
client by what it does, not by where it comes from. A `country=` tag in
|
where it comes from.
|
||||||
`SWW_TAG_DENY` refuses one country (see "Request tags").
|
|
||||||
|
|
||||||
|
- `SWW_DENIED_COUNTRIES` refuses every request from a listed country.
|
||||||
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` lets through only clients placed in a
|
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` lets through only clients placed in a
|
||||||
listed country. Every request from any other country is refused. So is every
|
listed country. Every request from any other country is refused. So is every
|
||||||
request from a client the lookup cannot place, such as internal monitoring or
|
request from a client the lookup cannot place, such as internal monitoring or
|
||||||
@@ -533,85 +514,45 @@ client by what it does, not by where it comes from. A `country=` tag in
|
|||||||
client in whenever GeoJS stops answering. The cost: while GeoJS fails, this
|
client in whenever GeoJS stops answering. The cost: while GeoJS fails, this
|
||||||
list refuses every new client. No other failure of GeoJS, a reputation source
|
list refuses every new client. No other failure of GeoJS, a reputation source
|
||||||
or an alert destination comes this close to taking the service down.
|
or an alert destination comes this close to taking the service down.
|
||||||
- A code in this list whose `country=` tag is in `SWW_TAG_DENY` stops the start
|
- Both may be set. `SWW_DENIED_COUNTRIES` then adds nothing, since the exclusive
|
||||||
|
list already refuses every other country. A code on both lists stops the start
|
||||||
with a message naming it.
|
with a message naming it.
|
||||||
- A refused request is answered with `SWW_BAN_RESPONSE`, as soon as the client's
|
- A refused request is answered with `SWW_BAN_RESPONSE`, as soon as the client's
|
||||||
address has been looked up. It is logged with the action `country_denied`, and
|
address has been looked up. It is logged with the action `country_denied`, and
|
||||||
counted in the metrics.
|
counted in the metrics.
|
||||||
|
|
||||||
### Request tags
|
|
||||||
|
|
||||||
Each request gets tags from what `discriminator` knows of its client and what
|
|
||||||
the checks found. The operator refuses or slows clients by tag. Tags appear in
|
|
||||||
the log line as `tags`, sorted, with empty lists left out (see
|
|
||||||
`docs/request-log.md`).
|
|
||||||
|
|
||||||
Each tag and where it comes from:
|
|
||||||
|
|
||||||
- `asn=<number>` and `country=<code>`, such as `asn=64496` and `country=DE`,
|
|
||||||
from the lookup. A client the lookup cannot place has neither. A request
|
|
||||||
refused by `deny_nets` or `active_ban` has neither, because those checks come
|
|
||||||
before the lookup.
|
|
||||||
- One tag per list of `SWW_TAG_LISTS` that holds the client (`tag_lists`), the
|
|
||||||
tag being the list's name, such as `torexit` or `vpn=mullvad`. These lists are
|
|
||||||
fetched and kept like a blocklist. `discriminator` ships none of its own.
|
|
||||||
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
|
|
||||||
the client, whatever the source's action. A zone with a key is named with the
|
|
||||||
key masked, as the `reputation` field gives it.
|
|
||||||
- `exploitsignature`, when a rule file or the Core Rule Set matched the request.
|
|
||||||
For the Core Rule Set a match is a score at or over
|
|
||||||
`SWW_WAF_ANOMALY_THRESHOLD`.
|
|
||||||
|
|
||||||
A client in `SWW_ALLOW_NETS` skips the checks and gets no tag. Every tag is in
|
|
||||||
lower case, except a country code, which is in capitals. `SWW_TAG_DENY` and
|
|
||||||
`SWW_TAG_LIMIT_PERCENT` accept tags in either case.
|
|
||||||
|
|
||||||
Two settings act on tags:
|
|
||||||
|
|
||||||
- `SWW_TAG_DENY` lists tags whose requests are refused with the action
|
|
||||||
`client_denied` (`tag_deny`). The check runs twice: just before the rate
|
|
||||||
limits, and again after the Core Rule Set, so that `exploitsignature` can
|
|
||||||
refuse too.
|
|
||||||
- `SWW_TAG_LIMIT_PERCENT` lists tags with a percentage from 1 to 99. Their
|
|
||||||
clients get that percentage of every request and byte limit. The lowest
|
|
||||||
percentage among a client's tags applies. `exploitsignature` cannot be listed,
|
|
||||||
since a request gets it only after the rate limits have counted it.
|
|
||||||
|
|
||||||
The tag settings are empty by default, for the same reason as the country list.
|
|
||||||
The lookup waits for GeoJS when either names an `asn=` or `country=` tag. A
|
|
||||||
setting that names one stops the start while `SWW_LOOKUP_SOURCE` is `off`.
|
|
||||||
|
|
||||||
### Biased thresholds
|
### Biased thresholds
|
||||||
|
|
||||||
The biased thresholds lower the limits of clients by AS number, country and tag.
|
The biased thresholds lower the limits of clients in listed AS numbers and
|
||||||
The lists are empty by default, for the same reason as the country list. The
|
countries. The lists are empty by default, for the same reason as the country
|
||||||
request log and the metrics show which AS numbers and countries a service's
|
lists. The request log and the metrics show which AS numbers and countries a
|
||||||
abuse comes from.
|
service's abuse comes from.
|
||||||
|
|
||||||
- `SWW_TAG_LIMIT_PERCENT` gives a tag a percentage, such as `asn=14061:50` (see
|
- Each list gives AS numbers or countries a percentage, such as `AS14061:50`.
|
||||||
"Request tags"). A lowered limit is rounded down.
|
Their clients get that percentage of every request and byte limit
|
||||||
- `SWW_UNKNOWN_LIMIT_PERCENT` at `0` is a zero allowance: the client's first
|
(`SWW_ASN_LIMIT_PERCENT`, `SWW_COUNTRY_LIMIT_PERCENT`). A lowered limit is
|
||||||
request breaks a limit and bans it.
|
rounded down. `0` is a zero allowance: the client's first request breaks a
|
||||||
|
limit and bans it.
|
||||||
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` set the byte limits'
|
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` set the byte limits'
|
||||||
percentage alone, when it should differ from the request limits'. For an AS
|
percentage alone, when it should differ from the request limits'. For an AS
|
||||||
number they list, they replace its `asn=` tag's percentage and its entry in
|
number they list, they replace its percentage from `SWW_ASN_LIMIT_PERCENT` and
|
||||||
`SWW_ASN_LIMIT_PERCENT_URL`; for a country, its `country=` tag's percentage.
|
`SWW_ASN_LIMIT_PERCENT_URL`; for a country, the one from
|
||||||
|
`SWW_COUNTRY_LIMIT_PERCENT`.
|
||||||
- `SWW_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so one
|
- `SWW_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so one
|
||||||
abuse-source list can be shared by every `discriminator` in the fleet. It is
|
abuse-source list can be shared by every `discriminator` in the fleet. It is
|
||||||
fetched and kept like a blocklist (see "Reputation"). Each entry counts like
|
fetched and kept like a blocklist (see "Reputation"). Where it and
|
||||||
an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`. Where both give a percentage, the
|
`SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies.
|
||||||
lower applies.
|
|
||||||
- A client's limit percentage is the lowest of those that apply to it: its AS
|
- A client's limit percentage is the lowest of those that apply to it: its AS
|
||||||
number's, its country's, its tags', or a reputation hit's `limit:<percent>`. A
|
number's, its country's, or a reputation hit's `limit:<percent>`. A client
|
||||||
client without a country gets `SWW_UNKNOWN_LIMIT_PERCENT` in place of a
|
without a country gets `SWW_UNKNOWN_LIMIT_PERCENT` in place of a country's.
|
||||||
country's. The percentage is 100 if none applies. Clients given a lower
|
The percentage is 100 if none applies. Clients given a lower percentage are
|
||||||
percentage are banned after fewer requests, first for a while and then, for
|
banned after fewer requests, first for a while and then, for repeated abuse,
|
||||||
repeated abuse, permanently. The log line of each counted request, and the
|
permanently. The log line of each counted request, and the notes of each ban,
|
||||||
notes of each ban, say which setting lowered the limit.
|
say which setting lowered the limit.
|
||||||
- No budget is shared by a whole country or AS number. One abuser could use it
|
- No budget is shared by a whole country or AS number. One abuser could use it
|
||||||
up and lock out everyone else there: a denial of service nobody chose, and the
|
up and lock out everyone else there: a denial of service nobody chose, and the
|
||||||
one this tool exists to prevent. Refusing a whole country is left to the
|
one this tool exists to prevent. Refusing a whole country is left to the
|
||||||
operator's choice, through `SWW_TAG_DENY` or the country list.
|
operator's choice, through the country lists.
|
||||||
|
|
||||||
### Attack detection
|
### Attack detection
|
||||||
|
|
||||||
@@ -966,7 +907,7 @@ left unset sends no alert. An alert needs a destination only the operator can
|
|||||||
supply, and what counts as unusual depends on each service's normal traffic,
|
supply, and what counts as unusual depends on each service's normal traffic,
|
||||||
which the metrics show. They reveal a widely distributed scraper, thousands of
|
which the metrics show. They reveal a widely distributed scraper, thousands of
|
||||||
addresses at low rates each, which the per-client limits do not see. A low
|
addresses at low rates each, which the per-client limits do not see. A low
|
||||||
percentage for its `asn=` tag in `SWW_TAG_LIMIT_PERCENT` is the response.
|
`SWW_ASN_LIMIT_PERCENT` for its AS number is the response.
|
||||||
|
|
||||||
- Requests and body bytes, as `SWW_BYTES_COUNT` counts them, are counted per
|
- Requests and body bytes, as `SWW_BYTES_COUNT` counts them, are counted per
|
||||||
minute and per hour, for each of:
|
minute and per hour, for each of:
|
||||||
@@ -1245,7 +1186,7 @@ the metrics.
|
|||||||
|
|
||||||
### The answer
|
### The answer
|
||||||
|
|
||||||
`discriminator` runs steps 3 to 16 of "What happens to a request", in that
|
`discriminator` runs steps 3 to 13 of "What happens to a request", in that
|
||||||
order. Every answer but the last below carries `X-Sww-Disposition`.
|
order. Every answer but the last below carries `X-Sww-Disposition`.
|
||||||
|
|
||||||
- `X-Sww-Disposition: allow`, status `200`, no body. `rproxy` passes the request
|
- `X-Sww-Disposition: allow`, status `200`, no body. `rproxy` passes the request
|
||||||
@@ -1279,18 +1220,18 @@ 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 the longest a question
|
||||||
waits on an outside source is `SWW_LOOKUP_TIMEOUT`, by default 800 milliseconds,
|
waits on an outside source, `SWW_LOOKUP_TIMEOUT`, is by default well within that
|
||||||
well within that time.
|
time, whatever the source does.
|
||||||
|
|
||||||
GeoJS is the only outside source a question can wait on. A question waits only
|
GeoJS is the only outside source a question can wait on. A question waits only
|
||||||
when step 6 says so, and for at most `SWW_LOOKUP_TIMEOUT`, by default 800
|
when step 5 says so, and for at most `SWW_LOOKUP_TIMEOUT`, by default 800
|
||||||
milliseconds. A client without an answer by then counts as unknown. No question
|
milliseconds. A client without an answer by then counts as unknown. No question
|
||||||
waits on these, which run in the background:
|
waits on these, which run in the background:
|
||||||
|
|
||||||
- the DNS blocklists and AbuseIPDB;
|
- the DNS blocklists and AbuseIPDB;
|
||||||
- fetching the blocklists, the tag lists, the CrowdSec decision list and the
|
- fetching the blocklists, the CrowdSec decision list and the file
|
||||||
file `SWW_ASN_LIMIT_PERCENT_URL` names.
|
`SWW_ASN_LIMIT_PERCENT_URL` names.
|
||||||
|
|
||||||
In `enforce` mode, a question does not wait for GeoJS when it is a request:
|
In `enforce` mode, a question does not wait for GeoJS when it is a request:
|
||||||
|
|
||||||
@@ -1325,9 +1266,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+13
-20
@@ -96,15 +96,13 @@ 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`, or for a reputation source with the
|
||||||
the action `deny`, or for a tag in `SWW_TAG_DENY`;
|
action `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, or for a
|
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
||||||
request that made such a ban or made its client's ban permanent;
|
refused by that check;
|
||||||
- `country_denied`, `client_is_rate_limited`, `blocked_by_block_rule` and
|
- `too_large`: over a header limit;
|
||||||
`waf_blocked`: refused by that check;
|
|
||||||
- `request_header_too_large`: over a header limit;
|
|
||||||
- `admin`: answered at one of `discriminator`'s own endpoints, or `404` for
|
- `admin`: answered at one of `discriminator`'s own endpoints, or `404` for
|
||||||
a path under `/_sww/`.
|
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
|
||||||
@@ -117,7 +115,6 @@ Every one of these lines has:
|
|||||||
- `deny_nets`: `netblock`, the entry of `SWW_DENY_NETS` the client is in;
|
- `deny_nets`: `netblock`, the entry of `SWW_DENY_NETS` the client is in;
|
||||||
- `active_ban`: `netblock`, the ban's netblock, and `ban_expires`;
|
- `active_ban`: `netblock`, the ban's netblock, and `ban_expires`;
|
||||||
- `countries`: `country`, and `setting`, the country list that refused it;
|
- `countries`: `country`, and `setting`, the country list that refused it;
|
||||||
- `tag_deny`: `tag`, the tag of `SWW_TAG_DENY` the request carries;
|
|
||||||
- `blocklists`: `lists`, the blocklist URLs that list the client;
|
- `blocklists`: `lists`, the blocklist URLs that list the client;
|
||||||
- `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the
|
- `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the
|
||||||
decision ends;
|
decision ends;
|
||||||
@@ -147,22 +144,20 @@ Every one of these lines has:
|
|||||||
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;
|
||||||
- `error_burst`.
|
- `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.
|
||||||
@@ -173,8 +168,6 @@ Every one of these lines has:
|
|||||||
- the CrowdSec decision list's URL;
|
- the CrowdSec decision list's URL;
|
||||||
- DNS blocklist zones;
|
- DNS blocklist zones;
|
||||||
- `abuseipdb`.
|
- `abuseipdb`.
|
||||||
- `tags`: the request's tags, sorted, left out when it has none. Each tag and
|
|
||||||
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
|
|
||||||
- `offence`: `limit`, for a request that broke a limit.
|
- `offence`: `limit`, for a request that broke a limit.
|
||||||
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
||||||
refused under one: a time, or `permanent`.
|
refused under one: a time, or `permanent`.
|
||||||
|
|||||||
+25
-67
@@ -90,11 +90,11 @@ between the two (see "The image's command" below).
|
|||||||
## Static lists
|
## Static lists
|
||||||
|
|
||||||
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
|
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
|
||||||
(see step 4 in [`SPEC.md`](../SPEC.md)).
|
(see step 3 in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
||||||
exempt from rate limiting and the byte limits.
|
exempt from rate limiting and the byte limits.
|
||||||
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
|
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
|
||||||
4 in [`SPEC.md`](../SPEC.md)).
|
3 in [`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
## Request rate limits, per client
|
## Request rate limits, per client
|
||||||
|
|
||||||
@@ -122,32 +122,24 @@ 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
|
These count body bytes, never headers. A request's bytes are counted once its
|
||||||
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
|
response has ended. The request that takes a client's total past a limit is not
|
||||||
cut off; the client is banned.
|
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 body
|
||||||
counted for a client in each window, a size or `off`. `SWW_BYTES_COUNT` says
|
bytes a client's requests may total 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 and the
|
||||||
passes the limit is not cut off, and the client is banned when its response
|
anomaly thresholds on bytes count: `response`, the app's response bodies;
|
||||||
ends.
|
`request`, the visitors' request bodies; or `both`.
|
||||||
- `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
|
||||||
|
|
||||||
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
||||||
the start while a setting that needs a lookup is set:
|
the start while a setting that needs a lookup is set:
|
||||||
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
|
- the two country lists;
|
||||||
- `SWW_ADD_LOOKUP_HEADERS`;
|
- `SWW_ADD_LOOKUP_HEADERS`;
|
||||||
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming an `asn=` or `country=`
|
- the four biased threshold lists and `SWW_ASN_LIMIT_PERCENT_URL`;
|
||||||
tag;
|
|
||||||
- the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`;
|
|
||||||
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
|
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
|
||||||
- an anomaly threshold per AS number.
|
- an anomaly threshold per AS number.
|
||||||
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
||||||
@@ -159,61 +151,28 @@ cut off; the client is banned.
|
|||||||
the client's AS number in `X-Client-ASN` and its country in
|
the client's AS number in `X-Client-ASN` and its country in
|
||||||
`X-Client-Country`.
|
`X-Client-Country`.
|
||||||
|
|
||||||
## Country list
|
## Country lists
|
||||||
|
|
||||||
|
- `SWW_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
|
||||||
|
such as `cn,ru,kp`.
|
||||||
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
||||||
countries whose clients get through, such as `us,de`. A country whose
|
countries whose clients get through, such as `us,de`. A country on both lists
|
||||||
`country=` tag is in `SWW_TAG_DENY` stops the start.
|
stops the start.
|
||||||
|
|
||||||
## Request tags
|
|
||||||
|
|
||||||
Every request gets tags from what `discriminator` knows of its client. See
|
|
||||||
"Request tags" in [`SPEC.md`](../SPEC.md).
|
|
||||||
|
|
||||||
- `SWW_TAG_LISTS` (default empty): address lists that each add a tag, as
|
|
||||||
`name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the
|
|
||||||
tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or
|
|
||||||
`https` URL fetched like a blocklist. A name or URL listed twice is refused.
|
|
||||||
So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`,
|
|
||||||
or the CrowdSec decision list.
|
|
||||||
- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as
|
|
||||||
`torexit,country=CN`. A tag listed twice, an `asn=` tag without a number, or a
|
|
||||||
`country=` tag without a country, is refused.
|
|
||||||
- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as
|
|
||||||
tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from
|
|
||||||
`1` to `99`, of every rate limit and byte limit. The lowest percentage among a
|
|
||||||
client's tags applies. A tag listed twice is refused. So is
|
|
||||||
`exploitsignature`: a request gets that tag after the rate limits have counted
|
|
||||||
it.
|
|
||||||
|
|
||||||
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag
|
|
||||||
that no request can get stops the start. A request can get only these:
|
|
||||||
|
|
||||||
- a name in `SWW_TAG_LISTS`;
|
|
||||||
- an `asn=` or `country=` tag;
|
|
||||||
- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key
|
|
||||||
masked as the `reputation` field shows it;
|
|
||||||
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set;
|
|
||||||
- `exploitsignature`, in `SWW_TAG_DENY` only, while `SWW_RULES_ENABLED` is
|
|
||||||
`true`, or while neither `SWW_WAF_MODE` nor `SWW_WAF_ANOMALY_THRESHOLD` is
|
|
||||||
`off`.
|
|
||||||
|
|
||||||
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses
|
|
||||||
the request first.
|
|
||||||
|
|
||||||
## Biased thresholds
|
## Biased thresholds
|
||||||
|
|
||||||
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a
|
- `SWW_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:`
|
||||||
percentage of the byte limits alone, for the AS numbers or countries listed,
|
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
|
||||||
such as `AS14061:50` or `cn:25`. It takes the place of the percentage
|
listed twice is refused.
|
||||||
`SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or
|
- `SWW_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
|
||||||
country listed twice is refused.
|
`cn:25,ru:50`.
|
||||||
|
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): the
|
||||||
|
same, for the byte limits alone.
|
||||||
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
||||||
lookup cannot place.
|
lookup cannot place.
|
||||||
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
||||||
text file with one AS number and percentage on each line, such as
|
text file with one AS number and percentage on each line, such as
|
||||||
`AS64496:50`. Each entry counts like an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`.
|
`AS64496:50`. A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
|
||||||
A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
|
|
||||||
|
|
||||||
## Attack detection
|
## Attack detection
|
||||||
|
|
||||||
@@ -246,9 +205,8 @@ 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_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client in
|
||||||
in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
|
||||||
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
|
||||||
|
|
||||||
## Ban lengths, response and scope
|
## Ban lengths, response and scope
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -63,9 +63,8 @@ since it was first seen:
|
|||||||
- 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;
|
- body bytes of its requests, and of the responses it was sent;
|
||||||
- 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
|
||||||
`waf_blocked`. A file may also hold `token_refused`, which is kept, and which
|
may also hold `token_refused`, which is kept, and which nothing adds to.
|
||||||
nothing adds to.
|
|
||||||
|
|
||||||
## `lookups.json`
|
## `lookups.json`
|
||||||
|
|
||||||
|
|||||||
+57
-396
@@ -93,30 +93,29 @@ type Config struct {
|
|||||||
LookupDBPath string
|
LookupDBPath string
|
||||||
LookupTimeout time.Duration
|
LookupTimeout time.Duration
|
||||||
AddLookupHeaders bool
|
AddLookupHeaders bool
|
||||||
// ExclusivelyAllowedCountries, when not empty, are the only countries
|
// DeniedCountries are the countries whose clients are refused
|
||||||
// whose clients are let through (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES),
|
// (SWW_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not
|
||||||
// two-letter codes in capitals, as GeoJS gives them.
|
// empty, are the only countries whose clients are let through
|
||||||
|
// (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES). Both hold two-letter codes in
|
||||||
|
// capitals, as GeoJS gives them.
|
||||||
|
DeniedCountries []string
|
||||||
ExclusivelyAllowedCountries []string
|
ExclusivelyAllowedCountries []string
|
||||||
// TagLists are the address lists that each add a tag to the requests of
|
// The biased thresholds. ASNLimitPercent and CountryLimitPercent give
|
||||||
// the clients they hold (SWW_TAG_LISTS). TagDeny are the tags whose
|
// the clients of the AS numbers and the countries they list that
|
||||||
// requests are refused (SWW_TAG_DENY). TagLimitPercent gives the clients
|
// percentage of every rate limit and byte limit
|
||||||
// with a tag that percentage of every rate limit and byte limit, from 1
|
// (SWW_ASN_LIMIT_PERCENT and SWW_COUNTRY_LIMIT_PERCENT).
|
||||||
// to 99 (SWW_TAG_LIMIT_PERCENT). All hold tags as requests carry them: in
|
// ASNBytesPercent and CountryBytesPercent give those they list a
|
||||||
// lower case, a country code in capitals.
|
// percentage of the byte limits in place of that one
|
||||||
TagLists []TagList
|
|
||||||
TagDeny []string
|
|
||||||
TagLimitPercent map[string]int64
|
|
||||||
// The biased thresholds. ASNBytesPercent and CountryBytesPercent give
|
|
||||||
// those they list a percentage of the byte limits in place of the one
|
|
||||||
// TagLimitPercent gives their asn= or country= tag
|
|
||||||
// (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds
|
// (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds
|
||||||
// percentages from 0 to 100, by AS number, written as AS64496, or by
|
// percentages from 0 to 100, by AS number, written as AS64496, or by
|
||||||
// country, a two-letter code in capitals, as the lookup gives them.
|
// country, a two-letter code in capitals, as the lookup gives them.
|
||||||
// UnknownLimitPercent is the percentage of every limit a client without
|
// UnknownLimitPercent is the percentage of every limit a client without
|
||||||
// a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
|
// a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
|
||||||
// where a file of AS:percent lines is fetched from, whose percentages
|
// where a file of AS:percent lines is fetched from, whose percentages
|
||||||
// count like asn= tags in TagLimitPercent (SWW_ASN_LIMIT_PERCENT_URL),
|
// count as those of ASNLimitPercent do (SWW_ASN_LIMIT_PERCENT_URL), ""
|
||||||
// "" while it is unset.
|
// while it is unset.
|
||||||
|
ASNLimitPercent map[string]int64
|
||||||
|
CountryLimitPercent map[string]int64
|
||||||
ASNBytesPercent map[string]int64
|
ASNBytesPercent map[string]int64
|
||||||
CountryBytesPercent map[string]int64
|
CountryBytesPercent map[string]int64
|
||||||
UnknownLimitPercent int64
|
UnknownLimitPercent int64
|
||||||
@@ -165,8 +164,8 @@ type Config struct {
|
|||||||
// BanResponse is the status a refused client is answered with, 403
|
// BanResponse is the status a refused client is answered with, 403
|
||||||
// or 429, or 0 to close the connection without an answer
|
// or 429, or 0 to close the connection without an answer
|
||||||
// (SWW_BAN_RESPONSE). It answers a banned client, a request that
|
// (SWW_BAN_RESPONSE). It answers a banned client, a request that
|
||||||
// breaks a rate limit or matches a ban rule, SWW_DENY_NETS,
|
// breaks a rate limit or matches a ban rule, SWW_DENY_NETS and the
|
||||||
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY.
|
// country lists.
|
||||||
BanResponse int
|
BanResponse int
|
||||||
// LimitBanDuration is the ban for a first broken rate limit
|
// LimitBanDuration is the ban for a first broken rate limit
|
||||||
// (SWW_LIMIT_BAN_DURATION). A limit broken again within
|
// (SWW_LIMIT_BAN_DURATION). A limit broken again within
|
||||||
@@ -368,7 +367,7 @@ var (
|
|||||||
errHeaderTakenOut = errors.New(
|
errHeaderTakenOut = errors.New(
|
||||||
"is taken out of every request by Go's HTTP server, so it can never " +
|
"is taken out of every request by Go's HTTP server, so it can never " +
|
||||||
"be logged")
|
"be logged")
|
||||||
errOnBothLists = errors.New("is in SWW_TAG_DENY too")
|
errOnBothLists = errors.New("is in SWW_DENIED_COUNTRIES too")
|
||||||
errNotLookupSource = errors.New("is not geojs, file or off")
|
errNotLookupSource = errors.New("is not geojs, file or off")
|
||||||
errNeedsLookups = errors.New("it needs each client looked up")
|
errNeedsLookups = errors.New("it needs each client looked up")
|
||||||
errNeedsDBPath = errors.New("it names the file to look clients up in")
|
errNeedsDBPath = errors.New("it names the file to look clients up in")
|
||||||
@@ -398,21 +397,18 @@ var (
|
|||||||
errNotPathPrefix = errors.New(
|
errNotPathPrefix = errors.New(
|
||||||
"is not a path prefix starting with /, such as /assets/")
|
"is not a path prefix starting with /, such as /assets/")
|
||||||
errNotTrapPath = errors.New(
|
errNotTrapPath = errors.New(
|
||||||
"is not a path starting with / and without a ?, such as /wp-login.php",
|
"is not a path starting with / and without a ?, such as /wp-login.php")
|
||||||
)
|
|
||||||
errNotWAFMode = errors.New("is not off, detect or block")
|
errNotWAFMode = errors.New("is not off, detect or block")
|
||||||
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
||||||
errNotRuleID = errors.New(
|
errNotRuleID = errors.New(
|
||||||
"is not the id of a Core Rule Set rule, a whole number such as 942100",
|
"is not the id of a Core Rule Set rule, a whole number such as 942100")
|
||||||
)
|
|
||||||
errSetupRuleID = errors.New(
|
errSetupRuleID = errors.New(
|
||||||
"is from 900000 to 900999, the ids of the rules that set the Core Rule Set " +
|
"is from 900000 to 900999, the ids of the rules that set the Core Rule Set " +
|
||||||
"up and of sww's own, which cannot be switched off")
|
"up and of sww's own, which cannot be switched off")
|
||||||
errNotBoolean = errors.New("is not true or false")
|
errNotBoolean = errors.New("is not true or false")
|
||||||
errNotLogRemoteURL = errors.New(
|
errNotLogRemoteURL = errors.New(
|
||||||
"is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " +
|
"is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " +
|
||||||
"and nothing more, such as syslog+tls://logs.example:6514",
|
"and nothing more, such as syslog+tls://logs.example:6514")
|
||||||
)
|
|
||||||
errNoCertificate = errors.New("holds no PEM certificate")
|
errNoCertificate = errors.New("holds no PEM certificate")
|
||||||
errNotFacility = errors.New("is not a syslog facility such as local0 or daemon")
|
errNotFacility = errors.New("is not a syslog facility such as local0 or daemon")
|
||||||
errNotAppName = errors.New(
|
errNotAppName = errors.New(
|
||||||
@@ -425,21 +421,16 @@ var (
|
|||||||
"is not a header name followed by : and the header's value, " +
|
"is not a header name followed by : and the header's value, " +
|
||||||
"such as Authorization:Bearer <token>")
|
"such as Authorization:Bearer <token>")
|
||||||
errControlCharacter = errors.New(
|
errControlCharacter = errors.New(
|
||||||
"holds a control character, such as the carriage return of a Windows line end",
|
"holds a control character, such as the carriage return of a Windows line end")
|
||||||
)
|
|
||||||
errNotAlertEvent = errors.New(
|
errNotAlertEvent = errors.New(
|
||||||
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
|
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
|
||||||
"source_failure or file_error")
|
"source_failure or file_error")
|
||||||
errNotNumberOrOff = errors.New(
|
errNotNumberOrOff = errors.New("is not a whole number above zero, such as 60, or off")
|
||||||
"is not a whole number above zero, such as 60, or off",
|
|
||||||
)
|
|
||||||
errNotUTF8 = errors.New("is not valid UTF-8")
|
errNotUTF8 = errors.New("is not valid UTF-8")
|
||||||
errNotASN = errors.New("is not an AS number such as AS64496")
|
errNotASN = errors.New("is not an AS number such as AS64496")
|
||||||
errNotPercentItem = errors.New(
|
errNotPercentItem = errors.New(
|
||||||
"is not a code, : and a percentage, such as AS64496:50 or cn:25")
|
"is not a code, : and a percentage, such as AS64496:50 or cn:25")
|
||||||
errNotPercent = errors.New(
|
errNotPercent = errors.New("is not a percentage, a whole number from 0 to 100")
|
||||||
"is not a percentage, a whole number from 0 to 100",
|
|
||||||
)
|
|
||||||
errListedTwice = errors.New("is listed twice")
|
errListedTwice = errors.New("is listed twice")
|
||||||
errNotListURL = errors.New(
|
errNotListURL = errors.New(
|
||||||
"is not an http or https URL without a user or a fragment, " +
|
"is not an http or https URL without a user or a fragment, " +
|
||||||
@@ -448,17 +439,11 @@ var (
|
|||||||
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
|
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
|
||||||
errNotAction = errors.New(
|
errNotAction = errors.New(
|
||||||
"is not deny, limit:<percent> such as limit:25, or log")
|
"is not deny, limit:<percent> such as limit:25, or log")
|
||||||
errNotZone = errors.New(
|
errNotZone = errors.New("is not a DNS zone such as dnsbl.dronebl.org")
|
||||||
"is not a DNS zone such as dnsbl.dronebl.org",
|
errZoneTooLong = errors.New("is longer than 189 characters, too long for the " +
|
||||||
)
|
"names IPv6 clients are asked about by")
|
||||||
errZoneTooLong = errors.New(
|
errNotResolver = errors.New("is not an IP address with an optional port, " +
|
||||||
"is longer than 189 characters, too long for the " +
|
"such as 192.0.2.53 or [2001:db8::53]:5353")
|
||||||
"names IPv6 clients are asked about by",
|
|
||||||
)
|
|
||||||
errNotResolver = errors.New(
|
|
||||||
"is not an IP address with an optional port, " +
|
|
||||||
"such as 192.0.2.53 or [2001:db8::53]:5353",
|
|
||||||
)
|
|
||||||
errNotLAPIURL = errors.New(
|
errNotLAPIURL = errors.New(
|
||||||
"is not an http or https URL without a user or a fragment, " +
|
"is not an http or https URL without a user or a fragment, " +
|
||||||
"such as http://172.17.0.1:8080")
|
"such as http://172.17.0.1:8080")
|
||||||
@@ -466,25 +451,6 @@ var (
|
|||||||
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
|
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
|
||||||
errAnotherList = errors.New(
|
errAnotherList = errors.New(
|
||||||
"is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too")
|
"is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too")
|
||||||
errFetchedOtherwise = errors.New("is in SWW_BLOCKLIST_URLS, is " +
|
|
||||||
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too")
|
|
||||||
errNotTagList = errors.New(
|
|
||||||
"is not a name, = and an http or https URL, such as " +
|
|
||||||
"torexit=https://example.com/tor.txt")
|
|
||||||
errNotTagPercentItem = errors.New(
|
|
||||||
"is not a tag, : and a percentage, such as vpn:50 or asn=14061:25")
|
|
||||||
errNotTagPercent = errors.New("is not a percentage from 1 to 99")
|
|
||||||
errNotASNTag = errors.New("is not asn= and a number, such as asn=14061")
|
|
||||||
errExploitTag = errors.New(
|
|
||||||
"cannot lower limits: the rate limits count a request before its " +
|
|
||||||
"rules are matched")
|
|
||||||
errCrowdSecTag = errors.New(
|
|
||||||
"cannot be used: a CrowdSec decision refuses the request first")
|
|
||||||
errNoRequestGetsTag = errors.New(
|
|
||||||
"is a tag no request can get: it is not a name in SWW_TAG_LISTS, an " +
|
|
||||||
"asn= or country= tag, a dnsbl= tag of a zone in SWW_DNSBL_ZONES, " +
|
|
||||||
"abuseipdb with SWW_ABUSEIPDB_KEY set, or exploitsignature with " +
|
|
||||||
"rule files or the Core Rule Set on")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// FromEnvironment reads the settings with lookupEnv, normally
|
// FromEnvironment reads the settings with lookupEnv, normally
|
||||||
@@ -494,9 +460,7 @@ var (
|
|||||||
// setting that is set but invalid is an error that names it.
|
// setting that is set but invalid is an error that names it.
|
||||||
//
|
//
|
||||||
//nolint:funlen // one line for each setting, a list that grows with them
|
//nolint:funlen // one line for each setting, a list that grows with them
|
||||||
func FromEnvironment(
|
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||||
lookupEnv func(string) (string, bool),
|
|
||||||
) (*Config, error) {
|
|
||||||
env := &environment{lookupEnv: lookupEnv}
|
env := &environment{lookupEnv: lookupEnv}
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ListenAddr: env.address("SWW_LISTEN_ADDR"),
|
ListenAddr: env.address("SWW_LISTEN_ADDR"),
|
||||||
@@ -519,11 +483,11 @@ func FromEnvironment(
|
|||||||
LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""),
|
LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""),
|
||||||
LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"),
|
LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"),
|
||||||
AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"),
|
AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"),
|
||||||
|
DeniedCountries: env.countries("SWW_DENIED_COUNTRIES", ""),
|
||||||
ExclusivelyAllowedCountries: env.countries(
|
ExclusivelyAllowedCountries: env.countries(
|
||||||
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
|
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
|
||||||
TagLists: env.tagLists("SWW_TAG_LISTS"),
|
ASNLimitPercent: env.percents("SWW_ASN_LIMIT_PERCENT", ParseASN),
|
||||||
TagDeny: env.tags("SWW_TAG_DENY"),
|
CountryLimitPercent: env.percents("SWW_COUNTRY_LIMIT_PERCENT", parseCountry),
|
||||||
TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"),
|
|
||||||
ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN),
|
ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN),
|
||||||
CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry),
|
CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry),
|
||||||
UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"),
|
UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"),
|
||||||
@@ -603,8 +567,6 @@ func FromEnvironment(
|
|||||||
env.checkCountriesAndLookups(cfg)
|
env.checkCountriesAndLookups(cfg)
|
||||||
env.checkASNLimitPercentURL(cfg)
|
env.checkASNLimitPercentURL(cfg)
|
||||||
env.checkCrowdSec(cfg)
|
env.checkCrowdSec(cfg)
|
||||||
env.checkTagLists(cfg)
|
|
||||||
env.checkTags(cfg)
|
|
||||||
|
|
||||||
if env.err != nil {
|
if env.err != nil {
|
||||||
return nil, env.err
|
return nil, env.err
|
||||||
@@ -696,10 +658,7 @@ func (e *environment) lookup(name string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if set {
|
if set {
|
||||||
e.check(
|
e.check(name, fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice))
|
||||||
name,
|
|
||||||
fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice),
|
|
||||||
)
|
|
||||||
|
|
||||||
return value, set
|
return value, set
|
||||||
}
|
}
|
||||||
@@ -882,31 +841,6 @@ func (e *environment) percents(
|
|||||||
return percents
|
return percents
|
||||||
}
|
}
|
||||||
|
|
||||||
// tagLists reads SWW_TAG_LISTS, which is empty by default.
|
|
||||||
func (e *environment) tagLists(name string) []TagList {
|
|
||||||
lists, err := parseTagLists(e.value(name, ""))
|
|
||||||
e.check(name, err)
|
|
||||||
|
|
||||||
return lists
|
|
||||||
}
|
|
||||||
|
|
||||||
// tags reads a setting that is a list of tags. It is empty by default.
|
|
||||||
func (e *environment) tags(name string) []string {
|
|
||||||
tags, err := parseTags(e.value(name, ""))
|
|
||||||
e.check(name, err)
|
|
||||||
|
|
||||||
return tags
|
|
||||||
}
|
|
||||||
|
|
||||||
// tagPercents reads a setting that is a list of tags, each with a
|
|
||||||
// percentage. It is empty by default.
|
|
||||||
func (e *environment) tagPercents(name string) map[string]int64 {
|
|
||||||
percents, err := parseTagPercents(e.value(name, ""))
|
|
||||||
e.check(name, err)
|
|
||||||
|
|
||||||
return percents
|
|
||||||
}
|
|
||||||
|
|
||||||
// percent reads a setting that is a percentage, from 0 to 100.
|
// percent reads a setting that is a percentage, from 0 to 100.
|
||||||
func (e *environment) percent(name, defaultValue string) int64 {
|
func (e *environment) percent(name, defaultValue string) int64 {
|
||||||
percent, err := ParsePercent(e.value(name, defaultValue))
|
percent, err := ParsePercent(e.value(name, defaultValue))
|
||||||
@@ -982,10 +916,7 @@ func (e *environment) zones(name string) []string {
|
|||||||
logged[i] = MaskZoneKey(zone)
|
logged[i] = MaskZoneKey(zone)
|
||||||
}
|
}
|
||||||
|
|
||||||
e.settings = append(
|
e.settings = append(e.settings, slog.String(name, strings.Join(logged, ",")))
|
||||||
e.settings,
|
|
||||||
slog.String(name, strings.Join(logged, ",")),
|
|
||||||
)
|
|
||||||
|
|
||||||
return zones
|
return zones
|
||||||
}
|
}
|
||||||
@@ -1044,17 +975,15 @@ func (e *environment) checkLookupDBPath(cfg *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkCountriesAndLookups refuses a country in
|
// checkCountriesAndLookups refuses a country on both country lists, and,
|
||||||
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES whose tag is in SWW_TAG_DENY, and,
|
|
||||||
// while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked
|
// while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked
|
||||||
// up: the country list, SWW_ADD_LOOKUP_HEADERS, the tag settings that name
|
// up: the country lists, SWW_ADD_LOOKUP_HEADERS, the biased thresholds,
|
||||||
// an asn= or country= tag, the biased thresholds,
|
|
||||||
// SWW_ASN_LIMIT_PERCENT_URL among them, of which
|
// SWW_ASN_LIMIT_PERCENT_URL among them, of which
|
||||||
// SWW_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
|
// SWW_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
|
||||||
// limit, and the anomaly thresholds per AS number.
|
// limit, and the anomaly thresholds per AS number.
|
||||||
func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
||||||
for _, country := range cfg.ExclusivelyAllowedCountries {
|
for _, country := range cfg.ExclusivelyAllowedCountries {
|
||||||
if slices.Contains(cfg.TagDeny, CountryTagPrefix+country) {
|
if slices.Contains(cfg.DeniedCountries, country) {
|
||||||
e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
||||||
fmt.Errorf("%q %w", country, errOnBothLists))
|
fmt.Errorf("%q %w", country, errOnBothLists))
|
||||||
}
|
}
|
||||||
@@ -1068,10 +997,11 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
|||||||
name string
|
name string
|
||||||
set bool
|
set bool
|
||||||
}{
|
}{
|
||||||
|
{"SWW_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0},
|
||||||
{"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
|
{"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
|
||||||
{"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
|
{"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
|
||||||
{"SWW_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, isLookupTag)},
|
{"SWW_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0},
|
||||||
{"SWW_TAG_LIMIT_PERCENT", tagLimitNeedsLookups(cfg)},
|
{"SWW_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0},
|
||||||
{"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
|
{"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
|
||||||
{"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
|
{"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
|
||||||
{"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
|
{"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
|
||||||
@@ -1117,90 +1047,6 @@ func (e *environment) checkCrowdSec(cfg *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkTagLists refuses a list of SWW_TAG_LISTS whose URL is fetched as
|
|
||||||
// another list too: the file at a URL is read as one kind of list.
|
|
||||||
func (e *environment) checkTagLists(cfg *Config) {
|
|
||||||
for _, list := range cfg.TagLists {
|
|
||||||
if slices.Contains(cfg.BlocklistURLs, list.URL) ||
|
|
||||||
list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL {
|
|
||||||
e.check("SWW_TAG_LISTS", fmt.Errorf("%q %w", list.URL, errFetchedOtherwise))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkTags refuses a tag of SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT that would
|
|
||||||
// never apply: crowdsec, whose request is refused before tags are acted on, and
|
|
||||||
// any tag these settings cannot give a request.
|
|
||||||
func (e *environment) checkTags(cfg *Config) {
|
|
||||||
for _, tag := range cfg.TagDeny {
|
|
||||||
e.check("SWW_TAG_DENY", checkTag(cfg, tag))
|
|
||||||
}
|
|
||||||
|
|
||||||
for tag := range cfg.TagLimitPercent {
|
|
||||||
e.check("SWW_TAG_LIMIT_PERCENT", checkTag(cfg, tag))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkTag returns an error when tag would never apply, as checkTags says.
|
|
||||||
func checkTag(cfg *Config, tag string) error {
|
|
||||||
if tag == CrowdSecTag {
|
|
||||||
return fmt.Errorf("%q %w", tag, errCrowdSecTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !requestCanGetTag(cfg, tag) {
|
|
||||||
return fmt.Errorf("%q %w", MaskZoneKey(tag), errNoRequestGetsTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// requestCanGetTag reports whether anything cfg sets up can give a request tag.
|
|
||||||
func requestCanGetTag(cfg *Config, tag string) bool {
|
|
||||||
isListName := func(list TagList) bool { return list.Name == tag }
|
|
||||||
if isLookupTag(tag) || slices.ContainsFunc(cfg.TagLists, isListName) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if tag == ExploitSignatureTag {
|
|
||||||
coreRuleSetOn := cfg.WAFMode != WAFModeOff && cfg.WAFAnomalyThreshold != 0
|
|
||||||
|
|
||||||
return cfg.RulesEnabled || coreRuleSetOn
|
|
||||||
}
|
|
||||||
|
|
||||||
if tag == AbuseIPDBTag {
|
|
||||||
return cfg.AbuseIPDBKey != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
zone, isZoneTag := strings.CutPrefix(tag, DNSBLTagPrefix)
|
|
||||||
|
|
||||||
return isZoneTag && slices.ContainsFunc(cfg.DNSBLZones, func(each string) bool {
|
|
||||||
return strings.ToLower(MaskZoneKey(each)) == zone
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT
|
|
||||||
// names an asn= or country= tag, which only the lookup can give a request.
|
|
||||||
func (c *Config) TagsNeedLookup() bool {
|
|
||||||
return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
// tagLimitNeedsLookups reports whether SWW_TAG_LIMIT_PERCENT names an asn=
|
|
||||||
// or country= tag.
|
|
||||||
func tagLimitNeedsLookups(cfg *Config) bool {
|
|
||||||
for tag := range cfg.TagLimitPercent {
|
|
||||||
if isLookupTag(tag) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// isLookupTag reports whether tag is an asn= or country= tag.
|
|
||||||
func isLookupTag(tag string) bool {
|
|
||||||
return strings.HasPrefix(tag, ASNTagPrefix) || strings.HasPrefix(tag, CountryTagPrefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
// headerNames reads a setting that is a list of header names, and
|
// headerNames reads a setting that is a list of header names, and
|
||||||
// returns them in lower case.
|
// returns them in lower case.
|
||||||
func (e *environment) headerNames(name, defaultValue string) []string {
|
func (e *environment) headerNames(name, defaultValue string) []string {
|
||||||
@@ -1212,9 +1058,7 @@ func (e *environment) headerNames(name, defaultValue string) []string {
|
|||||||
|
|
||||||
// durationNotOff reads a setting that is a duration and, unlike a
|
// durationNotOff reads a setting that is a duration and, unlike a
|
||||||
// timeout, cannot be off.
|
// timeout, cannot be off.
|
||||||
func (e *environment) durationNotOff(
|
func (e *environment) durationNotOff(name, defaultValue string) time.Duration {
|
||||||
name, defaultValue string,
|
|
||||||
) time.Duration {
|
|
||||||
duration, err := ParseDurationNotOff(e.value(name, defaultValue))
|
duration, err := ParseDurationNotOff(e.value(name, defaultValue))
|
||||||
e.check(name, err)
|
e.check(name, err)
|
||||||
|
|
||||||
@@ -1413,10 +1257,7 @@ func (e *environment) instanceName() string {
|
|||||||
// appName reads the setting that is the APP-NAME of the records the log
|
// appName reads the setting that is the APP-NAME of the records the log
|
||||||
// lines are sent in, by default the instance name. Its value is checked
|
// lines are sent in, by default the instance name. Its value is checked
|
||||||
// when it is set, and, while lines are sent, when it is the instance name.
|
// when it is set, and, while lines are sent, when it is the instance name.
|
||||||
func (e *environment) appName(
|
func (e *environment) appName(name, instanceName string, sending bool) string {
|
||||||
name, instanceName string,
|
|
||||||
sending bool,
|
|
||||||
) string {
|
|
||||||
value, set := e.lookup(name)
|
value, set := e.lookup(name)
|
||||||
if !set {
|
if !set {
|
||||||
value = instanceName
|
value = instanceName
|
||||||
@@ -1439,10 +1280,7 @@ func (e *environment) appName(
|
|||||||
// checkInstanceNameForNtfy refuses an instance name that holds a control
|
// checkInstanceNameForNtfy refuses an instance name that holds a control
|
||||||
// character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent
|
// character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent
|
||||||
// the instance name in a header, which cannot hold one.
|
// the instance name in a header, which cannot hold one.
|
||||||
func (e *environment) checkInstanceNameForNtfy(
|
func (e *environment) checkInstanceNameForNtfy(instanceName string, ntfySet bool) {
|
||||||
instanceName string,
|
|
||||||
ntfySet bool,
|
|
||||||
) {
|
|
||||||
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
|
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
|
||||||
e.check("SWW_INSTANCE_NAME", fmt.Errorf(
|
e.check("SWW_INSTANCE_NAME", fmt.Errorf(
|
||||||
"%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set",
|
"%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set",
|
||||||
@@ -1766,10 +1604,7 @@ func parseNamedNetblocks(value string) ([]anomaly.NamedNetblock, error) {
|
|||||||
return nil, fmt.Errorf("%q %w", name, errListedTwice)
|
return nil, fmt.Errorf("%q %w", name, errListedTwice)
|
||||||
}
|
}
|
||||||
|
|
||||||
named = append(
|
named = append(named, anomaly.NamedNetblock{Name: name, Netblock: netblock})
|
||||||
named,
|
|
||||||
anomaly.NamedNetblock{Name: name, Netblock: netblock},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return named, nil
|
return named, nil
|
||||||
@@ -1917,161 +1752,6 @@ func ParseASN(value string) (string, error) {
|
|||||||
return "AS" + strconv.FormatUint(number, 10), nil
|
return "AS" + strconv.FormatUint(number, 10), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// The tags a request can get that the settings can name, as discriminator
|
|
||||||
// writes them. The prefixes are followed by a value.
|
|
||||||
const (
|
|
||||||
ASNTagPrefix = "asn="
|
|
||||||
CountryTagPrefix = "country="
|
|
||||||
DNSBLTagPrefix = "dnsbl="
|
|
||||||
AbuseIPDBTag = "abuseipdb"
|
|
||||||
CrowdSecTag = "crowdsec"
|
|
||||||
// ExploitSignatureTag is the tag of a request a rule file or the Core
|
|
||||||
// Rule Set matched.
|
|
||||||
ExploitSignatureTag = "exploitsignature"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TagList is an address list of SWW_TAG_LISTS: the tag it adds to the
|
|
||||||
// requests of the clients it holds, and the URL it is fetched from.
|
|
||||||
type TagList struct {
|
|
||||||
Name string
|
|
||||||
URL string
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTagLists reads a comma-separated list of name=URL pairs, such as
|
|
||||||
// torexit=https://example.com/tor.txt, and returns each list with its name
|
|
||||||
// in lower case. A name can hold a =, as vpn=mullvad does, and ends at the
|
|
||||||
// = before http. A name or a URL listed twice is an error.
|
|
||||||
func parseTagLists(value string) ([]TagList, error) {
|
|
||||||
items, err := parseList(value)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
lists := make([]TagList, 0, len(items))
|
|
||||||
|
|
||||||
for _, item := range items {
|
|
||||||
end := strings.Index(item, "=http")
|
|
||||||
if end <= 0 || !isListURL(item[end+1:]) {
|
|
||||||
return nil, fmt.Errorf("%q %w", item, errNotTagList)
|
|
||||||
}
|
|
||||||
|
|
||||||
list := TagList{Name: strings.ToLower(item[:end]), URL: item[end+1:]}
|
|
||||||
|
|
||||||
if slices.ContainsFunc(lists, func(earlier TagList) bool {
|
|
||||||
return earlier.Name == list.Name || earlier.URL == list.URL
|
|
||||||
}) {
|
|
||||||
return nil, fmt.Errorf("%q %w", item, errListedTwice)
|
|
||||||
}
|
|
||||||
|
|
||||||
lists = append(lists, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lists, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTags reads a comma-separated list of tags, which parseTag reads. A
|
|
||||||
// tag listed twice is an error, which shows the tag as MaskZoneKey does.
|
|
||||||
func parseTags(value string) ([]string, error) {
|
|
||||||
items, err := parseList(value)
|
|
||||||
if err != nil {
|
|
||||||
// parseList's error, for an empty item, shows the whole value, keys
|
|
||||||
// included.
|
|
||||||
return nil, errEmptyItem
|
|
||||||
}
|
|
||||||
|
|
||||||
tags := make([]string, 0, len(items))
|
|
||||||
|
|
||||||
for _, item := range items {
|
|
||||||
tag, err := parseTag(item)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if slices.Contains(tags, tag) {
|
|
||||||
return nil, fmt.Errorf("%q %w", MaskZoneKey(item), errListedTwice)
|
|
||||||
}
|
|
||||||
|
|
||||||
tags = append(tags, tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tags, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTag reads a tag in either case, with spaces around it, and returns it
|
|
||||||
// as a request carries it: in lower case, but for a country code, which is in
|
|
||||||
// capitals, and an asn= tag's number without leading zeros. An asn= or
|
|
||||||
// country= tag must name a number or a country, so that a mistyped one stops
|
|
||||||
// the start instead of never matching.
|
|
||||||
func parseTag(value string) (string, error) {
|
|
||||||
tag := strings.ToLower(strings.TrimSpace(value))
|
|
||||||
|
|
||||||
if digits, isASN := strings.CutPrefix(tag, ASNTagPrefix); isASN {
|
|
||||||
number, err := strconv.ParseUint(digits, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("%q %w", value, errNotASNTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ASNTagPrefix + strconv.FormatUint(number, 10), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if code, isCountry := strings.CutPrefix(tag, CountryTagPrefix); isCountry {
|
|
||||||
country, err := parseCountry(code)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return CountryTagPrefix + country, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return tag, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTagPercents reads a comma-separated list of tags, each with : and a
|
|
||||||
// percentage from 1 to 99, such as vpn:50 or asn=14061:25. A tag listed
|
|
||||||
// twice is an error, and so is ExploitSignatureTag, which a request gets
|
|
||||||
// only after the rate limits have counted it.
|
|
||||||
func parseTagPercents(value string) (map[string]int64, error) {
|
|
||||||
items, err := parseList(value)
|
|
||||||
if err != nil {
|
|
||||||
// parseList's error, for an empty item, shows the whole value, keys
|
|
||||||
// included.
|
|
||||||
return nil, errEmptyItem
|
|
||||||
}
|
|
||||||
|
|
||||||
percents := make(map[string]int64, len(items))
|
|
||||||
|
|
||||||
for _, item := range items {
|
|
||||||
colon := strings.LastIndex(item, ":")
|
|
||||||
if colon <= 0 {
|
|
||||||
return nil, fmt.Errorf("%q %w", MaskZoneKey(item), errNotTagPercentItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
tag, err := parseTag(item[:colon])
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
percent, err := strconv.ParseInt(item[colon+1:], 10, 64)
|
|
||||||
if err != nil || percent < 1 || percent > 99 {
|
|
||||||
return nil, fmt.Errorf("%q %w", MaskZoneKey(item[colon+1:]), errNotTagPercent)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tag == ExploitSignatureTag {
|
|
||||||
return nil, fmt.Errorf("%q %w", tag, errExploitTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, listed := percents[tag]; listed {
|
|
||||||
// tag, since item[:colon] may end in a space, which MaskZoneKey
|
|
||||||
// would not mask.
|
|
||||||
return nil, fmt.Errorf("%q %w", MaskZoneKey(tag), errListedTwice)
|
|
||||||
}
|
|
||||||
|
|
||||||
percents[tag] = percent
|
|
||||||
}
|
|
||||||
|
|
||||||
return percents, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parsePercents reads a comma-separated list of items, each an AS number
|
// parsePercents reads a comma-separated list of items, each an AS number
|
||||||
// or a country, which parseCode reads, then : and a percentage, such as
|
// or a country, which parseCode reads, then : and a percentage, such as
|
||||||
// AS64496:50 or cn:25, and returns each one's percentage. An empty value
|
// AS64496:50 or cn:25, and returns each one's percentage. An empty value
|
||||||
@@ -2163,11 +1843,8 @@ func parseHeaderNames(value string) ([]string, error) {
|
|||||||
header := strings.ToLower(item)
|
header := strings.ToLower(item)
|
||||||
switch header {
|
switch header {
|
||||||
case "host":
|
case "host":
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf("%q %w; the request's host is the field host",
|
||||||
"%q %w; the request's host is the field host",
|
item, errHeaderTakenOut)
|
||||||
item,
|
|
||||||
errHeaderTakenOut,
|
|
||||||
)
|
|
||||||
case "transfer-encoding":
|
case "transfer-encoding":
|
||||||
return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut)
|
return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut)
|
||||||
}
|
}
|
||||||
@@ -2204,11 +1881,9 @@ func parseUpstreamURL(value string) (*url.URL, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") &&
|
onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") &&
|
||||||
upstream.Hostname() != "" && upstream.User == nil &&
|
upstream.Hostname() != "" && upstream.User == nil && upstream.Opaque == "" &&
|
||||||
upstream.Opaque == "" &&
|
|
||||||
(upstream.Path == "" || upstream.Path == "/") &&
|
(upstream.Path == "" || upstream.Path == "/") &&
|
||||||
upstream.RawQuery == "" &&
|
upstream.RawQuery == "" && upstream.Fragment == ""
|
||||||
upstream.Fragment == ""
|
|
||||||
if !onlySchemeAndHost {
|
if !onlySchemeAndHost {
|
||||||
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
|
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
|
||||||
}
|
}
|
||||||
@@ -2232,11 +1907,7 @@ func parseLogRemoteURL(value string) (*url.URL, error) {
|
|||||||
return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL)
|
return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
schemes := []string{
|
schemes := []string{remotelog.SchemeUDP, remotelog.SchemeTCP, remotelog.SchemeTLS}
|
||||||
remotelog.SchemeUDP,
|
|
||||||
remotelog.SchemeTCP,
|
|
||||||
remotelog.SchemeTLS,
|
|
||||||
}
|
|
||||||
port, err := strconv.ParseUint(remote.Port(), 10, 16)
|
port, err := strconv.ParseUint(remote.Port(), 10, 16)
|
||||||
|
|
||||||
onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) &&
|
onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) &&
|
||||||
@@ -2299,9 +1970,7 @@ func isHTTPURL(u *url.URL) bool {
|
|||||||
|
|
||||||
return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" &&
|
return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" &&
|
||||||
(u.Port() == "" || (err == nil && port != 0)) &&
|
(u.Port() == "" || (err == nil && port != 0)) &&
|
||||||
u.User == nil &&
|
u.User == nil && u.Opaque == "" && u.Fragment == ""
|
||||||
u.Opaque == "" &&
|
|
||||||
u.Fragment == ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isListURL reports whether value is a URL a list can be fetched from, as
|
// isListURL reports whether value is a URL a list can be fetched from, as
|
||||||
@@ -2368,12 +2037,9 @@ func parseZones(value string) ([]string, error) {
|
|||||||
|
|
||||||
for i, zone := range zones {
|
for i, zone := range zones {
|
||||||
shown := MaskZoneKey(zone)
|
shown := MaskZoneKey(zone)
|
||||||
listedBefore := slices.ContainsFunc(
|
listedBefore := slices.ContainsFunc(zones[:i], func(earlier string) bool {
|
||||||
zones[:i],
|
return strings.EqualFold(MaskZoneKey(earlier), shown)
|
||||||
func(earlier string) bool {
|
})
|
||||||
return strings.EqualFold(MaskZoneKey(earlier), shown)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case len(zone) > maxZoneLength:
|
case len(zone) > maxZoneLength:
|
||||||
@@ -2460,13 +2126,8 @@ func parseWebhookHeaders(value string) (http.Header, string, error) {
|
|||||||
name, headerValue, found := strings.Cut(item, ":")
|
name, headerValue, found := strings.Cut(item, ":")
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
|
|
||||||
if !found || !IsHeaderName(name) ||
|
if !found || !IsHeaderName(name) || strings.ContainsAny(headerValue, "\r\n\x00") {
|
||||||
strings.ContainsAny(headerValue, "\r\n\x00") {
|
return nil, "", fmt.Errorf("item %d %w", i+1, errNotWebhookHeader)
|
||||||
return nil, "", fmt.Errorf(
|
|
||||||
"item %d %w",
|
|
||||||
i+1,
|
|
||||||
errNotWebhookHeader,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.Add(name, strings.TrimSpace(headerValue))
|
headers.Add(name, strings.TrimSpace(headerValue))
|
||||||
|
|||||||
+53
-251
@@ -42,10 +42,10 @@ const (
|
|||||||
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||||
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
||||||
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||||
|
deniedCountries = "SWW_DENIED_COUNTRIES"
|
||||||
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
tagLists = "SWW_TAG_LISTS"
|
asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
|
||||||
tagDeny = "SWW_TAG_DENY"
|
countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
|
||||||
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
|
||||||
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||||
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||||
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||||
@@ -208,9 +208,6 @@ const off = "off"
|
|||||||
// enabled is true, as a setting's value.
|
// enabled is true, as a setting's value.
|
||||||
const enabled = "true"
|
const enabled = "true"
|
||||||
|
|
||||||
// disabled is false, as a setting's value.
|
|
||||||
const disabled = "false"
|
|
||||||
|
|
||||||
// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
|
// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
|
||||||
// fileSource the source that is the lookup database.
|
// fileSource the source that is the lookup database.
|
||||||
const (
|
const (
|
||||||
@@ -277,13 +274,9 @@ func TestDefaults(t *testing.T) {
|
|||||||
wantNetblocks(t, cfg.AllowNets)
|
wantNetblocks(t, cfg.AllowNets)
|
||||||
wantNetblocks(t, cfg.RateLimitExemptNets)
|
wantNetblocks(t, cfg.RateLimitExemptNets)
|
||||||
wantNetblocks(t, cfg.DenyNets)
|
wantNetblocks(t, cfg.DenyNets)
|
||||||
|
wantCountries(t, deniedCountries, cfg.DeniedCountries)
|
||||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
|
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
|
||||||
|
|
||||||
if len(cfg.TagLists) != 0 || len(cfg.TagDeny) != 0 || len(cfg.TagLimitPercent) != 0 {
|
|
||||||
t.Errorf("tag settings %v, %v and %v by default, want all empty",
|
|
||||||
cfg.TagLists, cfg.TagDeny, cfg.TagLimitPercent)
|
|
||||||
}
|
|
||||||
|
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
if err != nil || hostname == "" || cfg.InstanceName != hostname {
|
if err != nil || hostname == "" || cfg.InstanceName != hostname {
|
||||||
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
|
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
|
||||||
@@ -334,6 +327,7 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
rateLimitPerMinute: "60",
|
rateLimitPerMinute: "60",
|
||||||
rateLimitPerHour: "600",
|
rateLimitPerHour: "600",
|
||||||
rateLimitPerDay: "6000",
|
rateLimitPerDay: "6000",
|
||||||
|
deniedCountries: "cn, RU,kp,Xk",
|
||||||
allowedCountries: "de",
|
allowedCountries: "de",
|
||||||
banResponse: "429",
|
banResponse: "429",
|
||||||
limitBanDuration: "15m",
|
limitBanDuration: "15m",
|
||||||
@@ -348,7 +342,7 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
metricsTopN: "10",
|
metricsTopN: "10",
|
||||||
rulesDir: "/srv/waf-rules",
|
rulesDir: "/srv/waf-rules",
|
||||||
rulesEnabled: disabled,
|
rulesEnabled: "false",
|
||||||
})
|
})
|
||||||
|
|
||||||
wantSettings(t, cfg, config.Config{
|
wantSettings(t, cfg, config.Config{
|
||||||
@@ -376,6 +370,7 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
|
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
|
||||||
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
|
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
|
||||||
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
|
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
|
||||||
|
wantCountries(t, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK")
|
||||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1102,15 +1097,15 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) {
|
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
tagDeny: "country=cn,country=ru",
|
deniedCountries: "cn,ru",
|
||||||
allowedCountries: "de,RU",
|
allowedCountries: "de,RU",
|
||||||
}.lookupEnv)
|
}.lookupEnv)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("ru in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY was accepted")
|
t.Fatal("ru on both country lists was accepted")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
|
if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
|
||||||
@@ -1181,11 +1176,12 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for name, value := range map[string]string{
|
for name, value := range map[string]string{
|
||||||
|
deniedCountries: "kp",
|
||||||
allowedCountries: "de",
|
allowedCountries: "de",
|
||||||
addLookupHeaders: enabled,
|
addLookupHeaders: enabled,
|
||||||
tagDeny: "country=KP",
|
|
||||||
tagLimitPercent: "asn=64496:50",
|
|
||||||
asnLimitPercentURL: asnURL,
|
asnLimitPercentURL: asnURL,
|
||||||
|
asnLimitPercent: "AS64496:50",
|
||||||
|
countryLimitPercent: "cn:25",
|
||||||
asnBytesPercent: "AS64496:50",
|
asnBytesPercent: "AS64496:50",
|
||||||
countryBytesPercent: "cn:25",
|
countryBytesPercent: "cn:25",
|
||||||
unknownLimitPercent: "99",
|
unknownLimitPercent: "99",
|
||||||
@@ -1208,13 +1204,12 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set empty, the lists need nothing looked up, and nor does
|
// Set empty, the lists need nothing looked up, and nor does
|
||||||
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the
|
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
|
||||||
// lookup does not give, an anomaly threshold per AS number that is off,
|
// threshold per AS number that is off, any other anomaly threshold, or
|
||||||
// any other anomaly threshold, or a blocklist.
|
// a blocklist.
|
||||||
env := environment{
|
env := environment{
|
||||||
lookupSource: off, allowedCountries: "", asnBytesPercent: "",
|
lookupSource: off, deniedCountries: "", allowedCountries: "",
|
||||||
tagLists: "torexit=https://example.com/tor.txt,vpn=https://example.com/vpn.txt",
|
asnLimitPercent: "", countryLimitPercent: "", asnBytesPercent: "",
|
||||||
tagDeny: torExitTag, tagLimitPercent: "vpn:50",
|
|
||||||
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
|
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
|
||||||
blocklistURLs: dropURL,
|
blocklistURLs: dropURL,
|
||||||
}
|
}
|
||||||
@@ -1360,194 +1355,34 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// torExitTag, zenTag and exploitTag are tags these tests name.
|
|
||||||
const (
|
|
||||||
torExitTag = "torexit"
|
|
||||||
zenTag = "dnsbl=zen.example"
|
|
||||||
exploitTag = "exploitsignature"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTagSettingsAsSet(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const torURL = "https://example.com/tor.txt"
|
|
||||||
|
|
||||||
// Names and tags in either case, an AS number with leading zeros, and a
|
|
||||||
// name holding a =.
|
|
||||||
cfg := fromEnvironment(t, environment{
|
|
||||||
tagLists: "TorExit=" + torURL +
|
|
||||||
", vpn=mullvad=https://example.com/mullvad.txt",
|
|
||||||
dnsblZones: "Zen.example",
|
|
||||||
tagDeny: "TorExit, Country=kp, asn=014061",
|
|
||||||
tagLimitPercent: "vpn=mullvad :50, asn=014061:25, COUNTRY=cn:1, dnsbl=zen.example:99",
|
|
||||||
})
|
|
||||||
|
|
||||||
wantLists := []config.TagList{
|
|
||||||
{Name: torExitTag, URL: torURL},
|
|
||||||
{Name: "vpn=mullvad", URL: "https://example.com/mullvad.txt"},
|
|
||||||
}
|
|
||||||
if !slices.Equal(cfg.TagLists, wantLists) {
|
|
||||||
t.Errorf("%s gave %+v, want %+v", tagLists, cfg.TagLists, wantLists)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantDeny := []string{torExitTag, "country=KP", "asn=14061"}
|
|
||||||
if !slices.Equal(cfg.TagDeny, wantDeny) {
|
|
||||||
t.Errorf("%s gave %v, want %v", tagDeny, cfg.TagDeny, wantDeny)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantPercents := map[string]int64{
|
|
||||||
"vpn=mullvad": 50, "asn=14061": 25, "country=CN": 1, zenTag: 99,
|
|
||||||
}
|
|
||||||
if !maps.Equal(cfg.TagLimitPercent, wantPercents) {
|
|
||||||
t.Errorf("%s gave %v, want %v", tagLimitPercent, cfg.TagLimitPercent, wantPercents)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
list = "torexit=https://example.com/tor.txt"
|
|
||||||
key = "abc123"
|
|
||||||
zone = key + ".xbl.dq.spamhaus.net"
|
|
||||||
masked = "********.xbl.dq.spamhaus.net"
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
env environment
|
|
||||||
want string // "" is accepted
|
|
||||||
}{
|
|
||||||
{"a list's name", environment{tagLists: list, tagDeny: torExitTag}, ""},
|
|
||||||
{
|
|
||||||
"a mistyped list's name", environment{tagLists: list, tagDeny: "torexti"},
|
|
||||||
`"torexti"`,
|
|
||||||
},
|
|
||||||
{"a masked zone", environment{dnsblZones: zone, tagDeny: "dnsbl=" + masked}, ""},
|
|
||||||
{
|
|
||||||
"a zone with its key", environment{dnsblZones: zone, tagDeny: "dnsbl=" + zone},
|
|
||||||
masked,
|
|
||||||
},
|
|
||||||
{"another zone", environment{dnsblZones: zone, tagDeny: zenTag}, zenTag},
|
|
||||||
{
|
|
||||||
"abuseipdb with a key",
|
|
||||||
environment{abuseIPDBKey: "k", tagLimitPercent: "abuseipdb:50"}, "",
|
|
||||||
},
|
|
||||||
{"crowdsec, though a list has its name", environment{
|
|
||||||
tagLists: "crowdsec=https://example.com/c.txt", tagDeny: "crowdsec",
|
|
||||||
}, `"crowdsec" cannot be used`},
|
|
||||||
{exploitTag, environment{tagDeny: exploitTag}, ""},
|
|
||||||
{
|
|
||||||
exploitTag + " with rule files off",
|
|
||||||
environment{tagDeny: exploitTag, rulesEnabled: disabled}, "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
exploitTag + " with the Core Rule Set off",
|
|
||||||
environment{tagDeny: exploitTag, wafMode: off}, "",
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case tc.want == "" && err != nil:
|
|
||||||
t.Errorf("error %v, want none", err)
|
|
||||||
case tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)):
|
|
||||||
t.Errorf("error %v, want one holding %s", err, tc.want)
|
|
||||||
case err != nil && strings.Contains(err.Error(), key):
|
|
||||||
t.Errorf("error %v shows the zone's key", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorOfATagSettingLeavesOutAZoneKey(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
tag = "dnsbl=" + spamhaus
|
|
||||||
emptyItem = "has an empty item in its list"
|
|
||||||
twice = `"` + spamhausMasked + `" is listed twice`
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, tc := range []struct{ name, value, want string }{
|
|
||||||
{tagDeny, tag + "," + tag, twice},
|
|
||||||
{tagDeny, tag + ",,", emptyItem},
|
|
||||||
{
|
|
||||||
tagLimitPercent, tag,
|
|
||||||
`"` + spamhausMasked + `" is not a tag, : and a percentage, ` +
|
|
||||||
"such as vpn:50 or asn=14061:25",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tagLimitPercent, "50:" + tag,
|
|
||||||
`"` + spamhausMasked + `" is not a percentage from 1 to 99`,
|
|
||||||
},
|
|
||||||
{tagLimitPercent, tag + ":50," + tag + " :25", twice},
|
|
||||||
{tagLimitPercent, tag + ":50,,", emptyItem},
|
|
||||||
} {
|
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
|
||||||
if err == nil || err.Error() != want {
|
|
||||||
t.Errorf("%s=%s gave the error %v, want %s", tc.name, tc.value, err, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const lapi = "http://172.17.0.1:8080"
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name, listURL string
|
|
||||||
env environment
|
|
||||||
}{
|
|
||||||
{"a blocklist", dropURL, environment{blocklistURLs: dropURL}},
|
|
||||||
{"the file of AS:percent lines", asnURL, environment{asnLimitPercentURL: asnURL}},
|
|
||||||
{
|
|
||||||
"the CrowdSec decision list", lapi + "/v1/decisions",
|
|
||||||
environment{"SWW_CROWDSEC_LAPI_URL": lapi, "SWW_CROWDSEC_LAPI_KEY": "key"},
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tc.env[tagLists] = "dup=" + tc.listURL
|
|
||||||
|
|
||||||
want := tagLists + `: "` + tc.listURL + `" is in SWW_BLOCKLIST_URLS, is ` +
|
|
||||||
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too"
|
|
||||||
|
|
||||||
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
|
||||||
if err == nil || err.Error() != want {
|
|
||||||
t.Errorf("error %v, want %s", err, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBiasedThresholdsAsSet(t *testing.T) {
|
func TestBiasedThresholdsAsSet(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := fromEnvironment(t, environment{})
|
cfg := fromEnvironment(t, environment{})
|
||||||
if len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
|
if len(cfg.ASNLimitPercent) != 0 || len(cfg.CountryLimitPercent) != 0 ||
|
||||||
|
len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
|
||||||
cfg.UnknownLimitPercent != 100 {
|
cfg.UnknownLimitPercent != 100 {
|
||||||
t.Errorf("biased thresholds %v, %v and %d by default, "+
|
t.Errorf("biased thresholds %v, %v, %v, %v and %d by default, "+
|
||||||
"want two empty lists and 100",
|
"want four empty lists and 100", cfg.ASNLimitPercent, cfg.CountryLimitPercent,
|
||||||
cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent)
|
cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AS numbers and countries in either case, an AS number with leading
|
// AS numbers and countries in either case, an AS number with leading
|
||||||
// zeros, 0 and 100.
|
// zeros, 0 and 100.
|
||||||
cfg = fromEnvironment(t, environment{
|
cfg = fromEnvironment(t, environment{
|
||||||
|
asnLimitPercent: "AS14061:50, as16276:0,AS045102:100",
|
||||||
|
countryLimitPercent: "cn:25,RU:50",
|
||||||
asnBytesPercent: "as16276:75",
|
asnBytesPercent: "as16276:75",
|
||||||
countryBytesPercent: "ru:10",
|
countryBytesPercent: "ru:10",
|
||||||
unknownLimitPercent: "0",
|
unknownLimitPercent: "0",
|
||||||
})
|
})
|
||||||
|
|
||||||
for name, tc := range map[string]struct{ got, want map[string]int64 }{
|
for name, tc := range map[string]struct{ got, want map[string]int64 }{
|
||||||
|
asnLimitPercent: {
|
||||||
|
cfg.ASNLimitPercent,
|
||||||
|
map[string]int64{"AS14061": 50, "AS16276": 0, "AS45102": 100},
|
||||||
|
},
|
||||||
|
countryLimitPercent: {cfg.CountryLimitPercent, map[string]int64{"CN": 25, "RU": 50}},
|
||||||
asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}},
|
asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}},
|
||||||
countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}},
|
countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}},
|
||||||
} {
|
} {
|
||||||
@@ -1571,16 +1406,16 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct{ name, value, want string }{
|
for _, tc := range []struct{ name, value, want string }{
|
||||||
{asnBytesPercent, "14061:50", `"14061"` + notASN},
|
{asnLimitPercent, "14061:50", `"14061"` + notASN},
|
||||||
{asnBytesPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
|
{asnLimitPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
|
||||||
{asnBytesPercent, "AS14061", `"AS14061"` + notItem},
|
{asnLimitPercent, "AS14061", `"AS14061"` + notItem},
|
||||||
{asnBytesPercent, "AS14061:101", `"101"` + notPercent},
|
{asnLimitPercent, "AS14061:101", `"101"` + notPercent},
|
||||||
{asnBytesPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
|
{asnLimitPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
|
||||||
{
|
{
|
||||||
countryBytesPercent, "nk:25",
|
countryLimitPercent, "nk:25",
|
||||||
`"nk" is not a two-letter country code such as de or kp`,
|
`"nk" is not a two-letter country code such as de or kp`,
|
||||||
},
|
},
|
||||||
{countryBytesPercent, "cn:25,CN:50", `"CN" is listed twice`},
|
{countryLimitPercent, "cn:25,CN:50", `"CN" is listed twice`},
|
||||||
{asnBytesPercent, "AS14061:-1", `"-1"` + notPercent},
|
{asnBytesPercent, "AS14061:-1", `"-1"` + notPercent},
|
||||||
{countryBytesPercent, "cn:50%", `"50%"` + notPercent},
|
{countryBytesPercent, "cn:50%", `"50%"` + notPercent},
|
||||||
{unknownLimitPercent, "101", `"101"` + notPercent},
|
{unknownLimitPercent, "101", `"101"` + notPercent},
|
||||||
@@ -2168,37 +2003,23 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
|||||||
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
|
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
|
||||||
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
|
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
|
||||||
{addLookupHeaders, "yes"},
|
{addLookupHeaders, "yes"},
|
||||||
{allowedCountries, "nk"},
|
{deniedCountries, "nk"},
|
||||||
{allowedCountries, "kp,,ir"},
|
{deniedCountries, "kp,,ir"},
|
||||||
{allowedCountries, "prk"},
|
{deniedCountries, "prk"},
|
||||||
{allowedCountries, "408"},
|
{deniedCountries, "408"},
|
||||||
{allowedCountries, "k"},
|
{deniedCountries, "k"},
|
||||||
{allowedCountries, "eu"},
|
{deniedCountries, "eu"},
|
||||||
{allowedCountries, "un"},
|
{deniedCountries, "un"},
|
||||||
{allowedCountries, "su"},
|
{deniedCountries, "su"},
|
||||||
{allowedCountries, "ac"},
|
{allowedCountries, "ac"},
|
||||||
{allowedCountries, "uk"},
|
{allowedCountries, "uk"},
|
||||||
{allowedCountries, "zz"},
|
{allowedCountries, "zz"},
|
||||||
{allowedCountries, "de,germany"},
|
{allowedCountries, "de,germany"},
|
||||||
{asnBytesPercent, "AS14061:50,,AS16276:50"}, {asnBytesPercent, "ASX:50"},
|
{asnLimitPercent, "AS14061:50,,AS16276:50"}, {asnLimitPercent, "ASX:50"},
|
||||||
{asnBytesPercent, "AS14061:"}, {asnBytesPercent, "AS14061 :50"},
|
{asnLimitPercent, "AS14061:"}, {asnLimitPercent, "AS14061 :50"},
|
||||||
{asnBytesPercent, "AS14061:1.5"}, {asnBytesPercent, "AS-1:50"},
|
{asnLimitPercent, "AS14061:1.5"}, {asnLimitPercent, "AS-1:50"},
|
||||||
{countryBytesPercent, "cn"}, {countryBytesPercent, "cn:"},
|
{countryLimitPercent, "cn"}, {countryLimitPercent, "cn:"},
|
||||||
{countryBytesPercent, "cn:25:50"}, {countryBytesPercent, "china:25"},
|
{countryLimitPercent, "cn:25:50"}, {countryLimitPercent, "china:25"},
|
||||||
{tagDeny, "a,,b"}, {tagDeny, "country=nk"}, {tagDeny, "asn=abc"},
|
|
||||||
{tagDeny, "asn="}, {tagDeny, "torexit,TOREXIT"}, {tagDeny, "torexti"},
|
|
||||||
{tagDeny, zenTag}, {tagDeny, "abuseipdb"}, {tagDeny, "crowdsec"},
|
|
||||||
{tagLimitPercent, "torexti:50"}, {tagLimitPercent, "dnsbl=zen.example:50"},
|
|
||||||
{tagLimitPercent, "abuseipdb:50"}, {tagLimitPercent, "crowdsec:50"},
|
|
||||||
{tagLimitPercent, "vpn :50"}, {tagLimitPercent, "vpn"}, {tagLimitPercent, ":50"},
|
|
||||||
{tagLimitPercent, "vpn:"}, {tagLimitPercent, "vpn:0"}, {tagLimitPercent, "vpn:100"},
|
|
||||||
{tagLimitPercent, "vpn:1.5"}, {tagLimitPercent, "vpn:50,VPN:25"},
|
|
||||||
{tagLimitPercent, "exploitsignature:50"}, {tagLimitPercent, "asn=abc:50"},
|
|
||||||
{tagLists, torExitTag}, {tagLists, "=https://example.com/a.txt"},
|
|
||||||
{tagLists, "a,b=https://example.com/a.txt"},
|
|
||||||
{tagLists, "a=ftp://example.com/a.txt"},
|
|
||||||
{tagLists, "a=https://example.com/a.txt,A=https://example.com/b.txt"},
|
|
||||||
{tagLists, "a=https://example.com/a.txt,b=https://example.com/a.txt"},
|
|
||||||
{asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"},
|
{asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"},
|
||||||
{unknownLimitPercent, ""}, {unknownLimitPercent, "-1"},
|
{unknownLimitPercent, ""}, {unknownLimitPercent, "-1"},
|
||||||
{unknownLimitPercent, "50%"},
|
{unknownLimitPercent, "50%"},
|
||||||
@@ -2210,25 +2031,6 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
|||||||
{logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""},
|
{logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""},
|
||||||
{rulesEnabled, "yes"}, {rulesEnabled, "True"},
|
{rulesEnabled, "yes"}, {rulesEnabled, "True"},
|
||||||
})
|
})
|
||||||
|
|
||||||
wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff checks that with rule
|
|
||||||
// files off, and either setting turning off the Core Rule Set, the tag
|
|
||||||
// exploitsignature in SWW_TAG_DENY stops the start.
|
|
||||||
func wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for _, name := range []string{wafMode, wafAnomalyThreshold} {
|
|
||||||
_, err := config.FromEnvironment(environment{
|
|
||||||
tagDeny: exploitTag, rulesEnabled: disabled, name: off,
|
|
||||||
}.lookupEnv)
|
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), tagDeny+": ") {
|
|
||||||
t.Errorf("%s=off with rule files off gave the error %v, want one naming %s",
|
|
||||||
name, err, tagDeny)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
||||||
@@ -2483,11 +2285,11 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
lookupSource: defaultLookupSource,
|
lookupSource: defaultLookupSource,
|
||||||
lookupDBPath: "",
|
lookupDBPath: "",
|
||||||
lookupTimeout: "800ms",
|
lookupTimeout: "800ms",
|
||||||
addLookupHeaders: disabled,
|
addLookupHeaders: "false",
|
||||||
|
deniedCountries: "",
|
||||||
allowedCountries: "",
|
allowedCountries: "",
|
||||||
tagLists: "",
|
asnLimitPercent: "",
|
||||||
tagDeny: "",
|
countryLimitPercent: "",
|
||||||
tagLimitPercent: "",
|
|
||||||
asnBytesPercent: "",
|
asnBytesPercent: "",
|
||||||
countryBytesPercent: "",
|
countryBytesPercent: "",
|
||||||
unknownLimitPercent: "100",
|
unknownLimitPercent: "100",
|
||||||
|
|||||||
@@ -8,20 +8,18 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
|
// abuseIPDB notes AbuseIPDB, as noteHit does, with the score, when its score
|
||||||
// the score, when its score of the client is a hit, and refuses the request
|
// of the client is a hit, and refuses the request when SWW_REPUTATION_ACTION
|
||||||
// when SWW_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
|
// is deny, as dnsbl does for a zone. While SWW_ABUSEIPDB_KEY is unset it does
|
||||||
// SWW_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
|
// nothing. A client without a score is checked in the background, by the
|
||||||
// checked in the background, by the request's address, if its history counts
|
// request's address, if its history counts an offence, and the request does
|
||||||
// an offence, and the request does not wait for the answer. The score is then
|
// not wait for the answer. The score is then used for each address of the
|
||||||
// used for each address of the client. Its figures are the score and
|
// client. Its figures are the score and SWW_ABUSEIPDB_MIN_SCORE.
|
||||||
// SWW_ABUSEIPDB_MIN_SCORE.
|
|
||||||
type abuseIPDB struct{}
|
type abuseIPDB struct{}
|
||||||
|
|
||||||
func (abuseIPDB) name() string { return "abuseipdb" }
|
func (abuseIPDB) name() string { return "abuseipdb" }
|
||||||
@@ -41,7 +39,6 @@ func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rq.abuseIPDBHit = true
|
rq.abuseIPDBHit = true
|
||||||
rq.addTag(config.AbuseIPDBTag)
|
|
||||||
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
||||||
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
|
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
|
||||||
|
|
||||||
@@ -50,7 +47,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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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} {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ 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.DurationWAF != nil {
|
if line.DurationWAF != nil {
|
||||||
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF)
|
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
|
||||||
}
|
}
|
||||||
@@ -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.DurationWAF != nil {
|
|
||||||
t.Errorf("would_action %q and duration_waf %v, want %q and none",
|
t.Errorf("would_action %q and duration_waf %v, want %q and none",
|
||||||
line.WouldAction, line.DurationWAF, requestlog.ActionRequestHeaderTooLarge)
|
line.WouldAction, line.DurationWAF, requestlog.ActionTooLarge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,7 +280,7 @@ 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.LimitPercent != nil {
|
if line.LimitPercent != nil {
|
||||||
t.Errorf("limit percentage %d, want none", *line.LimitPercent)
|
t.Errorf("limit percentage %d, want none", *line.LimitPercent)
|
||||||
@@ -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) {
|
||||||
@@ -337,7 +335,7 @@ func newDiscriminatorInBubble(
|
|||||||
) (*discriminator.Server, *output) {
|
) (*discriminator.Server, *output) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{tagDeny: "country=KP"}
|
settings := map[string]string{deniedCountries: "KP"}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, settings)
|
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, settings)
|
||||||
@@ -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) {
|
||||||
@@ -548,7 +542,7 @@ func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
|||||||
|
|
||||||
return discriminator.New(params)
|
return discriminator.New(params)
|
||||||
}, held, time.Now, map[string]string{
|
}, held, time.Now, map[string]string{
|
||||||
tagDeny: "country=KP", wafMode: block,
|
deniedCountries: "KP", wafMode: block,
|
||||||
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
||||||
abuseIPDBKey: accountKey,
|
abuseIPDBKey: accountKey,
|
||||||
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -100,8 +90,8 @@ func (rq *request) countedBytes() int64 {
|
|||||||
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
||||||
// the client's counters back to zero. In observe mode it makes no ban and
|
// the client's counters back to zero. In observe mode it makes no ban and
|
||||||
// sets nothing back, and raises the alert for the ban it would have made,
|
// sets nothing back, and raises the alert for the ban it would have made,
|
||||||
// if that alert would be sent. It returns the action for that ban.
|
// if that alert would be sent.
|
||||||
func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) string {
|
func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
||||||
switch hit.Kind {
|
switch hit.Kind {
|
||||||
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
|
||||||
@@ -116,7 +106,7 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) str
|
|||||||
|
|
||||||
netblock := rq.h.netblock(rq.client)
|
netblock := rq.h.netblock(rq.client)
|
||||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
|
||||||
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseLimit))
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notes := bans.Notes{
|
notes := bans.Notes{
|
||||||
@@ -145,7 +135,7 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) str
|
|||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return banAction(ban.Permanent())
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ban, made := rq.h.ledger.BanForLimit(netblock, now, notes)
|
ban, made := rq.h.ledger.BanForLimit(netblock, now, notes)
|
||||||
@@ -155,19 +145,17 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) str
|
|||||||
if made {
|
if made {
|
||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return banAction(ban.Permanent())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// banForAttack bans the client's netblock at now for a clear sign of
|
// banForAttack bans the client's netblock at now for a clear sign of
|
||||||
// attack, which notes name: the ban rule that matched, or the trap path
|
// attack, 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
|
||||||
@@ -183,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)
|
||||||
@@ -192,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{
|
||||||
@@ -222,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,
|
||||||
@@ -232,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
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -213,14 +211,14 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
banScopeV4Prefix: "24",
|
banScopeV4Prefix: "24",
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
|
|
||||||
// fromDE's ban covers otherClient, which is refused unasked about.
|
// fromDE's ban covers otherClient, which is refused unasked about.
|
||||||
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+`"}`)
|
||||||
}
|
}
|
||||||
@@ -268,7 +266,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.setting != "" {
|
if tc.setting != "" {
|
||||||
@@ -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.ActionCountryDenied)
|
||||||
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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,15 +291,15 @@ func TestBanNotes(t *testing.T) {
|
|||||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -17,31 +17,30 @@ type percentage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// biasedThresholdsSet reports whether a biased threshold can lower a
|
// biasedThresholdsSet reports whether a biased threshold can lower a
|
||||||
// client's limits by its lookup: SWW_ASN_BYTES_PERCENT or
|
// client's limits: one of its lists is not empty,
|
||||||
// SWW_COUNTRY_BYTES_PERCENT is not empty, SWW_UNKNOWN_LIMIT_PERCENT is
|
// SWW_UNKNOWN_LIMIT_PERCENT is below 100, or SWW_ASN_LIMIT_PERCENT_URL
|
||||||
// below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
|
// is set. The client's lookup is then needed before its request goes on.
|
||||||
// then needed before its request goes on.
|
|
||||||
func biasedThresholdsSet(cfg *config.Config) bool {
|
func biasedThresholdsSet(cfg *config.Config) bool {
|
||||||
return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
|
return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
|
||||||
|
len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
|
||||||
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
|
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// limitPercentages returns the client's limit percentages, for the rate
|
// limitPercentages returns the client's limit percentages, for the rate
|
||||||
// limits and for the byte limits, by its tags, its AS number and country
|
// limits and for the byte limits, by its AS number and country as looked
|
||||||
// as looked up, each "" when unknown, and the blocklists, DNSBL zones and
|
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
|
||||||
// AbuseIPDB that list it. Each is the lowest of those the settings give
|
// that list it. Each is the lowest of those the settings give it, the
|
||||||
// it, the first of them in the order below when several are lowest: the
|
// first of them in the order below when several are lowest: the
|
||||||
// percentage SWW_TAG_LIMIT_PERCENT gives its asn= tag, the one the file
|
// percentage SWW_ASN_LIMIT_PERCENT gives its AS number, the one the file
|
||||||
// SWW_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
|
// SWW_ASN_LIMIT_PERCENT_URL names gives it, the one
|
||||||
// SWW_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
|
// SWW_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
|
||||||
// other tags, for a client without a country, unless its request skipped
|
// country, unless its request skipped the wait for GeoJS,
|
||||||
// the wait for GeoJS, SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
|
// SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the
|
||||||
// lists, the percentage of SWW_BLOCKLIST_ACTION while it is limit, and
|
// percentage of SWW_BLOCKLIST_ACTION while it is limit, and for a client
|
||||||
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
|
// a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the
|
||||||
// a hit, the percentage of SWW_REPUTATION_ACTION while it is limit. For
|
// percentage of SWW_REPUTATION_ACTION while it is limit. For the byte
|
||||||
// the byte limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT
|
// limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT take the
|
||||||
// take the place of the first three for an AS number or a country they
|
// place of the first three for an AS number or a country they list.
|
||||||
// list.
|
|
||||||
func (rq *request) limitPercentages() (percentage, percentage) {
|
func (rq *request) limitPercentages() (percentage, percentage) {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
asn, country := rq.line.ASN, rq.line.Country
|
asn, country := rq.line.ASN, rq.line.Country
|
||||||
@@ -66,18 +65,10 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
|||||||
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
|
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagSetting = "SWW_TAG_LIMIT_PERCENT"
|
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWW_ASN_LIMIT_PERCENT"),
|
||||||
|
fetched)
|
||||||
asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched)
|
countryRequests := given(cfg.CountryLimitPercent, country,
|
||||||
countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting)
|
"SWW_COUNTRY_LIMIT_PERCENT")
|
||||||
|
|
||||||
otherTags := percentage{percent: whole}
|
|
||||||
|
|
||||||
for _, tag := range rq.line.Tags {
|
|
||||||
if tag != asnTag(asn) && tag != countryTag(country) {
|
|
||||||
otherTags = lowest(otherTags, given(cfg.TagLimitPercent, tag, tagSetting))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
asnBytes, countryBytes := asnRequests, countryRequests
|
asnBytes, countryBytes := asnRequests, countryRequests
|
||||||
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
||||||
@@ -88,13 +79,12 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
|||||||
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
|
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted,
|
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
|
||||||
reputationListed),
|
lowest(asnBytes, countryBytes, unknown, blocklisted, reputationListed)
|
||||||
lowest(asnBytes, countryBytes, otherTags, unknown, blocklisted, reputationListed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// given returns the percentage percents, the setting named setting, gives
|
// given returns the percentage percents, the setting named setting, gives
|
||||||
// code, an AS number, a country or a tag, or whole when it does not list code.
|
// code, an AS number or a country, or whole when it does not list code.
|
||||||
func given(percents map[string]int64, code, setting string) percentage {
|
func given(percents map[string]int64, code, setting string) percentage {
|
||||||
percent, listed := percents[code]
|
percent, listed := percents[code]
|
||||||
if !listed {
|
if !listed {
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import (
|
|||||||
|
|
||||||
// The biased thresholds.
|
// The biased thresholds.
|
||||||
const (
|
const (
|
||||||
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
|
||||||
|
countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
|
||||||
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||||
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||||
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||||
@@ -34,11 +35,6 @@ const (
|
|||||||
asnDEHalf = asnDE + ":50"
|
asnDEHalf = asnDE + ":50"
|
||||||
asnDEQuarter = asnDE + ":25"
|
asnDEQuarter = asnDE + ":25"
|
||||||
countryDEHalf = "de:50"
|
countryDEHalf = "de:50"
|
||||||
// The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags.
|
|
||||||
asnTagDEHalf = "asn=64496:50"
|
|
||||||
asnTagDEQuarter = "asn=64496:25"
|
|
||||||
countryTagDEHalf = "country=DE:50"
|
|
||||||
countryTagDEQuarter = "country=DE:25"
|
|
||||||
// noCountry is in an AS of its own, AS64500, and in no country.
|
// noCountry is in an AS of its own, AS64500, and in no country.
|
||||||
noCountry = "192.0.2.80"
|
noCountry = "192.0.2.80"
|
||||||
// fourAMinute is the rate limit these tests set: half of it is 2
|
// fourAMinute is the rate limit these tests set: half of it is 2
|
||||||
@@ -58,8 +54,8 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting, value, from string
|
setting, value, from string
|
||||||
}{
|
}{
|
||||||
{tagLimitPercent, asnTagDEHalf, fromDE},
|
{asnLimitPercent, asnDEHalf, fromDE},
|
||||||
{tagLimitPercent, countryTagDEHalf, fromDE},
|
{countryLimitPercent, countryDEHalf, fromDE},
|
||||||
{unknownLimitPercent, "50", unplaced},
|
{unknownLimitPercent, "50", unplaced},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.setting, func(t *testing.T) {
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
@@ -76,7 +72,7 @@ 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, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
@@ -100,8 +96,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting, value, from string
|
setting, value, from string
|
||||||
}{
|
}{
|
||||||
{tagLimitPercent, asnTagDEHalf, fromDE},
|
{asnLimitPercent, asnDEHalf, fromDE},
|
||||||
{tagLimitPercent, "Country=de:50", fromDE},
|
{countryLimitPercent, "DE:50", fromDE},
|
||||||
{unknownLimitPercent, "50", unplaced},
|
{unknownLimitPercent, "50", unplaced},
|
||||||
{asnBytesPercent, "as64496:50", fromDE},
|
{asnBytesPercent, "as64496:50", fromDE},
|
||||||
{countryBytesPercent, countryDEHalf, fromDE},
|
{countryBytesPercent, countryDEHalf, fromDE},
|
||||||
@@ -155,13 +151,13 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"raising the byte limits back",
|
"raising the byte limits back",
|
||||||
map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
|
map[string]string{asnLimitPercent: asnDEHalf, asnBytesPercent: asnDE + ":100"},
|
||||||
"50 from " + tagLimitPercent, none, "",
|
"50 from " + asnLimitPercent, none, "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"raising the byte limits back, by country",
|
"raising the byte limits back, by country",
|
||||||
map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
|
map[string]string{countryLimitPercent: countryDEHalf, countryBytesPercent: "de:100"},
|
||||||
"50 from " + tagLimitPercent, none, "",
|
"50 from " + countryLimitPercent, none, "",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -188,19 +184,19 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
|||||||
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithLookups(t, map[string]string{unknownLimitPercent: "0"})
|
s, _, _ := startWithLookups(t, map[string]string{asnLimitPercent: asnDE + ":0"})
|
||||||
|
|
||||||
// The first request breaks the limit, and bans the client; the log line
|
// The first request breaks the limit, and bans the client; the log line
|
||||||
// gives the 0.
|
// gives the 0.
|
||||||
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
if line.fields["limit_percent"] != float64(0) ||
|
if line.fields["limit_percent"] != float64(0) ||
|
||||||
line.fields["limit_percent_setting"] != unknownLimitPercent {
|
line.fields["limit_percent_setting"] != asnLimitPercent {
|
||||||
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
|
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
|
||||||
line.fields["limit_percent"], line.fields["limit_percent_setting"],
|
line.fields["limit_percent"], line.fields["limit_percent_setting"],
|
||||||
unknownLimitPercent)
|
asnLimitPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(unplaced, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLowestPercentageApplies(t *testing.T) {
|
func TestLowestPercentageApplies(t *testing.T) {
|
||||||
@@ -215,29 +211,29 @@ func TestLowestPercentageApplies(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"the country's",
|
"the country's",
|
||||||
map[string]string{tagLimitPercent: asnTagDEHalf + "," + countryTagDEQuarter},
|
map[string]string{asnLimitPercent: asnDEHalf, countryLimitPercent: "de:25"},
|
||||||
fromDE, "25 from " + tagLimitPercent,
|
fromDE, "25 from " + countryLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the AS number's",
|
"the AS number's",
|
||||||
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEHalf},
|
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: countryDEHalf},
|
||||||
fromDE, "25 from " + tagLimitPercent,
|
fromDE, "25 from " + asnLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the AS number's, the first of two alike",
|
"the AS number's, the first of two alike",
|
||||||
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEQuarter},
|
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: "de:25"},
|
||||||
fromDE, "25 from " + tagLimitPercent,
|
fromDE, "25 from " + asnLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"that for a client without a country",
|
"that for a client without a country",
|
||||||
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
|
map[string]string{asnLimitPercent: "AS64500:50", unknownLimitPercent: "25"},
|
||||||
noCountry, "25 from " + unknownLimitPercent,
|
noCountry, "25 from " + unknownLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
||||||
"the AS number's, for a client without a country",
|
"the AS number's, for a client without a country",
|
||||||
map[string]string{tagLimitPercent: "asn=64500:25"},
|
map[string]string{asnLimitPercent: "AS64500:25"},
|
||||||
noCountry, "25 from " + tagLimitPercent,
|
noCountry, "25 from " + asnLimitPercent,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -250,7 +246,7 @@ 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, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
tc.want)
|
tc.want)
|
||||||
})
|
})
|
||||||
@@ -269,7 +265,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.
|
||||||
@@ -293,7 +289,7 @@ 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, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
"0 from "+unknownLimitPercent)
|
"0 from "+unknownLimitPercent)
|
||||||
})
|
})
|
||||||
@@ -308,12 +304,8 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
|||||||
setting, value string
|
setting, value string
|
||||||
waits bool
|
waits bool
|
||||||
}{
|
}{
|
||||||
{tagLimitPercent, asnTagDEHalf, true},
|
{asnLimitPercent, asnDEHalf, true},
|
||||||
{tagLimitPercent, countryTagDEHalf, true},
|
{countryLimitPercent, countryDEHalf, true},
|
||||||
{tagDeny, denyKP, true},
|
|
||||||
// A tag the lookup does not give needs no answer.
|
|
||||||
{tagLimitPercent, "vpn:50", false},
|
|
||||||
{tagDeny, "vpn", false},
|
|
||||||
{asnBytesPercent, asnDEHalf, true},
|
{asnBytesPercent, asnDEHalf, true},
|
||||||
{countryBytesPercent, countryDEHalf, true},
|
{countryBytesPercent, countryDEHalf, true},
|
||||||
{unknownLimitPercent, "99", true},
|
{unknownLimitPercent, "99", true},
|
||||||
@@ -328,9 +320,7 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
|||||||
// says, with a GeoJS that never answers.
|
// says, with a GeoJS that never answers.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
|
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
|
||||||
map[string]string{
|
map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value})
|
||||||
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
|
|
||||||
})
|
|
||||||
began := time.Now()
|
began := time.Now()
|
||||||
|
|
||||||
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
|
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
|
||||||
@@ -373,15 +363,15 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
|||||||
{
|
{
|
||||||
// A quarter of 12 requests a minute is 3: the fourth breaks it.
|
// A quarter of 12 requests a minute is 3: the fourth breaks it.
|
||||||
"a rate limit",
|
"a rate limit",
|
||||||
map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
|
map[string]string{rateLimitPerMinute: "12", asnLimitPercent: asnDEQuarter},
|
||||||
3, http.StatusForbidden, requestlog.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 " + asnLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// The byte limits' percentage, not the rate limits'.
|
// The byte limits' percentage, not the rate limits'.
|
||||||
"a byte limit",
|
"a byte limit",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
|
bytesLimitPerMinute: twoUploads, asnLimitPercent: asnDEQuarter,
|
||||||
asnBytesPercent: asnDEHalf,
|
asnBytesPercent: asnDEHalf,
|
||||||
},
|
},
|
||||||
0, http.StatusOK, requestlog.ActionForward,
|
0, http.StatusOK, requestlog.ActionForward,
|
||||||
@@ -497,7 +487,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// percentText gives a limit percentage and the setting that gave it as
|
// percentText gives a limit percentage and the setting that gave it as
|
||||||
// text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are
|
// text, such as "50 from SWW_ASN_LIMIT_PERCENT", or none when both are
|
||||||
// left out.
|
// left out.
|
||||||
func percentText(percent *int64, setting string) string {
|
func percentText(percent *int64, setting string) string {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -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},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -35,7 +37,7 @@ func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &verdict{
|
rq.banForLimit(now, hit, rq.out.status)
|
||||||
action: rq.banForLimit(now, hit, rq.out.status), figures: limitFigures(hit),
|
|
||||||
}
|
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -17,10 +17,9 @@ import (
|
|||||||
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
|
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
|
||||||
// pathExempt decides, and notes the rules it matched and its score in the
|
// pathExempt decides, and notes the rules it matched and its score in the
|
||||||
// log line, and the rules in the metrics. A score at or over
|
// log line, and the rules in the metrics. A score at or over
|
||||||
// SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature,
|
// SWW_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
|
||||||
// raises the waf_block alert, and in block mode refuses the request with 403,
|
// and in block mode refuses the request with 403, which is an offence its
|
||||||
// which is an offence its client's history counts. Its figures are the score
|
// client's history counts. Its figures are the score and the threshold.
|
||||||
// and the threshold.
|
|
||||||
type coreRuleSet struct{}
|
type coreRuleSet struct{}
|
||||||
|
|
||||||
func (coreRuleSet) name() string { return "core_rule_set" }
|
func (coreRuleSet) name() string { return "core_rule_set" }
|
||||||
@@ -51,7 +50,6 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.addTag(config.ExploitSignatureTag)
|
|
||||||
rq.alertWAFBlock(result)
|
rq.alertWAFBlock(result)
|
||||||
|
|
||||||
if cfg.WAFMode == config.WAFModeDetect {
|
if cfg.WAFMode == config.WAFModeDetect {
|
||||||
|
|||||||
@@ -61,42 +61,6 @@ func wantWAF(t *testing.T, line logLine, score *int, ruleIDs ...int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
mode string
|
|
||||||
status int
|
|
||||||
action string
|
|
||||||
}{
|
|
||||||
{block, block, http.StatusForbidden, requestlog.ActionWAFBlocked},
|
|
||||||
{detect, detect, http.StatusOK, requestlog.ActionForward},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
|
||||||
|
|
||||||
wantTags(t, s.request(client, sqlInjection, tc.status, tc.action),
|
|
||||||
"exploitsignature")
|
|
||||||
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
|
||||||
wafMode: detect, tagDeny: "exploitsignature",
|
|
||||||
})
|
|
||||||
|
|
||||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
|
||||||
requestlog.ActionClientDenied)
|
|
||||||
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
|
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -196,7 +160,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
|
|
||||||
// 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)
|
||||||
wantWAF(t, line, nil)
|
wantWAF(t, line, nil)
|
||||||
|
|
||||||
// With SWW_WAF_MODE off, no request is.
|
// With SWW_WAF_MODE off, no request is.
|
||||||
@@ -404,7 +368,7 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
|
|||||||
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, WAFBlocked: 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 {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package discriminator
|
package discriminator
|
||||||
|
|
||||||
// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
// The heuristic countries. It reads SWW_DENIED_COUNTRIES and
|
||||||
|
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -10,26 +11,32 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// countries refuses a request when SWW_EXCLUSIVELY_ALLOWED_COUNTRIES is set and
|
// countries refuses a request by the client's country as it was looked up. A
|
||||||
// does not hold the client's country as it was looked up. A client without a
|
// client without a country, or whose country cannot be found, is refused only
|
||||||
// country, or whose country cannot be found, is refused. Its figures are the
|
// by SWW_EXCLUSIVELY_ALLOWED_COUNTRIES. Its figures are the country and the
|
||||||
// country and the setting that refused it.
|
// setting that refused it.
|
||||||
type countries struct{}
|
type countries struct{}
|
||||||
|
|
||||||
func (countries) name() string { return "countries" }
|
func (countries) name() string { return "countries" }
|
||||||
|
|
||||||
func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
denied := rq.h.config.DeniedCountries
|
||||||
allowed := rq.h.config.ExclusivelyAllowedCountries
|
allowed := rq.h.config.ExclusivelyAllowedCountries
|
||||||
country := rq.line.Country
|
country := rq.line.Country
|
||||||
|
|
||||||
if len(allowed) == 0 || slices.Contains(allowed, country) {
|
var setting string
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case slices.Contains(denied, country):
|
||||||
|
setting = "SWW_DENIED_COUNTRIES"
|
||||||
|
case len(allowed) > 0 && !slices.Contains(allowed, country):
|
||||||
|
setting = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &verdict{
|
return &verdict{
|
||||||
action: requestlog.ActionCountryDenied,
|
action: requestlog.ActionCountryDenied,
|
||||||
figures: map[string]any{
|
figures: map[string]any{"country": country, "setting": setting},
|
||||||
"country": country, "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,13 +33,13 @@ func TestCountryLists(t *testing.T) {
|
|||||||
env map[string]string
|
env map[string]string
|
||||||
refused []string
|
refused []string
|
||||||
}{
|
}{
|
||||||
|
{"denied", map[string]string{deniedCountries: "kp"}, []string{fromKP}},
|
||||||
{
|
{
|
||||||
"exclusively allowed", map[string]string{allowedCountries: "DE"},
|
"exclusively allowed", map[string]string{allowedCountries: "DE"},
|
||||||
[]string{fromKP, unplaced},
|
[]string{fromKP, unplaced},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"with a country in SWW_TAG_DENY",
|
"both", map[string]string{deniedCountries: "kp", allowedCountries: "de,fr"},
|
||||||
map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"},
|
|
||||||
[]string{fromKP, unplaced},
|
[]string{fromKP, unplaced},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -95,12 +95,12 @@ func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h", allowedCountries: "de",
|
lookupTimeout: "1h", deniedCountries: "kp", allowedCountries: "de",
|
||||||
})
|
})
|
||||||
|
|
||||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
|
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||||
wantHeuristic(t, line, "countries",
|
wantHeuristic(t, line, "countries",
|
||||||
`{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
`{"country": "KP", "setting": "SWW_DENIED_COUNTRIES"}`)
|
||||||
|
|
||||||
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
|
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||||
wantHeuristic(t, line, "countries",
|
wantHeuristic(t, line, "countries",
|
||||||
@@ -117,9 +117,9 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
|||||||
})
|
})
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
allowedCountries: "de",
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
|
|
||||||
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
||||||
@@ -202,7 +202,7 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
|
|||||||
env map[string]string
|
env map[string]string
|
||||||
}{
|
}{
|
||||||
{"no setting needs the lookup", nil},
|
{"no setting needs the lookup", nil},
|
||||||
{"a tag setting needs it", map[string]string{tagDeny: denyKP}},
|
{"a country list is set", map[string]string{deniedCountries: "kp"}},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -8,15 +8,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// crowdSec refuses a request when a decision of the CrowdSec decision list on
|
// crowdSec refuses a request when a decision of the CrowdSec decision list on
|
||||||
// the client is in force at now. It then adds the tag crowdsec, notes the
|
// the client is in force at now. It then notes the list, as noteHit does, and
|
||||||
// list, as noteHit does, and bans the client until that decision ends. Its
|
// bans the client until that decision ends. Its figures are the decision's
|
||||||
// figures are the decision's scenario and when it ends.
|
// scenario and when it ends.
|
||||||
type crowdSec struct{}
|
type crowdSec struct{}
|
||||||
|
|
||||||
func (crowdSec) name() string { return "crowdsec" }
|
func (crowdSec) name() string { return "crowdsec" }
|
||||||
@@ -27,12 +26,12 @@ func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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),
|
||||||
|
|||||||
@@ -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,10 +105,9 @@ 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
|
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
|
||||||
@@ -129,9 +128,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 +163,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 {
|
||||||
|
|||||||
@@ -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()},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, asked, release := startHeldGeoJS(t)
|
geojsURL, asked, release := startHeldGeoJS(t)
|
||||||
s := startDiscriminator(t, geojsURL, map[string]string{
|
s := startDiscriminator(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h", tagDeny: denyKP, bytesLimitPerMinute: byteLimit,
|
lookupTimeout: "1h", deniedCountries: "kp", bytesLimitPerMinute: byteLimit,
|
||||||
})
|
})
|
||||||
|
|
||||||
first := s.question(siteA, client, "/")
|
first := s.question(siteA, client, "/")
|
||||||
@@ -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) {
|
||||||
@@ -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) {
|
||||||
@@ -765,7 +765,7 @@ func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s := startDiscriminator(t, geojsURL, map[string]string{
|
s := startDiscriminator(t, geojsURL, map[string]string{
|
||||||
addLookupHeaders: tc.setting, lookupTimeout: "1h", tagDeny: denyKP,
|
addLookupHeaders: tc.setting, lookupTimeout: "1h", deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
|
|
||||||
got := s.ask(fromDE, "/")
|
got := s.ask(fromDE, "/")
|
||||||
@@ -894,7 +894,7 @@ 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)
|
||||||
@@ -1298,11 +1298,11 @@ 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.ActionAdmin},
|
requestlog.ActionAdmin},
|
||||||
|
|||||||
@@ -5,16 +5,13 @@ package discriminator
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
|
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
|
||||||
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
|
// does, and refuses the request when SWW_REPUTATION_ACTION is deny. Being
|
||||||
// the request when SWW_REPUTATION_ACTION is deny. Being
|
|
||||||
// limit, it lowers the client's limits instead (see limitPercentages), and
|
// limit, it lowers the client's limits instead (see limitPercentages), and
|
||||||
// being log, it does nothing more. A zone without a verdict on the client is
|
// being log, it does nothing more. A zone without a verdict on the client is
|
||||||
// asked about it in the background, and the request does not wait for the
|
// asked about it in the background, and the request does not wait for the
|
||||||
@@ -28,16 +25,12 @@ func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
|||||||
rq.dnsblListed = len(listedBy) > 0
|
rq.dnsblListed = len(listedBy) > 0
|
||||||
rq.noteListed(listedBy, "listed by a DNSBL zone")
|
rq.noteListed(listedBy, "listed by a DNSBL zone")
|
||||||
|
|
||||||
for _, zone := range listedBy {
|
|
||||||
rq.addTag(config.DNSBLTagPrefix + strings.ToLower(zone))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
|
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &verdict{
|
return &verdict{
|
||||||
action: requestlog.ActionClientDenied,
|
action: requestlog.ActionDenied,
|
||||||
figures: map[string]any{"zones": listedBy},
|
figures: map[string]any{"zones": listedBy},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -43,15 +43,12 @@ const (
|
|||||||
// country are looked up, so that a client they refuse is not looked up.
|
// country are looked up, so that a client they refuse is not looked up.
|
||||||
func beforeLookup() []heuristic {
|
func beforeLookup() []heuristic {
|
||||||
return []heuristic{
|
return []heuristic{
|
||||||
tagLists{},
|
|
||||||
denyNets{},
|
denyNets{},
|
||||||
activeBan{},
|
activeBan{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// afterLookup are the heuristics that run once the lookup has been made.
|
// afterLookup are the heuristics that run once the lookup has been made.
|
||||||
// tag_deny runs twice: before the rate limits, on the tags known by then, and
|
|
||||||
// last, on the tag rule_files and core_rule_set can add.
|
|
||||||
func afterLookup() []heuristic {
|
func afterLookup() []heuristic {
|
||||||
return []heuristic{
|
return []heuristic{
|
||||||
countries{},
|
countries{},
|
||||||
@@ -59,13 +56,11 @@ func afterLookup() []heuristic {
|
|||||||
crowdSec{},
|
crowdSec{},
|
||||||
dnsbl{},
|
dnsbl{},
|
||||||
abuseIPDB{},
|
abuseIPDB{},
|
||||||
tagDeny{},
|
|
||||||
rateLimits{},
|
rateLimits{},
|
||||||
trapPaths{},
|
trapPaths{},
|
||||||
ruleFiles{},
|
ruleFiles{},
|
||||||
headerLimits{},
|
headerLimits{},
|
||||||
coreRuleSet{},
|
coreRuleSet{},
|
||||||
tagDeny{},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "2",
|
rateLimitPerMinute: "2",
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,8 +19,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// lookUp looks up the client's AS number and country, in the lookup
|
// lookUp looks up the client's AS number and country, in the lookup
|
||||||
// database or through GeoJS, and notes them for the log line and as the
|
// database or through GeoJS, and notes them for the log line, unless
|
||||||
// request's asn= and country= tags, unless
|
|
||||||
// SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or
|
// SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or
|
||||||
// link-local address, which no lookup can place. The lookup database
|
// link-local address, which no lookup can place. The lookup database
|
||||||
// answers at once. With GeoJS, while a setting needs the answer, such as a
|
// answers at once. With GeoJS, while a setting needs the answer, such as a
|
||||||
@@ -50,34 +47,6 @@ func (rq *request) lookUp(ctx context.Context, now time.Time) {
|
|||||||
rq.line.ASN = rq.lookupAnswer.ASN
|
rq.line.ASN = rq.lookupAnswer.ASN
|
||||||
rq.line.ASName = rq.lookupAnswer.ASName
|
rq.line.ASName = rq.lookupAnswer.ASName
|
||||||
rq.line.Country = rq.lookupAnswer.Country
|
rq.line.Country = rq.lookupAnswer.Country
|
||||||
|
|
||||||
if tag := asnTag(rq.line.ASN); tag != "" {
|
|
||||||
rq.addTag(tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tag := countryTag(rq.line.Country); tag != "" {
|
|
||||||
rq.addTag(tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// asnTag returns the tag of asn, an AS number as the lookup gives it, such as
|
|
||||||
// asn=64496 for AS64496, or "" for no AS number.
|
|
||||||
func asnTag(asn string) string {
|
|
||||||
if asn == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return config.ASNTagPrefix + strings.TrimPrefix(asn, "AS")
|
|
||||||
}
|
|
||||||
|
|
||||||
// countryTag returns the tag of country, a code as the lookup gives it, such
|
|
||||||
// as country=DE for DE, or "" for no country.
|
|
||||||
func countryTag(country string) string {
|
|
||||||
if country == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return config.CountryTagPrefix + country
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// addLookup adds answer, an answer about a client from the lookup
|
// addLookup adds answer, an answer about a client from the lookup
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -94,9 +94,9 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
|
|||||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)}
|
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)}
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
|
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
|
|
||||||
requestFromDE := func() *http.Request {
|
requestFromDE := func() *http.Request {
|
||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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",`+
|
||||||
@@ -195,10 +195,10 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
_, _ = io.WriteString(w, "hello")
|
_, _ = io.WriteString(w, "hello")
|
||||||
})
|
})
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
metricsTopN: "2",
|
metricsTopN: "2",
|
||||||
allowedCountries: "de,fr",
|
deniedCountries: "kp",
|
||||||
}
|
}
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env)
|
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env)
|
||||||
@@ -322,13 +322,13 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
|||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
addr, _, server := startProxyWithClock(t, app.URL, geojs.URL, time.Now,
|
addr, _, server := startProxyWithClock(t, app.URL, geojs.URL, time.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
|
|
||||||
// GeoJS fails, so the client has no country= tag, and SWW_TAG_DENY does
|
// GeoJS fails, so the client counts as coming from an unknown country,
|
||||||
// not refuse it.
|
// which SWW_DENIED_COUNTRIES does not refuse.
|
||||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
req.Header.Set(forwardedFor, fromDE)
|
req.Header.Set(forwardedFor, fromDE)
|
||||||
wantStatus(t, do(t, req), http.StatusOK)
|
wantStatus(t, do(t, req), http.StatusOK)
|
||||||
|
|||||||
@@ -14,70 +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 TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
|
func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -86,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"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +50,7 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.setting != "" {
|
if tc.setting != "" {
|
||||||
@@ -133,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.ActionCountryDenied},
|
||||||
{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)
|
||||||
@@ -174,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 != "" {
|
||||||
@@ -187,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,
|
||||||
|
|||||||
@@ -155,12 +155,11 @@ func newHandler(params Params) *handler {
|
|||||||
h.geojs = lookup.New(lookup.Params{
|
h.geojs = lookup.New(lookup.Params{
|
||||||
URL: params.GeoJSURL,
|
URL: params.GeoJSURL,
|
||||||
Timeout: params.Config.LookupTimeout,
|
Timeout: params.Config.LookupTimeout,
|
||||||
// The country list, the headers, the tag settings that name an asn=
|
// The country lists, the headers and the biased thresholds act on
|
||||||
// or country= tag and the biased thresholds act on the answer before
|
// the answer before the request goes on.
|
||||||
// the request goes on.
|
Wait: len(params.Config.DeniedCountries) > 0 ||
|
||||||
Wait: len(params.Config.ExclusivelyAllowedCountries) > 0 ||
|
len(params.Config.ExclusivelyAllowedCountries) > 0 ||
|
||||||
params.Config.AddLookupHeaders || params.Config.TagsNeedLookup() ||
|
params.Config.AddLookupHeaders || biasedThresholdsSet(params.Config),
|
||||||
biasedThresholdsSet(params.Config),
|
|
||||||
Answered: h.addLookup,
|
Answered: h.addLookup,
|
||||||
Now: params.Now,
|
Now: params.Now,
|
||||||
ProcessLog: params.ProcessLog,
|
ProcessLog: params.ProcessLog,
|
||||||
@@ -184,7 +183,6 @@ func newReputation(
|
|||||||
lists := reputation.New(reputation.Params{
|
lists := reputation.New(reputation.Params{
|
||||||
BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh,
|
BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh,
|
||||||
ASNLimitPercentURL: cfg.ASNLimitPercentURL,
|
ASNLimitPercentURL: cfg.ASNLimitPercentURL,
|
||||||
TagLists: cfg.TagLists,
|
|
||||||
CrowdSecDecisionsURL: cfg.CrowdSecDecisionsURL, CrowdSecKey: cfg.CrowdSecKey,
|
CrowdSecDecisionsURL: cfg.CrowdSecDecisionsURL, CrowdSecKey: cfg.CrowdSecKey,
|
||||||
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
|
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -69,9 +69,7 @@ const (
|
|||||||
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||||
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
||||||
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||||
tagDeny = "SWW_TAG_DENY"
|
deniedCountries = "SWW_DENIED_COUNTRIES"
|
||||||
// denyKP is the value of SWW_TAG_DENY that refuses fromKP's country.
|
|
||||||
denyKP = "country=KP"
|
|
||||||
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
banResponse = "SWW_BAN_RESPONSE"
|
banResponse = "SWW_BAN_RESPONSE"
|
||||||
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
||||||
|
|||||||
@@ -42,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
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -136,7 +136,7 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
rateLimitExemptPaths: "/assets/,/favicon.ico",
|
rateLimitExemptPaths: "/assets/,/favicon.ico",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
|
|
||||||
// The answers are kept before the requests, so that none waits for
|
// The answers are kept before the requests, so that none waits for
|
||||||
@@ -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 the country lists still refuse a path
|
||||||
// prefix.
|
// under a 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.ActionCountryDenied)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.wafBlocked {
|
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,11 +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.ActionWAFBlocked:
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
return &verdict{action: rq.banForLimit(now, hit, status), figures: limitFigures(hit)}
|
rq.banForLimit(now, hit, status)
|
||||||
|
|
||||||
|
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
@@ -113,7 +101,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
|||||||
"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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +191,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
errorBurstThreshold: "2",
|
errorBurstThreshold: "2",
|
||||||
rulesDir: writeRules(t, testRules),
|
rulesDir: writeRules(t, testRules),
|
||||||
tagLimitPercent: countryTagDEHalf,
|
countryLimitPercent: countryDEHalf,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Half of the threshold would be one, which the second refusal is over.
|
// Half of the threshold would be one, which the second refusal is over.
|
||||||
@@ -310,7 +298,7 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
|||||||
t.Errorf("log line has limit_hit %q, want error_burst", 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) {
|
||||||
@@ -353,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)
|
||||||
@@ -365,7 +353,7 @@ 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.LimitHitErrorBurst || line.BanExpires != "" {
|
if line.LimitHit != requestlog.LimitHitErrorBurst || line.BanExpires != "" {
|
||||||
t.Errorf("log line has limit_hit %q and ban_expires %q, want error_burst "+
|
t.Errorf("log line has limit_hit %q and ban_expires %q, want error_burst "+
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ 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
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -162,17 +162,17 @@ 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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBlocklistsComeAfterAllowedCountriesAndSkipAllowNets(t *testing.T) {
|
func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, server, queue := startWithLookups(t, map[string]string{
|
s, server, queue := startWithLookups(t, map[string]string{
|
||||||
blocklistURLs: dropURL, allowedCountries: "de", allowNets: fromDE,
|
blocklistURLs: dropURL, deniedCountries: "kp", allowNets: fromDE,
|
||||||
})
|
})
|
||||||
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
|
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
|
||||||
|
|
||||||
@@ -193,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]
|
||||||
@@ -211,18 +211,18 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
|
|||||||
// percentText gives them, and limitHit its limit_hit.
|
// percentText gives them, and limitHit its limit_hit.
|
||||||
want, limitHit string
|
want, limitHit string
|
||||||
}{
|
}{
|
||||||
{limitHalf, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
|
{limitHalf, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
|
||||||
{limitQuarter, asnTagDEHalf, "25 from " + blocklistAction, minuteBytes},
|
{limitQuarter, asnDEHalf, "25 from " + blocklistAction, minuteBytes},
|
||||||
// The AS number's, the first of two alike.
|
// The AS number's, the first of two alike.
|
||||||
{limitQuarter, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
|
{limitQuarter, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
|
||||||
{actionLog, "asn=64511:25", none, ""},
|
{actionLog, asnDE + ":100", none, ""},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) {
|
t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL,
|
bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL,
|
||||||
blocklistAction: tc.action, tagLimitPercent: tc.asnPercent,
|
blocklistAction: tc.action, asnLimitPercent: tc.asnPercent,
|
||||||
})
|
})
|
||||||
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
fromURL = "25 from " + asnLimitPercentURL
|
fromURL = "25 from " + asnLimitPercentURL
|
||||||
fromSetting = "25 from " + tagLimitPercent
|
fromSetting = "25 from " + asnLimitPercent
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -260,16 +260,16 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
|
|||||||
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
|
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
|
||||||
{
|
{
|
||||||
"the file's, lower than the setting's",
|
"the file's, lower than the setting's",
|
||||||
map[string]string{tagLimitPercent: asnTagDEHalf}, asnDEQuarter, fromURL, fromURL,
|
map[string]string{asnLimitPercent: asnDEHalf}, asnDEQuarter, fromURL, fromURL,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the setting's, lower than the file's",
|
"the setting's, lower than the file's",
|
||||||
map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEHalf,
|
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEHalf,
|
||||||
fromSetting, fromSetting,
|
fromSetting, fromSetting,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the setting's, the first of two alike",
|
"the setting's, the first of two alike",
|
||||||
map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEQuarter,
|
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEQuarter,
|
||||||
fromSetting, fromSetting,
|
fromSetting, fromSetting,
|
||||||
},
|
},
|
||||||
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
|
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
|
||||||
@@ -380,7 +380,7 @@ 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
|
||||||
@@ -397,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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -468,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))
|
||||||
|
|
||||||
@@ -482,46 +482,6 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
env map[string]string
|
|
||||||
load func(*discriminator.Server)
|
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"dnsbl",
|
|
||||||
map[string]string{
|
|
||||||
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionLog,
|
|
||||||
},
|
|
||||||
func(server *discriminator.Server) {
|
|
||||||
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
|
||||||
},
|
|
||||||
[]string{"asn=64496", "country=DE", "dnsbl=" + dnsblZone},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
abuseipdb,
|
|
||||||
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionLog},
|
|
||||||
func(server *discriminator.Server) {
|
|
||||||
loadScores(server, map[string]int64{fromDE: 80})
|
|
||||||
},
|
|
||||||
[]string{abuseipdb, "asn=64496", "country=DE"},
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, tc.env)
|
|
||||||
tc.load(server)
|
|
||||||
|
|
||||||
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
|
||||||
tc.want...)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
|
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -532,7 +492,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]
|
||||||
@@ -741,8 +701,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
|
||||||
@@ -781,7 +741,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
|
||||||
@@ -814,20 +774,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.ActionWAFBlocked, ratelimit.Offences{WAFBlocked: 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) {
|
||||||
@@ -858,7 +818,7 @@ 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
|
||||||
@@ -875,7 +835,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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -972,7 +932,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)
|
||||||
@@ -996,10 +956,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) {
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ type decision struct {
|
|||||||
counted bool
|
counted bool
|
||||||
limitPercent, bytesPercent percentage
|
limitPercent, bytesPercent percentage
|
||||||
// attack is true for a request that matched a ban rule or asked for a
|
// 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
|
||||||
// wafBlocked for one the Core Rule Set refused, each an offence its
|
// one the Core Rule Set refused, each an offence its client's history
|
||||||
// client's history counts.
|
// counts.
|
||||||
attack, blockedByBlockRule, wafBlocked 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
|
||||||
@@ -138,15 +138,6 @@ func (h *handler) startRequest(
|
|||||||
return rq
|
return rq
|
||||||
}
|
}
|
||||||
|
|
||||||
// addTag adds tag to the request's tags, which stay sorted and hold each tag
|
|
||||||
// once.
|
|
||||||
func (rq *request) addTag(tag string) {
|
|
||||||
at, found := slices.BinarySearch(rq.line.Tags, tag)
|
|
||||||
if !found {
|
|
||||||
rq.line.Tags = slices.Insert(rq.line.Tags, at, tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// requestHeaders returns the headers of r that names lists, by name in
|
// requestHeaders returns the headers of r that names lists, by name in
|
||||||
// lower case, each with its values joined by ", ". Authorization, Cookie
|
// lower case, each with its values joined by ", ". Authorization, Cookie
|
||||||
// and Set-Cookie are never among them, whatever names says.
|
// and Set-Cookie are never among them, whatever names says.
|
||||||
@@ -187,10 +178,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.ActionWAFBlocked:
|
|
||||||
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)
|
||||||
@@ -304,15 +294,15 @@ func timing(start, end time.Time) *float64 {
|
|||||||
// when it comes.
|
// when it comes.
|
||||||
func (rq *request) addToHistory() {
|
func (rq *request) addToHistory() {
|
||||||
request := ratelimit.Request{
|
request := ratelimit.Request{
|
||||||
Forwarded: rq.allowed,
|
Forwarded: rq.allowed,
|
||||||
Refused: rq.refused != nil,
|
Refused: rq.refused != nil,
|
||||||
Status: rq.out.status,
|
Status: rq.out.status,
|
||||||
RequestBytes: rq.requestBytes,
|
RequestBytes: rq.requestBytes,
|
||||||
ResponseBytes: rq.out.bytes,
|
ResponseBytes: rq.out.bytes,
|
||||||
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
||||||
Attack: rq.attack,
|
Attack: rq.attack,
|
||||||
BlockedByBlockRule: rq.blockedByBlockRule,
|
RuleBlocked: rq.ruleBlocked,
|
||||||
WAFBlocked: rq.wafBlocked,
|
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)
|
||||||
|
|||||||
@@ -8,18 +8,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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, and refuses the request
|
||||||
// exploitsignature when it matches any, and refuses the request
|
// when the last rule it matches refuses it: with ActionRuleBlocked for a block
|
||||||
// when the last rule it matches refuses it: with ActionBlockedByBlockRule for a
|
// rule, and ActionBanned for a ban rule. A ban rule bans the client's netblock
|
||||||
// block rule, and for a ban rule with the action for the ban it makes. A ban
|
// for a clear sign of attack, or in observe mode raises the alert for the ban
|
||||||
// rule bans the client's netblock for a clear sign of attack, or in observe
|
|
||||||
// mode raises the alert for the ban
|
|
||||||
// it would have made. Either rule's match is noted as an offence, for the
|
// 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{}
|
||||||
@@ -38,24 +35,20 @@ func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.addTag(config.ExploitSignatureTag)
|
|
||||||
|
|
||||||
// Only the last rule matched can refuse the request.
|
// Only the last rule matched can refuse the request.
|
||||||
last := matched[len(matched)-1]
|
last := matched[len(matched)-1]
|
||||||
figures := map[string]any{"rule_id": last.ID, "rule_action": last.Action}
|
figures := map[string]any{"rule_id": last.ID, "rule_action": last.Action}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,21 +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) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
|
||||||
rulesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
|
|
||||||
})
|
|
||||||
|
|
||||||
wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward),
|
|
||||||
"exploitsignature")
|
|
||||||
wantTags(t, s.request(client, "/blocked", http.StatusForbidden,
|
|
||||||
requestlog.ActionBlockedByBlockRule), "exploitsignature")
|
|
||||||
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
|
func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
|
||||||
@@ -116,11 +101,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 +117,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 +127,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 +155,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 +165,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 +177,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 +205,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 +215,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)
|
||||||
|
|||||||
@@ -32,12 +32,13 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
|
|||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
allowNets: "198.51.100.0/24",
|
allowNets: "198.51.100.0/24",
|
||||||
denyNets: fromKP,
|
denyNets: fromKP,
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
// Neither SWW_DENY_NETS, SWW_TAG_DENY nor the limit of one request a
|
// Neither SWW_DENY_NETS, the country lists nor the limit of one
|
||||||
// minute refuses the client, and its country is not looked up.
|
// request a minute refuses the client, and its country is not looked
|
||||||
|
// up.
|
||||||
wantAnswers(t, addr, out, []sentRequest{
|
wantAnswers(t, addr, out, []sentRequest{
|
||||||
{fromKP, http.StatusOK, requestlog.ActionForward},
|
{fromKP, http.StatusOK, requestlog.ActionForward},
|
||||||
{fromKP, http.StatusOK, requestlog.ActionForward},
|
{fromKP, http.StatusOK, requestlog.ActionForward},
|
||||||
@@ -68,7 +69,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},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +83,9 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
|
|||||||
})
|
})
|
||||||
geojsURL, asked := startGeoJS(t)
|
geojsURL, asked := startGeoJS(t)
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
denyNets: "203.0.113.0/24",
|
denyNets: "203.0.113.0/24",
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
|
|
||||||
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
||||||
@@ -92,7 +93,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 +109,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 +126,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},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,19 +142,19 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitExemptNets: listedAddr + "," + fromKP,
|
rateLimitExemptNets: listedAddr + "," + fromKP,
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
// listedAddr's requests are neither refused nor counted, so the first
|
// listedAddr's requests are neither refused nor counted, so the first
|
||||||
// request from unlistedAddr is within the limit of one a minute.
|
// request from unlistedAddr is within the limit of one a minute. The
|
||||||
// SWW_TAG_DENY still refuses an exempt client.
|
// country lists still refuse an exempt client.
|
||||||
wantAnswers(t, addr, out, []sentRequest{
|
wantAnswers(t, addr, out, []sentRequest{
|
||||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
||||||
{fromKP, http.StatusForbidden, requestlog.ActionClientDenied},
|
{fromKP, http.StatusForbidden, requestlog.ActionCountryDenied},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic tag_deny. It reads SWW_TAG_DENY.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"slices"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// tagDeny refuses a request that carries a tag in SWW_TAG_DENY. Its figure is
|
|
||||||
// the first such tag, in sorted order.
|
|
||||||
type tagDeny struct{}
|
|
||||||
|
|
||||||
func (tagDeny) name() string { return "tag_deny" }
|
|
||||||
|
|
||||||
func (tagDeny) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|
||||||
for _, tag := range rq.line.Tags {
|
|
||||||
if slices.Contains(rq.h.config.TagDeny, tag) {
|
|
||||||
return &verdict{
|
|
||||||
action: requestlog.ActionClientDenied,
|
|
||||||
figures: map[string]any{"tag": tag},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package discriminator_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// The setting takes a country code in either case; the request's tag is
|
|
||||||
// in capitals.
|
|
||||||
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"})
|
|
||||||
|
|
||||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
|
|
||||||
wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`)
|
|
||||||
wantTags(t, line, "asn=64511", "country=KP")
|
|
||||||
|
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagDenyRefusesAnASNTag(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"})
|
|
||||||
|
|
||||||
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
|
|
||||||
"tag_deny", `{"tag": "asn=64511"}`)
|
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagDenyRefusesAListTagBeforeTheRateLimitsCount(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
|
||||||
tagLists: "torexit=" + torList, tagDeny: "torexit", rateLimitPerMinute: "1",
|
|
||||||
})
|
|
||||||
loadLists(t, server, map[string][]string{torList: {fromDE}})
|
|
||||||
|
|
||||||
// Each is refused for its tag: none is counted to break the limit of one.
|
|
||||||
for range 3 {
|
|
||||||
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
|
|
||||||
"tag_deny", `{"tag": "torexit"}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const mixedCaseZone = "DNSBL.example"
|
|
||||||
|
|
||||||
// The zone's tag is in lower case, however SWW_DNSBL_ZONES writes it.
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
|
||||||
dnsblZones: mixedCaseZone, dnsblResolver: noResolver,
|
|
||||||
reputationAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
|
|
||||||
})
|
|
||||||
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
|
|
||||||
|
|
||||||
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
|
|
||||||
"tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
|
||||||
rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature",
|
|
||||||
})
|
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
|
|
||||||
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
|
||||||
wantTags(t, line, "exploitsignature")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
|
||||||
rateLimitPerMinute: fourAMinute,
|
|
||||||
tagLists: "vpn=" + vpnList + ",tor=" + torList,
|
|
||||||
tagLimitPercent: "vpn:50,tor:25",
|
|
||||||
})
|
|
||||||
loadLists(t, server, map[string][]string{vpnList: {fromDE}, torList: {fromDE}})
|
|
||||||
|
|
||||||
// A quarter of 4 requests a minute: the second breaks the limit.
|
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
|
|
||||||
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
|
||||||
"25 from "+tagLimitPercent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
|
||||||
bytesLimitPerMinute: twoUploads,
|
|
||||||
tagLists: "vpn=" + vpnList,
|
|
||||||
tagLimitPercent: "vpn:50",
|
|
||||||
})
|
|
||||||
loadLists(t, server, map[string][]string{vpnList: {fromDE}})
|
|
||||||
|
|
||||||
// The upload's 100 bytes are over half of 199, 99.
|
|
||||||
line := s.uploadFrom(fromDE)
|
|
||||||
if line.LimitHit != minuteBytes {
|
|
||||||
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
|
||||||
"50 from "+tagLimitPercent)
|
|
||||||
|
|
||||||
// fromKP, which the list does not hold, has the whole limit.
|
|
||||||
line = s.uploadFrom(fromKP)
|
|
||||||
if line.LimitHit != "" {
|
|
||||||
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, none)
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic tag_lists. It reads the address lists of SWW_TAG_LISTS through
|
|
||||||
// the reputation lists.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// tagLists adds to the request one tag for each list of SWW_TAG_LISTS that
|
|
||||||
// holds the client, the tag being the list's name. It refuses nothing, and runs
|
|
||||||
// first so that every refusal's log line carries these tags.
|
|
||||||
type tagLists struct{}
|
|
||||||
|
|
||||||
func (tagLists) name() string { return "tag_lists" }
|
|
||||||
|
|
||||||
func (tagLists) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|
||||||
for _, name := range rq.h.lists.TagsOf(rq.client) {
|
|
||||||
rq.addTag(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package discriminator_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// tagLists is SWW_TAG_LISTS, and vpnList and torList lists it names.
|
|
||||||
const (
|
|
||||||
tagLists = "SWW_TAG_LISTS"
|
|
||||||
vpnList = "https://lists.example/vpn.txt"
|
|
||||||
torList = "https://lists.example/torexit.txt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// wantTags checks the log line's tags.
|
|
||||||
func wantTags(t *testing.T, line logLine, want ...string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
got, _ := line.fields["tags"].([]any)
|
|
||||||
|
|
||||||
tags := make([]string, 0, len(got))
|
|
||||||
for _, tag := range got {
|
|
||||||
text, _ := tag.(string)
|
|
||||||
tags = append(tags, text)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !slices.Equal(tags, want) {
|
|
||||||
t.Errorf("log line has tags %v, want %v", tags, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLookupAndTagListsGiveTheRequestItsTagsSorted(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
|
||||||
tagLists: "vpn=mullvad=" + vpnList + ",TorExit=" + torList,
|
|
||||||
})
|
|
||||||
loadLists(t, server, map[string][]string{
|
|
||||||
vpnList: {fromDE}, torList: {fromDE + "/32", fromKP},
|
|
||||||
})
|
|
||||||
|
|
||||||
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
|
||||||
"asn=64496", "country=DE", "torexit", "vpn=mullvad")
|
|
||||||
wantTags(t, s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
|
||||||
"asn=64511", "country=KP", "torexit")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestWithoutATagLeavesTheFieldOut(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// With no lookup, a client is not placed and no setting gives it a tag.
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{lookupSource: "off"})
|
|
||||||
|
|
||||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
if _, present := line.fields["tags"]; present {
|
|
||||||
t.Errorf("log line has tags %v, want none", line.fields["tags"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagListsTagEvenARequestTheCountryListRefuses(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
|
||||||
tagLists: "vpn=" + vpnList, allowedCountries: "de",
|
|
||||||
})
|
|
||||||
loadLists(t, server, map[string][]string{vpnList: {fromKP}})
|
|
||||||
|
|
||||||
wantTags(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
|
||||||
"asn=64511", "country=KP", "vpn")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
|
||||||
tagLists: "vpn=" + vpnList, denyNets: fromKP, rateLimitPerMinute: "1",
|
|
||||||
})
|
|
||||||
loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}})
|
|
||||||
|
|
||||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
|
|
||||||
if line.Heuristic != "deny_nets" {
|
|
||||||
t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantTags(t, line, "vpn")
|
|
||||||
|
|
||||||
// The second request breaks the limit of one a minute, which bans the client.
|
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
|
||||||
|
|
||||||
line = s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
|
||||||
if line.Heuristic != "active_ban" {
|
|
||||||
t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantTags(t, line, "vpn")
|
|
||||||
}
|
|
||||||
@@ -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},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
+4
-14
@@ -133,11 +133,8 @@ func (f *File) Watch(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.params.ProcessLog.Error(
|
f.params.ProcessLog.Error("cannot watch the lookup database for replacements",
|
||||||
"cannot watch the lookup database for replacements",
|
"error", err.Error())
|
||||||
"error",
|
|
||||||
err.Error(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -197,10 +194,7 @@ func (f *File) readAgain() {
|
|||||||
f.params.Alerts.Raise(alerts.Alert{
|
f.params.Alerts.Raise(alerts.Alert{
|
||||||
Event: alerts.EventFileError,
|
Event: alerts.EventFileError,
|
||||||
Reason: kept,
|
Reason: kept,
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{"file": f.params.Path, "error": err.Error()},
|
||||||
"file": f.params.Path,
|
|
||||||
"error": err.Error(),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
f.params.ProcessLog.Error(kept, "error", err.Error())
|
f.params.ProcessLog.Error(kept, "error", err.Error())
|
||||||
|
|
||||||
@@ -218,11 +212,7 @@ func (f *File) use(reader *maxminddb.Reader) {
|
|||||||
f.lastRead = f.params.Now()
|
f.lastRead = f.params.Now()
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
|
||||||
f.params.ProcessLog.Info(
|
f.params.ProcessLog.Info("read the lookup database", "file", f.params.Path)
|
||||||
"read the lookup database",
|
|
||||||
"file",
|
|
||||||
f.params.Path,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// read reads the lookup database at path. The whole file is read into
|
// read reads the lookup database at path. The whole file is read into
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type busiest struct {
|
|||||||
requests *prometheus.CounterVec
|
requests *prometheus.CounterVec
|
||||||
requestBytes *prometheus.CounterVec
|
requestBytes *prometheus.CounterVec
|
||||||
responseBytes *prometheus.CounterVec
|
responseBytes *prometheus.CounterVec
|
||||||
// refused are, by country, the requests the country list refused; nil
|
// refused are, by country, the requests the country lists refused; nil
|
||||||
// by AS number.
|
// by AS number.
|
||||||
refused *prometheus.CounterVec
|
refused *prometheus.CounterVec
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ type busiest struct {
|
|||||||
func newCountries(topN int) *busiest {
|
func newCountries(topN int) *busiest {
|
||||||
countries := newBusiest(topN, "country", "the client's country")
|
countries := newBusiest(topN, "country", "the client's country")
|
||||||
countries.refused = counterVec("sww_country_list_refusals_total",
|
countries.refused = counterVec("sww_country_list_refusals_total",
|
||||||
"Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
|
"Requests the country lists refused, by the client's country.",
|
||||||
[]string{"country"})
|
[]string{"country"})
|
||||||
|
|
||||||
return countries
|
return countries
|
||||||
@@ -88,7 +88,7 @@ func (b *busiest) Collect(ch chan<- prometheus.Metric) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// vecs returns the metrics: by AS number, those of requests and bytes; by
|
// vecs returns the metrics: by AS number, those of requests and bytes; by
|
||||||
// country, the refusals by the country list as well.
|
// country, the refusals by the country lists as well.
|
||||||
func (b *busiest) vecs() []*prometheus.CounterVec {
|
func (b *busiest) vecs() []*prometheus.CounterVec {
|
||||||
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
|
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
|
||||||
if b.refused != nil {
|
if b.refused != nil {
|
||||||
|
|||||||
@@ -424,10 +424,10 @@ func (m *Metrics) LimitHit(hit ratelimit.Hit) {
|
|||||||
// them.
|
// them.
|
||||||
func (m *Metrics) Offences(r ratelimit.Request) {
|
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,
|
||||||
"waf_blocked": r.WAFBlocked,
|
"waf_blocked": r.WAFBlocked,
|
||||||
} {
|
} {
|
||||||
if committed {
|
if committed {
|
||||||
m.offences.WithLabelValues(kind).Inc()
|
m.offences.WithLabelValues(kind).Inc()
|
||||||
|
|||||||
@@ -122,12 +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 WAFBlocked those the Core Rule Set refused.
|
// rule refused, and WAFBlocked those the Core Rule Set 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"`
|
||||||
WAFBlocked int64 `json:"waf_blocked"`
|
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"`
|
||||||
@@ -147,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 WAFBlocked 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
|
||||||
WAFBlocked 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
|
||||||
@@ -323,8 +323,8 @@ 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.WAFBlocked {
|
if r.WAFBlocked {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
// Package reputation fetches the lists the settings name by URL: the
|
// Package reputation fetches the lists the settings name by URL: the
|
||||||
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
|
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
|
||||||
// SWW_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_TAG_LISTS, and
|
// SWW_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
|
||||||
// the decision list of the CrowdSec engine SWW_CROWDSEC_LAPI_URL names. It
|
// engine SWW_CROWDSEC_LAPI_URL names. It keeps the last good copy of
|
||||||
// keeps the last good copy of
|
|
||||||
// each, whole, comment lines included, which is used while a fetch fails,
|
// each, whole, comment lines included, which is used while a fetch fails,
|
||||||
// and when each was last tried. It also asks the DNSBL zones of
|
// and when each was last tried. It also asks the DNSBL zones of
|
||||||
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
||||||
@@ -72,8 +71,6 @@ type Params struct {
|
|||||||
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
||||||
BlocklistURLs []string
|
BlocklistURLs []string
|
||||||
ASNLimitPercentURL string
|
ASNLimitPercentURL string
|
||||||
// TagLists are the address lists that add a tag (SWW_TAG_LISTS).
|
|
||||||
TagLists []config.TagList
|
|
||||||
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
||||||
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
||||||
// (SWW_CROWDSEC_LAPI_KEY).
|
// (SWW_CROWDSEC_LAPI_KEY).
|
||||||
@@ -115,11 +112,11 @@ type list struct {
|
|||||||
failures int
|
failures int
|
||||||
}
|
}
|
||||||
|
|
||||||
// entries are what the lines of a copy say: for a blocklist or a tag list, the
|
// entries are what the lines of a copy say: for a blocklist, the netblocks
|
||||||
// netblocks it names, with the lengths among them, for the file of AS:percent
|
// it names, with the lengths among them, for the file of AS:percent lines,
|
||||||
// lines, the percentage it gives each AS number, and for the CrowdSec decision
|
// the percentage it gives each AS number, and for the CrowdSec decision
|
||||||
// list, the decision on each netblock that ends last, with the lengths among
|
// list, the decision on each netblock that ends last, with the lengths
|
||||||
// them.
|
// among them.
|
||||||
type entries struct {
|
type entries struct {
|
||||||
netblocks map[netip.Prefix]bool
|
netblocks map[netip.Prefix]bool
|
||||||
lengths []int
|
lengths []int
|
||||||
@@ -155,18 +152,14 @@ func New(params Params) *Lists {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// URLs returns the URL of every list: the blocklists' in the order
|
// URLs returns the URL of every list: the blocklists' in the order
|
||||||
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then the
|
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then
|
||||||
// tag lists', then the CrowdSec decision list's.
|
// the CrowdSec decision list's.
|
||||||
func (l *Lists) URLs() []string {
|
func (l *Lists) URLs() []string {
|
||||||
urls := slices.Clone(l.params.BlocklistURLs)
|
urls := slices.Clone(l.params.BlocklistURLs)
|
||||||
if l.params.ASNLimitPercentURL != "" {
|
if l.params.ASNLimitPercentURL != "" {
|
||||||
urls = append(urls, l.params.ASNLimitPercentURL)
|
urls = append(urls, l.params.ASNLimitPercentURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, list := range l.params.TagLists {
|
|
||||||
urls = append(urls, list.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if l.params.CrowdSecDecisionsURL != "" {
|
if l.params.CrowdSecDecisionsURL != "" {
|
||||||
urls = append(urls, l.params.CrowdSecDecisionsURL)
|
urls = append(urls, l.params.CrowdSecDecisionsURL)
|
||||||
}
|
}
|
||||||
@@ -191,22 +184,6 @@ func (l *Lists) ListedBy(addr netip.Addr) []string {
|
|||||||
return listedBy
|
return listedBy
|
||||||
}
|
}
|
||||||
|
|
||||||
// TagsOf returns the names of the tag lists whose copy holds addr.
|
|
||||||
func (l *Lists) TagsOf(addr netip.Addr) []string {
|
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
|
|
||||||
var names []string
|
|
||||||
|
|
||||||
for _, list := range l.params.TagLists {
|
|
||||||
if l.lists[list.URL].entries.contain(addr) {
|
|
||||||
names = append(names, list.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
// ASNLimitPercent returns the percentage the copy of the file of
|
// ASNLimitPercent returns the percentage the copy of the file of
|
||||||
// AS:percent lines gives asn, and whether it lists asn.
|
// AS:percent lines gives asn, and whether it lists asn.
|
||||||
func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
|
func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
|
||||||
@@ -504,10 +481,10 @@ func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parse reads the lines of the list at listURL, fetched at fetched: those
|
// parse reads the lines of the list at listURL, fetched at fetched: those
|
||||||
// of a blocklist or a tag list, of the file of AS:percent lines, or of the
|
// of a blocklist, of the file of AS:percent lines, or of the CrowdSec
|
||||||
// CrowdSec decision list. In all but the decision list, anything after a ; or
|
// decision list. In the first two, anything after a ; or a # on a line is
|
||||||
// a # on a line is left out, and so is a line left blank. Any other line that
|
// left out, and so is a line left blank. Any other line that does not read
|
||||||
// does not read is an error naming it by its number.
|
// is an error naming it by its number.
|
||||||
func (l *Lists) parse(
|
func (l *Lists) parse(
|
||||||
listURL string, lines []string, fetched time.Time,
|
listURL string, lines []string, fetched time.Time,
|
||||||
) (entries, error) {
|
) (entries, error) {
|
||||||
@@ -521,8 +498,8 @@ func (l *Lists) parse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseNetblocks reads the lines of a blocklist or a tag list, each an address
|
// parseNetblocks reads a blocklist's lines, each an address or a netblock
|
||||||
// or a netblock as the settings take them.
|
// as the settings take them.
|
||||||
func parseNetblocks(lines []string) (entries, error) {
|
func parseNetblocks(lines []string) (entries, error) {
|
||||||
found := entries{netblocks: map[netip.Prefix]bool{}}
|
found := entries{netblocks: map[netip.Prefix]bool{}}
|
||||||
|
|
||||||
@@ -574,7 +551,7 @@ func parseNetblock(text string) (netip.Prefix, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parsePercents reads the lines of the file of AS:percent lines, each an
|
// parsePercents reads the lines of the file of AS:percent lines, each an
|
||||||
// AS number, : and a percentage, such as AS64496:50. An
|
// AS number, : and a percentage, as SWW_ASN_LIMIT_PERCENT takes them. An
|
||||||
// AS number listed more than once gets the lowest of its percentages.
|
// AS number listed more than once gets the lowest of its percentages.
|
||||||
func parsePercents(lines []string) (entries, error) {
|
func parsePercents(lines []string) (entries, error) {
|
||||||
found := entries{percents: map[string]int64{}}
|
found := entries{percents: map[string]int64{}}
|
||||||
|
|||||||
@@ -19,41 +19,28 @@ const (
|
|||||||
// ActionForward is a request discriminator allowed, which rproxy passes to
|
// ActionForward is a request discriminator allowed, which rproxy passes to
|
||||||
// the app.
|
// the app.
|
||||||
ActionForward = "forward"
|
ActionForward = "forward"
|
||||||
|
// ActionTooLarge is a request over the header limits.
|
||||||
// ActionRequestHeaderTooLarge is a request over the header limits.
|
ActionTooLarge = "too_large"
|
||||||
ActionRequestHeaderTooLarge = "request_header_too_large"
|
// ActionRateLimited is a request refused because it took its client
|
||||||
|
// over a rate limit, which bans the client.
|
||||||
// ActionClientIsRateLimited is a request refused because it took its
|
ActionRateLimited = "rate_limited"
|
||||||
// client over a rate limit, which bans the client.
|
// ActionBanned is a request refused because a ban covers its client,
|
||||||
ActionClientIsRateLimited = "client_is_rate_limited"
|
// or because it matched a ban rule, asked for a trap path or the
|
||||||
|
// CrowdSec decision list lists its client, each of which bans the
|
||||||
// ActionTemporarilyBanned is a request refused because a ban that runs
|
// client.
|
||||||
// out covers its client, or because it matched a ban rule, asked for a
|
ActionBanned = "banned"
|
||||||
// trap path or the CrowdSec decision list lists its client, and so made
|
// ActionRuleBlocked is a request refused because it matched a block
|
||||||
// such a ban.
|
// rule.
|
||||||
ActionTemporarilyBanned = "temporarily_banned"
|
ActionRuleBlocked = "rule_blocked"
|
||||||
|
|
||||||
// ActionPermanentlyBanned is a request refused because a ban that never
|
|
||||||
// runs out covers its client, or because the request made such a ban or
|
|
||||||
// made its client's ban permanent.
|
|
||||||
ActionPermanentlyBanned = "permanently_banned"
|
|
||||||
|
|
||||||
// ActionBlockedByBlockRule is a request refused because it matched a
|
|
||||||
// block rule.
|
|
||||||
ActionBlockedByBlockRule = "blocked_by_block_rule"
|
|
||||||
|
|
||||||
// ActionWAFBlocked is a request refused because the Core Rule Set
|
// ActionWAFBlocked is a request refused because the Core Rule Set
|
||||||
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
|
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
ActionWAFBlocked = "waf_blocked"
|
ActionWAFBlocked = "waf_blocked"
|
||||||
|
// ActionDenied is a request refused because its client is in
|
||||||
// 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.
|
||||||
ActionClientDenied = "client_denied"
|
ActionDenied = "denied"
|
||||||
|
// ActionCountryDenied is a request refused for its client's country.
|
||||||
// ActionCountryDenied is a request refused because its client's country
|
|
||||||
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
|
||||||
ActionCountryDenied = "country_denied"
|
ActionCountryDenied = "country_denied"
|
||||||
// ActionAdmin is a request discriminator answered at one of its own
|
// ActionAdmin is a request discriminator answered at one of its own
|
||||||
// endpoints, or a question about a path under /_sww/.
|
// endpoints, or a question about a path under /_sww/.
|
||||||
@@ -139,10 +126,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, ActionWAFBlocked,
|
// ActionRuleBlocked, ActionWAFBlocked, or, in discriminator, ActionTooLarge
|
||||||
// or, in discriminator, ActionRequestHeaderTooLarge for the header limits.
|
// for the header limits.
|
||||||
WouldAction string `json:"would_action,omitempty"`
|
WouldAction string `json:"would_action,omitempty"`
|
||||||
// Heuristic is the name of the heuristic that refused the request, in
|
// 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
|
||||||
@@ -185,8 +172,6 @@ type Line struct {
|
|||||||
// whose verdict lists it, their keys masked, then abuseipdb when its
|
// whose verdict lists it, their keys masked, then abuseipdb when its
|
||||||
// score is a hit.
|
// score is a hit.
|
||||||
Reputation []string `json:"reputation,omitempty"`
|
Reputation []string `json:"reputation,omitempty"`
|
||||||
// Tags are the request's tags, sorted.
|
|
||||||
Tags []string `json:"tags,omitempty"`
|
|
||||||
// Offence is the offence the request was held as, OffenceLimit.
|
// Offence is the offence the request was held as, OffenceLimit.
|
||||||
Offence string `json:"offence,omitempty"`
|
Offence string `json:"offence,omitempty"`
|
||||||
// BanExpires is when the ban the request made, or was refused under,
|
// BanExpires is when the ban the request made, or was refused under,
|
||||||
@@ -270,11 +255,7 @@ func FormatTime(t time.Time) string {
|
|||||||
|
|
||||||
// Milliseconds is d in milliseconds, to the microsecond.
|
// Milliseconds is d in milliseconds, to the microsecond.
|
||||||
func Milliseconds(d time.Duration) float64 {
|
func Milliseconds(d time.Duration) float64 {
|
||||||
return float64(
|
return float64(d.Microseconds()) / float64(time.Millisecond/time.Microsecond)
|
||||||
d.Microseconds(),
|
|
||||||
) / float64(
|
|
||||||
time.Millisecond/time.Microsecond,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProcessLogger returns the logger for the process's own messages:
|
// NewProcessLogger returns the logger for the process's own messages:
|
||||||
@@ -282,25 +263,17 @@ func Milliseconds(d time.Duration) float64 {
|
|||||||
// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
|
// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
|
||||||
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
|
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
|
||||||
// the request lines Write writes are never held back.
|
// the request lines Write writes are never held back.
|
||||||
func NewProcessLogger(
|
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
|
||||||
w io.Writer,
|
|
||||||
instanceName string,
|
|
||||||
level slog.Level,
|
|
||||||
) *slog.Logger {
|
|
||||||
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
||||||
Level: level,
|
Level: level,
|
||||||
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
||||||
if attr.Key == slog.TimeKey && len(groups) == 0 {
|
if attr.Key == slog.TimeKey && len(groups) == 0 {
|
||||||
return slog.String(
|
return slog.String(slog.TimeKey, FormatTime(attr.Value.Time()))
|
||||||
slog.TimeKey,
|
|
||||||
FormatTime(attr.Value.Time()),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return attr
|
return attr
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return slog.New(handler).
|
return slog.New(handler).With("type", "process", "instance", instanceName)
|
||||||
With("type", "process", "instance", instanceName)
|
|
||||||
}
|
}
|
||||||
+14
-15
@@ -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) {
|
||||||
@@ -611,14 +611,14 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
|
|||||||
writeLookupDatabase(t, path, "DE")
|
writeLookupDatabase(t, path, "DE")
|
||||||
|
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
lookupSource: "file",
|
lookupSource: "file",
|
||||||
lookupDBPath: path,
|
lookupDBPath: path,
|
||||||
"SWW_TAG_DENY": "country=KP",
|
"SWW_DENIED_COUNTRIES": "kp",
|
||||||
// The requests sent until a replacement takes effect, and those
|
// The requests sent until a replacement takes effect, and those
|
||||||
// for the metrics, must not break a rate limit, whose ban would
|
// for the metrics, must not break a rate limit, whose ban would
|
||||||
// refuse them too.
|
// refuse them too.
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1281,7 +1280,7 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
|
|||||||
"SWW_RATE_LIMIT_PER_HOUR": "1500",
|
"SWW_RATE_LIMIT_PER_HOUR": "1500",
|
||||||
rateLimitPerDay: "6000",
|
rateLimitPerDay: "6000",
|
||||||
"SWW_RATE_LIMIT_EXEMPT_PATHS": "",
|
"SWW_RATE_LIMIT_EXEMPT_PATHS": "",
|
||||||
"SWW_TAG_DENY": "",
|
"SWW_DENIED_COUNTRIES": "",
|
||||||
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
||||||
"SWW_BAN_RESPONSE": "403",
|
"SWW_BAN_RESPONSE": "403",
|
||||||
"SWW_LIMIT_BAN_DURATION": "1h",
|
"SWW_LIMIT_BAN_DURATION": "1h",
|
||||||
|
|||||||
+4
-5
@@ -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
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/publish: build the image on the Docker host and push it to the
|
|
||||||
# registry under four tags: the UTC date (YYYY-MM-DD), the short commit
|
|
||||||
# hash, the branch name and latest. The build is script/docker's, so the
|
|
||||||
# tests and the linter run first. The host and the registry name are
|
|
||||||
# environment variables with defaults, for running it against another.
|
|
||||||
#
|
|
||||||
# The working tree must be clean: the hash tag names a commit, and an
|
|
||||||
# image built from uncommitted changes would carry a hash that does not
|
|
||||||
# describe it.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
||||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|
||||||
|
|
||||||
: "${DOCKER_HOST:=ssh://root@fsn1app1}"
|
|
||||||
: "${PUBLISH_IMAGE:=git.eeqj.de/sneak/smallwebwaf}"
|
|
||||||
export DOCKER_HOST
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
|
|
||||||
# Own line: a failing command substitution inside an argument does
|
|
||||||
# not trip `set -e`.
|
|
||||||
changes="$(git status --porcelain)"
|
|
||||||
if [ -n "$changes" ]; then
|
|
||||||
echo "script/publish: the working tree is not clean; commit or stash first" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
date="$(date -u +%Y-%m-%d)"
|
|
||||||
hash="$(git rev-parse --short HEAD)"
|
|
||||||
branch="$(git rev-parse --abbrev-ref HEAD)"
|
|
||||||
if [ "$branch" = "HEAD" ]; then
|
|
||||||
echo "script/publish: detached HEAD; check out a branch first" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# A tag is [A-Za-z0-9_.-]; a branch name such as feature/x is not.
|
|
||||||
branch="$(printf '%s' "$branch" | tr -c 'A-Za-z0-9_.-' '-')"
|
|
||||||
|
|
||||||
local_tag="$("$SCRIPT_DIR/projectname")"
|
|
||||||
"$SCRIPT_DIR/docker"
|
|
||||||
|
|
||||||
for tag in "$date" "$hash" "$branch" latest; do
|
|
||||||
docker tag "$local_tag" "$PUBLISH_IMAGE:$tag"
|
|
||||||
done
|
|
||||||
for tag in "$date" "$hash" "$branch" latest; do
|
|
||||||
docker push "$PUBLISH_IMAGE:$tag"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "pushed $PUBLISH_IMAGE with tags: $date $hash $branch latest"
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
Reference in new issue
Block a user