Compare commits
1
Commits
next
..
1f1e97cb4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f1e97cb4e |
No files matched your search
+44
-56
@@ -12,8 +12,8 @@ configured only by environment variables, that provides:
|
||||
- R1: rate limits per minute, per hour and per day
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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: "",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/publish: build the image on the Docker host and push it to the
|
||||
# registry under four tags: the UTC date (YYYY-MM-DD), the short commit
|
||||
# hash, the branch name and latest. The build is script/docker's, so the
|
||||
# tests and the linter run first. The host and the registry name are
|
||||
# environment variables with defaults, for running it against another.
|
||||
#
|
||||
# The working tree must be clean: the hash tag names a commit, and an
|
||||
# image built from uncommitted changes would carry a hash that does not
|
||||
# describe it.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
: "${DOCKER_HOST:=ssh://root@fsn1app1}"
|
||||
: "${PUBLISH_IMAGE:=git.eeqj.de/sneak/smallwebwaf}"
|
||||
export DOCKER_HOST
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
# Own line: a failing command substitution inside an argument does
|
||||
# not trip `set -e`.
|
||||
changes="$(git status --porcelain)"
|
||||
if [ -n "$changes" ]; then
|
||||
echo "script/publish: the working tree is not clean; commit or stash first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
date="$(date -u +%Y-%m-%d)"
|
||||
hash="$(git rev-parse --short HEAD)"
|
||||
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||
if [ "$branch" = "HEAD" ]; then
|
||||
echo "script/publish: detached HEAD; check out a branch first" >&2
|
||||
exit 1
|
||||
fi
|
||||
# A tag is [A-Za-z0-9_.-]; a branch name such as feature/x is not.
|
||||
branch="$(printf '%s' "$branch" | tr -c 'A-Za-z0-9_.-' '-')"
|
||||
|
||||
local_tag="$("$SCRIPT_DIR/projectname")"
|
||||
"$SCRIPT_DIR/docker"
|
||||
|
||||
for tag in "$date" "$hash" "$branch" latest; do
|
||||
docker tag "$local_tag" "$PUBLISH_IMAGE:$tag"
|
||||
done
|
||||
for tag in "$date" "$hash" "$branch" latest; do
|
||||
docker push "$PUBLISH_IMAGE:$tag"
|
||||
done
|
||||
|
||||
echo "pushed $PUBLISH_IMAGE with tags: $date $hash $branch latest"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in new issue
Block a user