1 Commits
Author SHA1 Message Date
sneak 60630859af Request tags, with refusing and limits per tag (closes #175)
check / check (push) Successful in 3m36s
Each request gets sorted tags from the lookup, new address lists, the
reputation sources and the rule matches, logged as `tags`. New settings:
`SWW_TAG_LISTS`, `SWW_TAG_DENY`, `SWW_TAG_LIMIT_PERCENT`. They replace
`SWW_ASN_LIMIT_PERCENT`, `SWW_COUNTRY_LIMIT_PERCENT` and
`SWW_DENIED_COUNTRIES`. New heuristics `tag_lists` and `tag_deny`; the
latter runs before the rate limits and again after the Core Rule Set.

Model: opus-5-5
2026-11-09 00:39:44 +00:00
29 changed files with 271 additions and 754 deletions

No files matched your search

+44 -56
View File
@@ -12,8 +12,8 @@ configured only by environment variables, that provides:
- R1: rate limits per minute, per hour and per day
- R2: netblocks that bypass rate limiting
- R3: alerts on attacks through webhook, Slack or ntfy
- R4: alerts on anomalies: requests made or bytes transferred per minute or
hour, for given IPs or netblocks, crossing a threshold
- R4: alerts on anomalies: requests or bytes per minute or hour, for given IPs
or netblocks, crossing a threshold
- R5: temporary blocks for abusers, permanent bans for repeat offenders
- R6: one or more RBLs or IP reputation APIs
- R7: AS number lookup
@@ -24,19 +24,14 @@ configured only by environment variables, that provides:
## Verdict
Nothing existing is suitable. No surveyed tool meets R8, which asks for limits
scaled by AS number or country. None meets R4's alerts on bytes transferred. The
tools that come closest on the rest fail R10: they need several containers, YAML
or a web UI. The recommendation is to build a small single-binary sidecar;
`SPEC.md` in this directory begins that spec.
The sidecar reuses the mature parts of the surveyed field instead of
reimplementing them. It takes these as libraries and data sources:
- Coraza and the OWASP Core Rule Set, for attack detection
- DNSBLs
- AbuseIPDB
- optionally, a CrowdSec decision feed
Nothing existing is suitable. No surveyed tool meets R8 (scaling limits by AS
number or country) or the bytes half of R4 at all, and the tools that come
closest on the rest fail R10: they need several containers, YAML or a web UI.
The recommendation is to build a small single-binary sidecar; `SPEC.md` in this
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
detection, DNSBLs, AbuseIPDB, optionally a CrowdSec decision feed) instead of
reimplementing them.
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
blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule
Set, or Coraza plugin), env-var settings.
- Fails:
- R8: AS number and country can only allow or deny, never scale a limit.
- R4: no alerts on request-count or byte thresholds in the free edition;
reporting is a paid feature.
- R5: no escalation for repeat offenders.
- R10: since 1.6 it is not one plain sidecar. It needs a `bunkerweb`
container, a `bw-scheduler` container and a database, or the
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.
- Fails: R8 (AS number and country can only allow or deny, never scale a
limit), R4 (no volume or byte threshold alerts in the free edition;
reporting is a paid feature), R5 escalation, and R10 in spirit: since 1.6
it needs a `bunkerweb` container plus a `bw-scheduler` container and a
database, or the all-in-one image that bundles nginx, scheduler, UI and
Redis in one container. It is designed to be the front door for many
sites, not a per-app sidecar. AGPL-3.0.
- Unverified: whether several rates (minute, hour, day) can be stacked on
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.
@@ -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,
it reacts after the fact by banning, and it is not an inline limiter that
answers 429.
- Fails:
- R10, because it needs:
- the security engine container with persistent state
- log acquisition from traefik
- a bouncer such as the traefik plugin
- YAML for acquisition, profiles, scenarios and notifications
- R4's alerts on bytes.
- Fails: R10 (needs the security engine container with persistent state, log
acquisition from traefik, a bouncer such as the traefik plugin, and YAML
for acquisition, profiles, scenarios and notifications), bytes half of R4.
- CrowdSec plus traefik's own `rateLimit` middleware is the best combination
with no new code. It gives inline limiting (one window per middleware, keyed
by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and
reputation lookups. It still fails R8, R4's alerts on bytes, and R10 entirely:
the configuration lives in traefik labels and CrowdSec YAML on each host, not
in one sidecar's environment. It meets R1's three windows only by chaining
three middlewares per router.
reputation. It still fails R8, the bytes half of R4, R1's three stacked
windows only by chaining three middlewares per router, and R10 entirely: the
configuration lives in traefik labels and CrowdSec YAML on each host, not in
one sidecar's environment.
## Candidate by candidate
@@ -99,10 +87,10 @@ Closest existing options, and why each still falls short:
cached, streamed decision list (recommended), standalone against the
central API, or AppSec only. Supports captcha remediation.
- 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
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.
- Maturity: widely used (about 900 stars, listed in the traefik plugin
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
semantic detection engine and a web console. About 22,000 stars.
- 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
messages, syslog forwarding and the better reputation database are paid
(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
- What it is: a Go library that implements the ModSecurity rule language and
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),
`coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and
`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`)
- 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`,
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.
- Covers: R9, and R10 (single container, env vars, one backend).
- Misses: R1 to R8.
@@ -164,8 +152,8 @@ Closest existing options, and why each still falls short:
- Anubis (`TecharoHQ/anubis`), 1.27 current
- What it is: a single-binary reverse proxy that makes browsers solve a
proof-of-work challenge before passing them to `TARGET`. Aimed at
scrapers, which are likely a large share of the unwanted traffic on a
public code forge.
scrapers, which is likely a large share of unwanted traffic on a public
code forge.
- Covers: R10 well (one container, env vars for listener, target,
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
@@ -192,13 +180,13 @@ Closest existing options, and why each still falls short:
temporary ban, no escalation, state lost on traefik restart) and R2
(allowlist). Nothing else.
- `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock
plugins (country allow or deny through an external lookup API): each adds
one small feature, is configured in traefik and keeps its state in memory.
plugins (country allow or deny through an external lookup API): same
shape, each one small feature, configured in traefik, state in memory.
- Sidecar fit: no; they live inside traefik.
- Traefik built-in middlewares
- `rateLimit` (one average-and-burst window per middleware, optional Redis
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
- 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
@@ -210,12 +198,12 @@ Closest existing options, and why each still falls short:
- open-appsec (Check Point)
- 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;
rate limiting and richer features are in paid tiers. It attaches to an
existing proxy, so it is not the sidecar R10 asks for.
rate limiting and richer features are in paid tiers. Not a sidecar in the
required sense.
- iocaine
- 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
passing interest for scraper traffic.
scrapers. Not a limiter, WAF or ban tool; out of scope except as a
curiosity for scraper traffic.
- Pangolin
- A tunnelled access platform that bundles traefik and optionally CrowdSec.
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.
- R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins.
- R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid).
- R4 alerts on request counts and bytes: none. CrowdSec can approximate alerts
on request counts with a custom scenario; no surveyed tool alerts on bytes.
- R5 temporary then permanent: CrowdSec only. BunkerWeb's ban is either one
fixed length or permanent, and does not change with a client's history.
- R4 volume and byte anomalies: none. CrowdSec can approximate request-count
anomalies with a custom scenario; nothing handles bytes.
- R5 temporary then permanent: CrowdSec only. BunkerWeb has fixed or permanent,
not both by history.
- R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists).
- R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or
deny), Anubis (hosted service).
- R8 biased thresholds: none.
- R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open,
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps Coraza
or ModSecurity).
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps the
first).
- R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis.
None of these covers anything beyond its one job.
+3 -7
View File
@@ -1,12 +1,11 @@
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \
hooks build run example-app
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
run example-app
# Makefile targets are thin shims; the implementations live in script/
# 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,
# 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
# the image on the Docker host and pushes it to the registry.
# example-app checks the image with an app built on it.
bootstrap:
@script/bootstrap
@@ -35,9 +34,6 @@ check:
docker:
@script/docker
publish:
@script/publish
hooks:
@script/install-precommit
+40 -55
View File
@@ -25,11 +25,11 @@ without an answer in time, `rproxy` passes the request to the app.
It protects against:
- Scrapers that fetch every page of a site, all day. Each client gets a limit on
requests and on body bytes per minute, per hour and per day. The defaults
leave room for a person reading a site for hours. They catch a scraper that
keeps up one request every 2 seconds. A client that breaks a limit is banned:
for a while at first, for three times as long at each repeat, and permanently
when it keeps at it.
requests and on bytes per minute, per hour and per day. The defaults leave
room for a person reading a site for hours. They catch a scraper that keeps up
one request every 2 seconds. A client over a limit is refused and banned: for
a while at first, for three times as long at each repeat, and permanently when
it keeps at it.
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
default rule file bans such a client at its first probe, and its next request
during the ban makes the ban permanent. Your own rules, or a list of trap
@@ -68,9 +68,8 @@ Not built, and not planned:
### The image
The image is published at `git.eeqj.de/sneak/smallwebwaf`. You can also build it
from a clone. `make docker` runs the tests and the linter, then builds the
image, tagged `smallwebwaf`.
No image is published. Build it from a clone. `make docker` runs the tests and
the linter, then builds the image, tagged `smallwebwaf`.
```sh
git clone https://git.eeqj.de/sneak/smallwebwaf.git
@@ -210,10 +209,6 @@ account, a key, a token or an alert destination stays off until you give one.
percentage is a whole number from 0 to 100. A path prefix starts with `/`.
- `off` switches a limit, a threshold or a timeout off where the table says a
setting takes it.
- The byte limits and the byte anomaly thresholds count only body bytes: the
app's response bodies, the visitors' request bodies, or both, as
`SWW_BYTES_COUNT` says. Headers are never counted. What the visitor sent over
a WebSocket counts as request bytes, and what the app sent as response bytes.
- An invalid setting stops the process at start with one log line naming the
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 `********`.
@@ -221,7 +216,7 @@ account, a key, a token or an alert destination stays off until you give one.
Every setting the code reads, with its default:
| Variable | Default | What it does |
| ---------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ---------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
@@ -230,8 +225,8 @@ Every setting the code reads, with its default:
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
| `SWRP_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_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_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_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` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `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`. |
@@ -240,30 +235,30 @@ Every setting the code reads, with its default:
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWW_MAX_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_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_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_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_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_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_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_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_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_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
| `SWW_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
| `SWW_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_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_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. |
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWW_LOOKUP_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_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=cn`. |
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
@@ -315,27 +310,27 @@ Every setting the code reads, with its default:
| `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_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_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_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. |
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_NET_REQUESTS_PER_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_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_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. |
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWW_ANOMALY_NET_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_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_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_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_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_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_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. |
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWW_WATCH_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_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_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_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. |
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
full, and the limits that are fixed rather than settings.
@@ -418,7 +413,7 @@ services:
SWW_RATE_LIMIT_PER_HOUR: "600"
SWW_RATE_LIMIT_PER_DAY: "3000"
# 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_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=cn:25
# Paths only scanners ask for; one request bans the client.
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
# Answer a refused client with 429 rather than 403.
@@ -510,10 +505,10 @@ of the request reaches the app.
`discriminator` takes the checks:
3. The tag lists.
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
5. The bans.
6. The lookup of the client's AS number and country.
3. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
4. The bans.
5. The lookup of the client's AS number and country.
6. The tag lists.
7. The country list.
8. The reputation sources.
9. The tag refusals of `SWW_TAG_DENY`.
@@ -531,9 +526,8 @@ of the request reaches the app.
17. The request is passed to the app, streaming.
18. After the answer, `rproxy` sends `discriminator` a report with the status
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
they take it past one, and adds the request to the client's history.
and the bytes each way. For an allowed request, `discriminator` counts the
bytes toward the byte limits and adds the request to the client's history.
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
@@ -688,15 +682,6 @@ on the host.
`make check`.
- `script/docker`: builds the image, whose build runs the tests and the linter
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
image build.
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
+39 -50
View File
@@ -22,8 +22,8 @@ to them.
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
restarting (see "Without an answer from `discriminator`").
- It limits each client's requests and body bytes per minute, hour and day. It
bounds the size and duration of every request and answer. It detects attacks.
- It limits each client's requests and bytes per minute, hour and day. It bounds
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,
and permanently when they keep at it.
- It looks up each client's AS number and country. It can refuse whole countries
@@ -143,13 +143,11 @@ request log's `heuristic` field gives that name. `refusal_limit` and
- 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
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
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
3. The static lists. A client in `SWW_ALLOW_NETS` skips steps 4 to 15, the byte
limits and the error burst. A client in `SWW_DENY_NETS` is refused
(`deny_nets`); a client in both lists is let through.
5. Bans. A client under a ban is refused (`active_ban`; see "Bans").
6. The lookup, unless `SWW_LOOKUP_SOURCE` is `off` (see "Lookup of AS number and
4. Bans. A client under a ban is refused (`active_ban`; see "Bans").
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,
up to `SWW_LOOKUP_TIMEOUT`, only when one of these needs it:
- the country list;
@@ -160,6 +158,8 @@ request log's `heuristic` field gives that name. `refusal_limit` and
A request that ends before the answer comes is logged without it.
6. The tag lists (`tag_lists`; see "Request tags"). Each list that holds the
client adds its tag.
7. The country list (`countries`; see "Country list").
8. The reputation sources (see "Reputation"), in this order:
- A client on a fetched blocklist gets `SWW_BLOCKLIST_ACTION`
@@ -192,12 +192,11 @@ request log's `heuristic` field gives that name. `refusal_limit` and
sent is removed.
18. After the visitor's answer has ended, `rproxy` sends the report (see "The
report"). `discriminator` then counts:
- 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
a WebSocket, what it carried each way counts once it closes. A client
whose total passes a byte limit times the percentage has broken that limit
and is banned (`byte_limits`). The request that broke it has already been
passed on whole; nothing is cut off.
- For a request it allowed, the response bytes, the request bytes or both
are added to the byte counters, as `SWW_BYTES_COUNT` says. For a
WebSocket, what it carried each way counts once it closes. A client whose
total passes a byte limit times the percentage has broken that limit and
is banned (`byte_limits`). The response in progress is not cut off.
- For a request it allowed, the client's history and the anomaly counters
are updated, and the alert thresholds are evaluated.
@@ -260,8 +259,7 @@ to a ban, each by its own rule:
clear sign of attack bans it permanently at once.
- A broken limit, one of:
- a request over a rate limit;
- the bytes counted for a request and its response taking the client's total
past a byte limit;
- a response that takes the client's byte total past a byte limit;
- more than `SWW_ERROR_BURST_THRESHOLD` refusals within a minute.
The bans for a broken limit:
@@ -403,7 +401,7 @@ Conventions:
### 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
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
@@ -450,15 +448,10 @@ the same way.
### Byte limits, per client
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.
What the visitor sent over a WebSocket counts as request bytes, and what the app
sent as response bytes. The bytes counted for a request and its response are
added to the client's total once the response has ended (see step 18). So the
request that takes a client past a limit is passed on whole, and the ban refuses
the client's requests from then on. By default there is no minute limit, so a
client can download in a burst at full speed. The hour limit defaults to 50 GiB
and the day limit to 100 GiB. They allow several downloads of a few GiB each.
By default there is no minute limit, so a client can download in a burst at full
speed. The hour limit defaults to 50 GiB and the day limit to 100 GiB. They
allow several downloads of a few GiB each. A response's bytes are counted when
it ends (see step 18).
### Lookup of AS number and country
@@ -487,7 +480,7 @@ service that keeps its visitors' addresses on its own host.
dropped from that table. The answer reaches the client's history and ban notes
when it comes.
- `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
until it comes, and its later requests do not wait. Its address is asked about
again in the background.
@@ -541,10 +534,8 @@ the log line as `tags`, sorted, with empty lists left out (see
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.
- `asn=<number>` and `country=<code>`, in lower case, such as `asn=64496` and
`country=de`, from the lookup. A client the lookup cannot place has neither.
- 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.
@@ -555,9 +546,8 @@ Each tag and where it comes from:
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.
A client in `SWW_ALLOW_NETS` skips the checks and gets no tag. `SWW_TAG_DENY`
and `SWW_TAG_LIMIT_PERCENT` are read case-insensitively.
Two settings act on tags:
@@ -590,16 +580,16 @@ abuse comes from.
`SWW_ASN_LIMIT_PERCENT_URL`; for a country, its `country=` tag's percentage.
- `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
fetched and kept like a blocklist (see "Reputation"). Each entry counts like
an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`. Where both give a percentage, the
fetched and kept like a blocklist (see "Reputation"). Each entry counts as an
`asn=` tag in `SWW_TAG_LIMIT_PERCENT` does. Where both give a percentage, the
lower applies.
- 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
client without a country gets `SWW_UNKNOWN_LIMIT_PERCENT` in place of a
country's. The percentage is 100 if none applies. Clients given a lower
percentage are banned after fewer requests, first for a while and then, for
repeated abuse, permanently. The log line of each counted request, and the
notes of each ban, say which setting lowered the limit.
number's, its country's, or a reputation hit's `limit:<percent>`. A client
without a country gets `SWW_UNKNOWN_LIMIT_PERCENT` in place of a country's.
The percentage is 100 if none applies. Clients given a lower percentage are
banned after fewer requests, first for a while and then, for repeated abuse,
permanently. The log line of each counted request, and the notes of each ban,
say which setting lowered the limit.
- No budget is shared by a whole country or AS number. One abuser could use it
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
@@ -960,8 +950,7 @@ which the metrics show. They reveal a widely distributed scraper, thousands of
addresses at low rates each, which the per-client limits do not see. A low
percentage for its `asn=` tag in `SWW_TAG_LIMIT_PERCENT` is the response.
- Requests and body bytes, as `SWW_BYTES_COUNT` counts them, are counted per
minute and per hour, for each of:
- Requests and bytes are counted per minute and per hour, for each of:
- a client;
- the surrounding netblock;
- an AS number;
@@ -1271,12 +1260,12 @@ short.
### Answering in time
`rproxy` stops waiting for `discriminator` after `SWRP_TIMEOUT_MS`, by default 2
seconds, and `SWRP_DEFAULT_DISPOSITION` then decides. The longest a question
waits on an outside source is `SWW_LOOKUP_TIMEOUT`, by default 800 milliseconds,
well within that time.
seconds, and `SWRP_DEFAULT_DISPOSITION` then decides. So at the defaults,
`discriminator` answers a request well within that time, whatever the request
and whatever its outside sources do.
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
waits on these, which run in the background:
@@ -1369,9 +1358,9 @@ answer.
For a request it holds, `discriminator` writes the request's last log line (see
"The log"). For a request it allowed, it first:
- counts the body bytes `SWW_BYTES_COUNT` names, toward the client's byte limits
at the percentages it gave that request;
- bans a client whose total passes a byte limit;
- counts the bytes as `SWW_BYTES_COUNT` says, toward the client's byte limits at
the percentages it gave that request;
- bans a client that passes a byte limit;
- adds the request with its status to the client's history, the anomaly counters
and the metrics.
-3
View File
@@ -5,9 +5,6 @@ What `GET /_sww/metrics` answers with, at `discriminator`'s own address.
keeps the `instance` label as `exported_instance` unless the scrape sets
`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
- `sww_requests_total`, `sww_request_bytes_total` and
+6 -8
View File
@@ -145,22 +145,20 @@ Every one of these lines has:
included. It is left out when rate limiting did not count the request. It
holds:
- `minute`, `hour` and `day`: requests;
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
`SWW_BYTES_COUNT` names, in that window.
- `minute_bytes`, `hour_bytes` and `day_bytes`: bytes.
A count can have a fraction (see "Counting method" in
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
response are added when its report comes. So on the `decision` line, the
three byte totals leave them out.
[`SPEC.md`](../SPEC.md)). The bytes of an allowed request are counted when
its report comes. So on the `decision` line, the bytes are without this
request's.
- `limit_hit`: the limit broken:
- `minute`, `hour` or `day` for a rate limit;
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
- `error_burst`.
When the bytes counted for a request and its response break a limit, the
request has been passed on whole, so its `action` stays `forward`. Only its
`report` line gives that limit.
A request whose bytes broke a limit was passed on whole, so its `action`
stays `forward`. Only its `report` line gives that limit.
- `rule_ids`: the rule file rules that matched, in order, up to the one that
refused.
+13 -43
View File
@@ -90,16 +90,14 @@ between the two (see "The image's command" below).
## Static lists
- `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
exempt from rate limiting and the byte limits.
- `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
The request past one of these limits is refused, and the client is banned.
- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
client may make in a minute. A count or `off`. The default leaves room for a
person opening two pages at once with an empty cache, up to 60 files each.
@@ -122,22 +120,11 @@ The request past one of these limits is refused, and the client is banned.
## Byte limits, per client
These count body bytes, never headers. What the visitor sent over a WebSocket
counts as request bytes, and what the app sent as response bytes. The bytes
counted for a request and its response are added to the client's total once the
response has ended. The request that takes a client's total past a limit is not
cut off; the client is banned.
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes
counted for a client in each window, a size or `off`. `SWW_BYTES_COUNT` says
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_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.
counted for a client in each window. A size or `off`.
- `SWW_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
`response`, `request` or `both`.
## Lookup of AS number and country
@@ -177,7 +164,7 @@ Every request gets tags from what `discriminator` knows of its client. See
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
`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
@@ -186,21 +173,6 @@ Every request gets tags from what `discriminator` knows of its client. See
`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
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a
@@ -212,8 +184,8 @@ the request first.
lookup cannot place.
- `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
`AS64496:50`. Each entry counts like an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`.
A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
`AS64496:50`. Each entry counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`
does. A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
## Attack detection
@@ -246,9 +218,8 @@ the request first.
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
yet. It has no `_FILE` form.
- `SWW_ERROR_BURST_THRESHOLD` (default `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 (see "Attack detection" in `SPEC.md`).
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client in
a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
## Ban lengths, response and scope
@@ -325,10 +296,9 @@ the request first.
## Anomaly thresholds
Each scope has four thresholds: the most requests and the most body bytes
counted in a minute and in an hour. The bytes are those `SWW_BYTES_COUNT` names.
Requests are counts, bytes are sizes. Each is `off` by default. The four
thresholds per client are:
Each scope has four thresholds: the most requests and the most bytes counted in
a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by
default. The four thresholds per client are:
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
+1 -1
View File
@@ -61,7 +61,7 @@ since it was first seen:
- the first and last time seen;
- the AS number, AS name and country last looked up, and when;
- total requests, and how many were forwarded and how many refused;
- body bytes of its requests, and of the responses it was sent;
- bytes in each direction;
- responses by status class;
- offences by kind: `limit`, `attack`, `rule_blocked` and `waf_blocked`. A file
may also hold `token_refused`, which is kept, and which nothing adds to.
+58 -180
View File
@@ -101,8 +101,8 @@ type Config struct {
// the clients they hold (SWW_TAG_LISTS). TagDeny are the tags whose
// requests are refused (SWW_TAG_DENY). TagLimitPercent gives the clients
// with a tag that percentage of every rate limit and byte limit, from 1
// to 99 (SWW_TAG_LIMIT_PERCENT). All hold tags as requests carry them: in
// lower case, a country code in capitals.
// to 99 (SWW_TAG_LIMIT_PERCENT). All hold tags in lower case, as requests
// carry them.
TagLists []TagList
TagDeny []string
TagLimitPercent map[string]int64
@@ -115,7 +115,7 @@ type Config struct {
// UnknownLimitPercent is the percentage of every limit a client without
// a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
// 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 asn= tags in TagLimitPercent do (SWW_ASN_LIMIT_PERCENT_URL),
// "" while it is unset.
ASNBytesPercent map[string]int64
CountryBytesPercent map[string]int64
@@ -165,8 +165,8 @@ type Config struct {
// BanResponse is the status a refused client is answered with, 403
// or 429, or 0 to close the connection without an answer
// (SWW_BAN_RESPONSE). It answers a banned client, a request that
// breaks a rate limit or matches a ban rule, SWW_DENY_NETS,
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY.
// breaks a rate limit or matches a ban rule, SWW_DENY_NETS and the
// country lists.
BanResponse int
// LimitBanDuration is the ban for a first broken rate limit
// (SWW_LIMIT_BAN_DURATION). A limit broken again within
@@ -398,21 +398,18 @@ var (
errNotPathPrefix = errors.New(
"is not a path prefix starting with /, such as /assets/")
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")
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
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(
"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")
errNotBoolean = errors.New("is not true or false")
errNotLogRemoteURL = errors.New(
"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")
errNotFacility = errors.New("is not a syslog facility such as local0 or daemon")
errNotAppName = errors.New(
@@ -425,21 +422,16 @@ var (
"is not a header name followed by : and the header's value, " +
"such as Authorization:Bearer <token>")
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(
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
"source_failure or file_error")
errNotNumberOrOff = errors.New(
"is not a whole number above zero, such as 60, or off",
)
errNotNumberOrOff = errors.New("is not a whole number above zero, such as 60, or off")
errNotUTF8 = errors.New("is not valid UTF-8")
errNotASN = errors.New("is not an AS number such as AS64496")
errNotPercentItem = errors.New(
"is not a code, : and a percentage, such as AS64496:50 or cn:25")
errNotPercent = errors.New(
"is not a percentage, a whole number from 0 to 100",
)
errNotPercent = errors.New("is not a percentage, a whole number from 0 to 100")
errListedTwice = errors.New("is listed twice")
errNotListURL = errors.New(
"is not an http or https URL without a user or a fragment, " +
@@ -448,17 +440,11 @@ var (
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
errNotAction = errors.New(
"is not deny, limit:<percent> such as limit:25, or log")
errNotZone = errors.New(
"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",
)
errNotResolver = errors.New(
"is not an IP address with an optional port, " +
"such as 192.0.2.53 or [2001:db8::53]:5353",
)
errNotZone = errors.New("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")
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(
"is not an http or https URL without a user or a fragment, " +
"such as http://172.17.0.1:8080")
@@ -478,13 +464,6 @@ var (
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
@@ -494,9 +473,7 @@ var (
// 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
func FromEnvironment(
lookupEnv func(string) (string, bool),
) (*Config, error) {
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
env := &environment{lookupEnv: lookupEnv}
cfg := &Config{
ListenAddr: env.address("SWW_LISTEN_ADDR"),
@@ -604,7 +581,6 @@ func FromEnvironment(
env.checkASNLimitPercentURL(cfg)
env.checkCrowdSec(cfg)
env.checkTagLists(cfg)
env.checkTags(cfg)
if env.err != nil {
return nil, env.err
@@ -696,10 +672,7 @@ func (e *environment) lookup(name string) (string, bool) {
}
if set {
e.check(
name,
fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice),
)
e.check(name, fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice))
return value, set
}
@@ -982,10 +955,7 @@ func (e *environment) zones(name string) []string {
logged[i] = MaskZoneKey(zone)
}
e.settings = append(
e.settings,
slog.String(name, strings.Join(logged, ",")),
)
e.settings = append(e.settings, slog.String(name, strings.Join(logged, ",")))
return zones
}
@@ -1054,7 +1024,7 @@ func (e *environment) checkLookupDBPath(cfg *Config) {
// limit, and the anomaly thresholds per AS number.
func (e *environment) checkCountriesAndLookups(cfg *Config) {
for _, country := range cfg.ExclusivelyAllowedCountries {
if slices.Contains(cfg.TagDeny, CountryTagPrefix+country) {
if slices.Contains(cfg.TagDeny, countryTagPrefix+strings.ToLower(country)) {
e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
fmt.Errorf("%q %w", country, errOnBothLists))
}
@@ -1128,56 +1098,6 @@ func (e *environment) checkTagLists(cfg *Config) {
}
}
// 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 {
@@ -1198,7 +1118,7 @@ func tagLimitNeedsLookups(cfg *Config) bool {
// isLookupTag reports whether tag is an asn= or country= tag.
func isLookupTag(tag string) bool {
return strings.HasPrefix(tag, ASNTagPrefix) || strings.HasPrefix(tag, CountryTagPrefix)
return strings.HasPrefix(tag, asnTagPrefix) || strings.HasPrefix(tag, countryTagPrefix)
}
// headerNames reads a setting that is a list of header names, and
@@ -1212,9 +1132,7 @@ func (e *environment) headerNames(name, defaultValue string) []string {
// durationNotOff reads a setting that is a duration and, unlike a
// timeout, cannot be off.
func (e *environment) durationNotOff(
name, defaultValue string,
) time.Duration {
func (e *environment) durationNotOff(name, defaultValue string) time.Duration {
duration, err := ParseDurationNotOff(e.value(name, defaultValue))
e.check(name, err)
@@ -1413,10 +1331,7 @@ func (e *environment) instanceName() string {
// 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
// when it is set, and, while lines are sent, when it is the instance name.
func (e *environment) appName(
name, instanceName string,
sending bool,
) string {
func (e *environment) appName(name, instanceName string, sending bool) string {
value, set := e.lookup(name)
if !set {
value = instanceName
@@ -1439,10 +1354,7 @@ func (e *environment) appName(
// checkInstanceNameForNtfy refuses an instance name that holds a control
// character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent
// the instance name in a header, which cannot hold one.
func (e *environment) checkInstanceNameForNtfy(
instanceName string,
ntfySet bool,
) {
func (e *environment) checkInstanceNameForNtfy(instanceName string, ntfySet bool) {
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
e.check("SWW_INSTANCE_NAME", fmt.Errorf(
"%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set",
@@ -1766,10 +1678,7 @@ func parseNamedNetblocks(value string) ([]anomaly.NamedNetblock, error) {
return nil, fmt.Errorf("%q %w", name, errListedTwice)
}
named = append(
named,
anomaly.NamedNetblock{Name: name, Netblock: netblock},
)
named = append(named, anomaly.NamedNetblock{Name: name, Netblock: netblock})
}
return named, nil
@@ -1917,19 +1826,16 @@ func ParseASN(value string) (string, error) {
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.
// The prefixes of the tags the lookup gives a request.
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"
asnTagPrefix = "asn="
countryTagPrefix = "country="
)
// ExploitSignatureTag is the tag of a request a rule file or the Core Rule
// Set matched.
const 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 {
@@ -1970,13 +1876,11 @@ func parseTagLists(value string) ([]TagList, error) {
}
// 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.
// tag listed twice is an error.
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
return nil, err
}
tags := make([]string, 0, len(items))
@@ -1988,7 +1892,7 @@ func parseTags(value string) ([]string, error) {
}
if slices.Contains(tags, tag) {
return nil, fmt.Errorf("%q %w", MaskZoneKey(item), errListedTwice)
return nil, fmt.Errorf("%q %w", item, errListedTwice)
}
tags = append(tags, tag)
@@ -1997,30 +1901,27 @@ func parseTags(value string) ([]string, error) {
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.
// parseTag reads a tag in either case, and returns it as a request carries
// it: in lower case, 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))
tag := strings.ToLower(value)
if digits, isASN := strings.CutPrefix(tag, ASNTagPrefix); isASN {
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
return asnTagPrefix + strconv.FormatUint(number, 10), nil
}
if code, isCountry := strings.CutPrefix(tag, CountryTagPrefix); isCountry {
country, err := parseCountry(code)
if code, isCountry := strings.CutPrefix(tag, countryTagPrefix); isCountry {
_, err := parseCountry(code)
if err != nil {
return "", err
}
return CountryTagPrefix + country, nil
}
return tag, nil
@@ -2033,9 +1934,7 @@ func parseTag(value string) (string, error) {
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
return nil, err
}
percents := make(map[string]int64, len(items))
@@ -2043,7 +1942,7 @@ func parseTagPercents(value string) (map[string]int64, error) {
for _, item := range items {
colon := strings.LastIndex(item, ":")
if colon <= 0 {
return nil, fmt.Errorf("%q %w", MaskZoneKey(item), errNotTagPercentItem)
return nil, fmt.Errorf("%q %w", item, errNotTagPercentItem)
}
tag, err := parseTag(item[:colon])
@@ -2053,7 +1952,7 @@ func parseTagPercents(value string) (map[string]int64, error) {
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)
return nil, fmt.Errorf("%q %w", item[colon+1:], errNotTagPercent)
}
if tag == ExploitSignatureTag {
@@ -2061,9 +1960,7 @@ func parseTagPercents(value string) (map[string]int64, error) {
}
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)
return nil, fmt.Errorf("%q %w", item[:colon], errListedTwice)
}
percents[tag] = percent
@@ -2163,11 +2060,8 @@ func parseHeaderNames(value string) ([]string, error) {
header := strings.ToLower(item)
switch header {
case "host":
return nil, fmt.Errorf(
"%q %w; the request's host is the field host",
item,
errHeaderTakenOut,
)
return nil, fmt.Errorf("%q %w; the request's host is the field host",
item, errHeaderTakenOut)
case "transfer-encoding":
return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut)
}
@@ -2204,11 +2098,9 @@ func parseUpstreamURL(value string) (*url.URL, error) {
}
onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") &&
upstream.Hostname() != "" && upstream.User == nil &&
upstream.Opaque == "" &&
upstream.Hostname() != "" && upstream.User == nil && upstream.Opaque == "" &&
(upstream.Path == "" || upstream.Path == "/") &&
upstream.RawQuery == "" &&
upstream.Fragment == ""
upstream.RawQuery == "" && upstream.Fragment == ""
if !onlySchemeAndHost {
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
}
@@ -2232,11 +2124,7 @@ func parseLogRemoteURL(value string) (*url.URL, error) {
return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL)
}
schemes := []string{
remotelog.SchemeUDP,
remotelog.SchemeTCP,
remotelog.SchemeTLS,
}
schemes := []string{remotelog.SchemeUDP, remotelog.SchemeTCP, remotelog.SchemeTLS}
port, err := strconv.ParseUint(remote.Port(), 10, 16)
onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) &&
@@ -2299,9 +2187,7 @@ func isHTTPURL(u *url.URL) bool {
return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" &&
(u.Port() == "" || (err == nil && port != 0)) &&
u.User == nil &&
u.Opaque == "" &&
u.Fragment == ""
u.User == nil && u.Opaque == "" && u.Fragment == ""
}
// isListURL reports whether value is a URL a list can be fetched from, as
@@ -2368,12 +2254,9 @@ func parseZones(value string) ([]string, error) {
for i, zone := range zones {
shown := MaskZoneKey(zone)
listedBefore := slices.ContainsFunc(
zones[:i],
func(earlier string) bool {
listedBefore := slices.ContainsFunc(zones[:i], func(earlier string) bool {
return strings.EqualFold(MaskZoneKey(earlier), shown)
},
)
})
switch {
case len(zone) > maxZoneLength:
@@ -2460,13 +2343,8 @@ func parseWebhookHeaders(value string) (http.Header, string, error) {
name, headerValue, found := strings.Cut(item, ":")
name = strings.TrimSpace(name)
if !found || !IsHeaderName(name) ||
strings.ContainsAny(headerValue, "\r\n\x00") {
return nil, "", fmt.Errorf(
"item %d %w",
i+1,
errNotWebhookHeader,
)
if !found || !IsHeaderName(name) || strings.ContainsAny(headerValue, "\r\n\x00") {
return nil, "", fmt.Errorf("item %d %w", i+1, errNotWebhookHeader)
}
headers.Add(name, strings.TrimSpace(headerValue))
+14 -138
View File
@@ -208,9 +208,6 @@ const off = "off"
// enabled is true, as a setting's value.
const enabled = "true"
// disabled is false, as a setting's value.
const disabled = "false"
// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
// fileSource the source that is the lookup database.
const (
@@ -348,7 +345,7 @@ func TestValuesAsSet(t *testing.T) {
metricsToken: token,
metricsTopN: "10",
rulesDir: "/srv/waf-rules",
rulesEnabled: disabled,
rulesEnabled: "false",
})
wantSettings(t, cfg, config.Config{
@@ -1102,7 +1099,7 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
}
}
func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) {
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{
@@ -1110,7 +1107,7 @@ func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) {
allowedCountries: "de,RU",
}.lookupEnv)
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+": ") ||
@@ -1183,7 +1180,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
for name, value := range map[string]string{
allowedCountries: "de",
addLookupHeaders: enabled,
tagDeny: "country=KP",
tagDeny: "country=kp",
tagLimitPercent: "asn=64496:50",
asnLimitPercentURL: asnURL,
asnBytesPercent: "AS64496:50",
@@ -1213,7 +1210,6 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
// any other anomaly threshold, or a blocklist.
env := environment{
lookupSource: off, allowedCountries: "", asnBytesPercent: "",
tagLists: "torexit=https://example.com/tor.txt,vpn=https://example.com/vpn.txt",
tagDeny: torExitTag, tagLimitPercent: "vpn:50",
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
blocklistURLs: dropURL,
@@ -1360,12 +1356,8 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
}
}
// torExitTag, zenTag and exploitTag are tags these tests name.
const (
torExitTag = "torexit"
zenTag = "dnsbl=zen.example"
exploitTag = "exploitsignature"
)
// torExitTag is a tag these tests name.
const torExitTag = "torexit"
func TestTagSettingsAsSet(t *testing.T) {
t.Parallel()
@@ -1377,9 +1369,8 @@ func TestTagSettingsAsSet(t *testing.T) {
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",
tagDeny: "TorExit, Country=KP, asn=014061",
tagLimitPercent: "vpn:50, asn=014061:25, COUNTRY=cn:1, dnsbl=zen.example:99",
})
wantLists := []config.TagList{
@@ -1390,112 +1381,19 @@ func TestTagSettingsAsSet(t *testing.T) {
t.Errorf("%s gave %+v, want %+v", tagLists, cfg.TagLists, wantLists)
}
wantDeny := []string{torExitTag, "country=KP", "asn=14061"}
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,
"vpn": 50, "asn=14061": 25, "country=cn": 1, "dnsbl=zen.example": 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()
@@ -2186,12 +2084,9 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{countryBytesPercent, "cn"}, {countryBytesPercent, "cn:"},
{countryBytesPercent, "cn:25:50"}, {countryBytesPercent, "china:25"},
{tagDeny, "a,,b"}, {tagDeny, "country=nk"}, {tagDeny, "asn=abc"},
{tagDeny, "asn="}, {tagDeny, "torexit,TOREXIT"}, {tagDeny, "torexti"},
{tagDeny, zenTag}, {tagDeny, "abuseipdb"}, {tagDeny, "crowdsec"},
{tagLimitPercent, "torexti:50"}, {tagLimitPercent, "dnsbl=zen.example:50"},
{tagLimitPercent, "abuseipdb:50"}, {tagLimitPercent, "crowdsec:50"},
{tagLimitPercent, "vpn :50"}, {tagLimitPercent, "vpn"}, {tagLimitPercent, ":50"},
{tagLimitPercent, "vpn:"}, {tagLimitPercent, "vpn:0"}, {tagLimitPercent, "vpn:100"},
{tagDeny, "asn="}, {tagDeny, "torexit,TOREXIT"},
{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"},
@@ -2210,25 +2105,6 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""},
{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) {
@@ -2483,7 +2359,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
lookupSource: defaultLookupSource,
lookupDBPath: "",
lookupTimeout: "800ms",
addLookupHeaders: disabled,
addLookupHeaders: "false",
allowedCountries: "",
tagLists: "",
tagDeny: "",
+1 -2
View File
@@ -8,7 +8,6 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
@@ -41,7 +40,7 @@ func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
}
rq.abuseIPDBHit = true
rq.addTag(config.AbuseIPDBTag)
rq.addTag("abuseipdb")
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
+4 -6
View File
@@ -37,8 +37,8 @@ const (
// 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"
countryTagDEHalf = "country=de:50"
countryTagDEQuarter = "country=de:25"
// noCountry is in an AS of its own, AS64500, and in no country.
noCountry = "192.0.2.80"
// fourAMinute is the rate limit these tests set: half of it is 2
@@ -101,7 +101,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
setting, value, from string
}{
{tagLimitPercent, asnTagDEHalf, fromDE},
{tagLimitPercent, "Country=de:50", fromDE},
{tagLimitPercent, "Country=DE:50", fromDE},
{unknownLimitPercent, "50", unplaced},
{asnBytesPercent, "as64496:50", fromDE},
{countryBytesPercent, countryDEHalf, fromDE},
@@ -328,9 +328,7 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
// says, with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) {
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
map[string]string{
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
})
map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value})
began := time.Now()
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
+1 -2
View File
@@ -8,7 +8,6 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
@@ -27,7 +26,7 @@ func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
return nil
}
rq.addTag(config.CrowdSecTag)
rq.addTag("crowdsec")
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
"listed by the CrowdSec decision list")
rq.banForCrowdSec(now, decision)
+1 -3
View File
@@ -5,10 +5,8 @@ package discriminator
import (
"context"
"strings"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
@@ -29,7 +27,7 @@ func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
rq.noteListed(listedBy, "listed by a DNSBL zone")
for _, zone := range listedBy {
rq.addTag(config.DNSBLTagPrefix + strings.ToLower(zone))
rq.addTag("dnsbl=" + zone)
}
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
+1 -1
View File
@@ -43,7 +43,6 @@ const (
// country are looked up, so that a client they refuse is not looked up.
func beforeLookup() []heuristic {
return []heuristic{
tagLists{},
denyNets{},
activeBan{},
}
@@ -54,6 +53,7 @@ func beforeLookup() []heuristic {
// last, on the tag rule_files and core_rule_set can add.
func afterLookup() []heuristic {
return []heuristic{
tagLists{},
countries{},
blocklists{},
crowdSec{},
+3 -4
View File
@@ -7,7 +7,6 @@ import (
"strings"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/lookup"
)
@@ -67,17 +66,17 @@ func asnTag(asn string) string {
return ""
}
return config.ASNTagPrefix + strings.TrimPrefix(asn, "AS")
return "asn=" + 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.
// as country=de for DE, or "" for no country.
func countryTag(country string) string {
if country == "" {
return ""
}
return config.CountryTagPrefix + country
return "country=" + strings.ToLower(country)
}
// addLookup adds answer, an answer about a client from the lookup
+1 -1
View File
@@ -71,7 +71,7 @@ const (
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
tagDeny = "SWW_TAG_DENY"
// denyKP is the value of SWW_TAG_DENY that refuses fromKP's country.
denyKP = "country=KP"
denyKP = "country=kp"
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
banResponse = "SWW_BAN_RESPONSE"
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
+2 -2
View File
@@ -162,8 +162,8 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
// /assets/, and breaks the limit.
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
// A ban, SWW_DENY_NETS and SWW_TAG_DENY still refuse a path under a
// prefix.
// A ban, SWW_DENY_NETS and the country lists still refuse a path
// under a prefix.
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
s.request(fromKP, "/assets/app.js",
+3 -3
View File
@@ -168,7 +168,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T)
}
}
func TestBlocklistsComeAfterAllowedCountriesAndSkipAllowNets(t *testing.T) {
func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) {
t.Parallel()
s, server, queue := startWithLookups(t, map[string]string{
@@ -499,7 +499,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
func(server *discriminator.Server) {
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
},
[]string{"asn=64496", "country=DE", "dnsbl=" + dnsblZone},
[]string{"asn=64496", "country=de", "dnsbl=" + dnsblZone},
},
{
abuseipdb,
@@ -507,7 +507,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
func(server *discriminator.Server) {
loadScores(server, map[string]int64{fromDE: 80})
},
[]string{abuseipdb, "asn=64496", "country=DE"},
[]string{abuseipdb, "asn=64496", "country=de"},
},
} {
t.Run(tc.name, func(t *testing.T) {
+4 -2
View File
@@ -138,9 +138,11 @@ func (h *handler) startRequest(
return rq
}
// addTag adds tag to the request's tags, which stay sorted and hold each tag
// once.
// addTag adds tag, in lower case, to the request's tags, which stay sorted and
// hold each tag once.
func (rq *request) addTag(tag string) {
tag = strings.ToLower(tag)
at, found := slices.BinarySearch(rq.line.Tags, tag)
if !found {
rq.line.Tags = slices.Insert(rq.line.Tags, at, tag)
+5 -4
View File
@@ -36,8 +36,9 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
rateLimitPerMinute: "1",
})
// Neither SWW_DENY_NETS, SWW_TAG_DENY nor the limit of one request a
// minute refuses the client, and its country is not looked up.
// Neither SWW_DENY_NETS, the country lists nor the limit of one
// request a minute refuses the client, and its country is not looked
// up.
wantAnswers(t, addr, out, []sentRequest{
{fromKP, http.StatusOK, requestlog.ActionForward},
{fromKP, http.StatusOK, requestlog.ActionForward},
@@ -146,8 +147,8 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
})
// listedAddr's requests are neither refused nor counted, so the first
// request from unlistedAddr is within the limit of one a minute.
// SWW_TAG_DENY still refuses an exempt client.
// request from unlistedAddr is within the limit of one a minute. The
// country lists still refuse an exempt client.
wantAnswers(t, addr, out, []sentRequest{
{listedAddr, http.StatusOK, requestlog.ActionForward},
{listedAddr, http.StatusOK, requestlog.ActionForward},
+3 -49
View File
@@ -10,13 +10,11 @@ import (
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"})
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=KP"})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`)
wantTags(t, line, "asn=64511", "country=KP")
wantHeuristic(t, line, "tag_deny", `{"tag": "country=kp"}`)
wantTags(t, line, "asn=64511", "country=kp")
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
@@ -46,22 +44,6 @@ func TestTagDenyRefusesAListTagBeforeTheRateLimitsCount(t *testing.T) {
}
}
func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
t.Parallel()
const mixedCaseZone = "DNSBL.example"
// The zone's tag is in lower case, however SWW_DNSBL_ZONES writes it.
s, server, _ := startWithLookups(t, map[string]string{
dnsblZones: mixedCaseZone, dnsblResolver: noResolver,
reputationAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
})
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionDenied),
"tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`)
}
func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
t.Parallel()
@@ -91,31 +73,3 @@ func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
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)
}
+3 -30
View File
@@ -43,9 +43,9 @@ func TestLookupAndTagListsGiveTheRequestItsTagsSorted(t *testing.T) {
})
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
"asn=64496", "country=DE", "torexit", "vpn=mullvad")
"asn=64496", "country=de", "torexit", "vpn=mullvad")
wantTags(t, s.get(fromKP, http.StatusOK, requestlog.ActionForward),
"asn=64511", "country=KP", "torexit")
"asn=64511", "country=kp", "torexit")
}
func TestRequestWithoutATagLeavesTheFieldOut(t *testing.T) {
@@ -69,32 +69,5 @@ func TestTagListsTagEvenARequestTheCountryListRefuses(t *testing.T) {
loadLists(t, server, map[string][]string{vpnList: {fromKP}})
wantTags(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
"asn=64511", "country=KP", "vpn")
}
func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
tagLists: "vpn=" + vpnList, denyNets: fromKP, rateLimitPerMinute: "1",
})
loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
if line.Heuristic != "deny_nets" {
t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic)
}
wantTags(t, line, "vpn")
// The second request breaks the limit of one a minute, which bans the client.
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
line = s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
if line.Heuristic != "active_ban" {
t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic)
}
wantTags(t, line, "vpn")
"asn=64511", "country=kp", "vpn")
}
+4 -14
View File
@@ -133,11 +133,8 @@ func (f *File) Watch(ctx context.Context) {
}
if err != nil {
f.params.ProcessLog.Error(
"cannot watch the lookup database for replacements",
"error",
err.Error(),
)
f.params.ProcessLog.Error("cannot watch the lookup database for replacements",
"error", err.Error())
return
}
@@ -197,10 +194,7 @@ func (f *File) readAgain() {
f.params.Alerts.Raise(alerts.Alert{
Event: alerts.EventFileError,
Reason: kept,
Detail: map[string]any{
"file": f.params.Path,
"error": err.Error(),
},
Detail: map[string]any{"file": f.params.Path, "error": err.Error()},
})
f.params.ProcessLog.Error(kept, "error", err.Error())
@@ -218,11 +212,7 @@ func (f *File) use(reader *maxminddb.Reader) {
f.lastRead = f.params.Now()
f.mu.Unlock()
f.params.ProcessLog.Info(
"read the lookup database",
"file",
f.params.Path,
)
f.params.ProcessLog.Info("read the lookup database", "file", f.params.Path)
}
// read reads the lookup database at path. The whole file is read into
+1 -1
View File
@@ -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
// country, the refusals by the country list as well.
// country, the refusals by the country lists as well.
func (b *busiest) vecs() []*prometheus.CounterVec {
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
if b.refused != nil {
+11 -11
View File
@@ -115,11 +115,11 @@ type list struct {
failures int
}
// entries are what the lines of a copy say: for a blocklist or a tag list, the
// netblocks it names, with the lengths among them, for the file of AS:percent
// lines, the percentage it gives each AS number, and for the CrowdSec decision
// list, the decision on each netblock that ends last, with the lengths among
// them.
// entries are what the lines of a copy say: for a blocklist, the netblocks
// it names, with the lengths among them, for the file of AS:percent lines,
// the percentage it gives each AS number, and for the CrowdSec decision
// list, the decision on each netblock that ends last, with the lengths
// among them.
type entries struct {
netblocks map[netip.Prefix]bool
lengths []int
@@ -504,10 +504,10 @@ func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
}
// parse reads the lines of the list at listURL, fetched at fetched: those
// of a blocklist or a tag list, of the file of AS:percent lines, or of the
// CrowdSec decision list. In all but the decision list, anything after a ; or
// a # on a line is left out, and so is a line left blank. Any other line that
// does not read is an error naming it by its number.
// of a blocklist, of the file of AS:percent lines, or of the CrowdSec
// decision list. In the first two, anything after a ; or a # on a line is
// left out, and so is a line left blank. Any other line that does not read
// is an error naming it by its number.
func (l *Lists) parse(
listURL string, lines []string, fetched time.Time,
) (entries, error) {
@@ -521,8 +521,8 @@ func (l *Lists) parse(
}
}
// parseNetblocks reads the lines of a blocklist or a tag list, each an address
// or a netblock as the settings take them.
// parseNetblocks reads a blocklist's lines, each an address or a netblock
// as the settings take them.
func parseNetblocks(lines []string) (entries, error) {
found := entries{netblocks: map[netip.Prefix]bool{}}
+4 -23
View File
@@ -19,34 +19,27 @@ const (
// ActionForward is a request discriminator allowed, which rproxy passes to
// the app.
ActionForward = "forward"
// ActionTooLarge is a request over the header limits.
ActionTooLarge = "too_large"
// ActionRateLimited is a request refused because it took its client
// over a rate limit, which bans the client.
ActionRateLimited = "rate_limited"
// ActionBanned is a request refused because a ban covers its client,
// 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
// client.
ActionBanned = "banned"
// ActionRuleBlocked is a request refused because it matched a block
// rule.
ActionRuleBlocked = "rule_blocked"
// ActionWAFBlocked is a request refused because the Core Rule Set
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
ActionWAFBlocked = "waf_blocked"
// ActionDenied is a request refused because its client is in
// 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
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
ActionDenied = "denied"
// ActionCountryDenied is a request refused because its client's country
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
ActionCountryDenied = "country_denied"
@@ -265,11 +258,7 @@ func FormatTime(t time.Time) string {
// Milliseconds is d in milliseconds, to the microsecond.
func Milliseconds(d time.Duration) float64 {
return float64(
d.Microseconds(),
) / float64(
time.Millisecond/time.Microsecond,
)
return float64(d.Microseconds()) / float64(time.Millisecond/time.Microsecond)
}
// NewProcessLogger returns the logger for the process's own messages:
@@ -277,25 +266,17 @@ func Milliseconds(d time.Duration) float64 {
// 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;
// the request lines Write writes are never held back.
func NewProcessLogger(
w io.Writer,
instanceName string,
level slog.Level,
) *slog.Logger {
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
if attr.Key == slog.TimeKey && len(groups) == 0 {
return slog.String(
slog.TimeKey,
FormatTime(attr.Value.Time()),
)
return slog.String(slog.TimeKey, FormatTime(attr.Value.Time()))
}
return attr
},
})
return slog.New(handler).
With("type", "process", "instance", instanceName)
return slog.New(handler).With("type", "process", "instance", instanceName)
}
+1 -1
View File
@@ -618,7 +618,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
instanceName: instance,
lookupSource: "file",
lookupDBPath: path,
"SWW_TAG_DENY": "country=KP",
"SWW_TAG_DENY": "country=kp",
// The requests sent until a replacement takes effect, and those
// for the metrics, must not break a rate limit, whose ban would
// refuse them too.
-54
View File
@@ -1,54 +0,0 @@
#!/bin/sh
# script/publish: build the image on the Docker host and push it to the
# registry under four tags: the UTC date (YYYY-MM-DD), the short commit
# hash, the branch name and latest. The build is script/docker's, so the
# tests and the linter run first. The host and the registry name are
# environment variables with defaults, for running it against another.
#
# The working tree must be clean: the hash tag names a commit, and an
# image built from uncommitted changes would carry a hash that does not
# describe it.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
: "${DOCKER_HOST:=ssh://root@fsn1app1}"
: "${PUBLISH_IMAGE:=git.eeqj.de/sneak/smallwebwaf}"
export DOCKER_HOST
main() {
cd "$ROOT"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`.
changes="$(git status --porcelain)"
if [ -n "$changes" ]; then
echo "script/publish: the working tree is not clean; commit or stash first" >&2
exit 1
fi
date="$(date -u +%Y-%m-%d)"
hash="$(git rev-parse --short HEAD)"
branch="$(git rev-parse --abbrev-ref HEAD)"
if [ "$branch" = "HEAD" ]; then
echo "script/publish: detached HEAD; check out a branch first" >&2
exit 1
fi
# A tag is [A-Za-z0-9_.-]; a branch name such as feature/x is not.
branch="$(printf '%s' "$branch" | tr -c 'A-Za-z0-9_.-' '-')"
local_tag="$("$SCRIPT_DIR/projectname")"
"$SCRIPT_DIR/docker"
for tag in "$date" "$hash" "$branch" latest; do
docker tag "$local_tag" "$PUBLISH_IMAGE:$tag"
done
for tag in "$date" "$hash" "$branch" latest; do
docker push "$PUBLISH_IMAGE:$tag"
done
echo "pushed $PUBLISH_IMAGE with tags: $date $hash $branch latest"
}
main "$@"