Author SHA1 Message Date
sneak 85ef2038cb Read every setting through viper and rename the settings (closes #211)
check / check (push) Failing after 1m14s
Every setting of discriminator and rproxy is read through viper, in place
of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv
and AllowEmptyEnv on, so a setting that is set but empty reads as set. A
setting may also be given as a file, the _FILE form, which rproxy's
settings now have too; rproxy still writes nothing to disk.

The settings are renamed. Discriminator's take the prefix SWW_WAF_,
rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core
Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_.

Model: deepseek-v4-pro (first version); opus-5-5 (rework)
2026-11-09 07:54:36 +00:00
clawbot 719691d1e8 Name log fields, metrics and the Core Rule Set package for what they are (closes #192)
check / check (push) Failing after 3m22s
The first half of the naming audit: names now say what they are. `internal/waf` is `internal/coreruleset`. Log fields, the action `blocked_by_core_rule_set`, the metric and the alert event say Core Rule Set. Timings end `_ms`. Percentages say of what, as `rate_limit_percent` and `bytes_limit_percent`. Setting names follow in #234.

Judgement call: `blocked_by_core_rule_set`, not the audit's `core_rule_set_blocked`, to match `blocked_by_block_rule`.
Trips a reader: an existing state file holding `waf_blocked` or the anomaly scope `watch` stops the start and must be removed. `SWW_ALERT_EVENTS` listing `waf_block` must list `core_rule_set_match`.

Model: opus-5-5
2026-11-09 08:47:26 +01:00
clawbot aede434f6a Name request log actions so they say what they mean (closes #229)
check / check (push) Successful in 6m30s
The request log's `action` values now say what they mean, as sneak named them: `request_header_too_large`, `client_is_rate_limited`, `blocked_by_block_rule` and `client_denied`, with no aliases. `banned` splits into `temporarily_banned` and `permanently_banned`, by whether the ban runs out. When a permanent ban also covers a client, that ban is the one reported.

Trips a reader: a request over a rate limit stays `client_is_rate_limited` even when the ban it makes is permanent.
Disclosure: the offence key `rule_blocked` is renamed in `clients.json` too, so an existing file holding it stops the start and must be removed. Its `version` stays 1.

Model: opus-5-5
2026-11-09 06:48:26 +01:00
clawbot 6850cb0e9f Add asn= and prefix= tags from a routewatch instance (closes #204)
check / check (push) Successful in 5m0s
With `SWW_ROUTEWATCH_URL` set, a new heuristic, `routewatch_tags`, adds `asn=<number>` and `prefix=<netblock>` to a request from what a routewatch instance answers about the client. New package `internal/routewatch` asks in the background, never on a request's path, and keeps answers 1 hour in memory. The request log gains `routewatch_tags_missing`, saying why a request has none. `prefix=` works in `SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT`.

Trips a reader: a client's first request has no routewatch tags, so `prefix=` refuses from its next request after the answer comes.
Judgement call: with `SWW_LOOKUP_SOURCE=off` and `SWW_ROUTEWATCH_URL` set, an `asn=` tag no longer stops the start.

Model: opus-5-5
2026-11-09 05:47:34 +01:00
clawbot d7c62f2854 Say which bytes the byte limits count, and what passing one does (closes #203)
check / check (push) Failing after 3m48s
sneak asked for every document to be audited for sentences that do not say what they mean. The byte limits now say which bytes count (response bodies, request bodies or both, as `SWW_BYTES_COUNT` says, WebSocket traffic included) and what passing one does. Other unclear or false sentences in `README.md`, `SPEC.md`, `EVALUATION.md` and `docs/` are rewritten.

Documents only. `REPO_POLICIES.md` is shared policy text and stayed out.

Model: fable-5-1 (first audit); opus-5-5 (rework)
2026-11-09 04:03:17 +01:00
122 changed files with 4510 additions and 3477 deletions

No files matched your search

+2 -2
View File
@@ -184,12 +184,12 @@ RUN groupadd --system --gid 65532 sww \
--gid sww --no-create-home --shell /usr/sbin/nologin \ --gid sww --no-create-home --shell /usr/sbin/nologin \
sww sww
# The state files' directory, SWW_STATE_DIR by default, where a volume # The state files' directory, SWW_WAF_STATE_DIR by default, where a volume
# is mounted to keep them across deploys. The run script gives it to the # is mounted to keep them across deploys. The run script gives it to the
# sww user at each start of discriminator. # sww user at each start of discriminator.
RUN mkdir /var/lib/sww RUN mkdir /var/lib/sww
# The default rule file, in SWW_RULES_DIR by default, where a Dockerfile # The default rule file, in SWW_WAF_RULES_DIR by default, where a Dockerfile
# built FROM this image can copy rule files of its own beside it. # built FROM this image can copy rule files of its own beside it.
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
+56 -44
View File
@@ -12,8 +12,8 @@ configured only by environment variables, that provides:
- R1: rate limits per minute, per hour and per day - R1: rate limits per minute, per hour and per day
- R2: netblocks that bypass rate limiting - R2: netblocks that bypass rate limiting
- R3: alerts on attacks through webhook, Slack or ntfy - R3: alerts on attacks through webhook, Slack or ntfy
- R4: alerts on anomalies: requests or bytes per minute or hour, for given IPs - R4: alerts on anomalies: requests made or bytes transferred per minute or
or netblocks, crossing a threshold hour, for given IPs or netblocks, crossing a threshold
- R5: temporary blocks for abusers, permanent bans for repeat offenders - R5: temporary blocks for abusers, permanent bans for repeat offenders
- R6: one or more RBLs or IP reputation APIs - R6: one or more RBLs or IP reputation APIs
- R7: AS number lookup - R7: AS number lookup
@@ -24,14 +24,19 @@ configured only by environment variables, that provides:
## Verdict ## Verdict
Nothing existing is suitable. No surveyed tool meets R8 (scaling limits by AS Nothing existing is suitable. No surveyed tool meets R8, which asks for limits
number or country) or the bytes half of R4 at all, and the tools that come scaled by AS number or country. None meets R4's alerts on bytes transferred. The
closest on the rest fail R10: they need several containers, YAML or a web UI. tools that come closest on the rest fail R10: they need several containers, YAML
The recommendation is to build a small single-binary sidecar; `SPEC.md` in this or a web UI. The recommendation is to build a small single-binary sidecar;
directory begins that spec. It reuses the mature parts of the surveyed field as `SPEC.md` in this directory begins that spec.
libraries and data sources (Coraza and the OWASP Core Rule Set for attack
detection, DNSBLs, AbuseIPDB, optionally a CrowdSec decision feed) instead of The sidecar reuses the mature parts of the surveyed field instead of
reimplementing them. 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
Closest existing options, and why each still falls short: Closest existing options, and why each still falls short:
@@ -45,13 +50,16 @@ Closest existing options, and why each still falls short:
external blacklist URLs, optional CrowdSec), R7 partly (AS number used for external blacklist URLs, optional CrowdSec), R7 partly (AS number used for
blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule
Set, or Coraza plugin), env-var settings. Set, or Coraza plugin), env-var settings.
- Fails: R8 (AS number and country can only allow or deny, never scale a - Fails:
limit), R4 (no volume or byte threshold alerts in the free edition; - R8: AS number and country can only allow or deny, never scale a limit.
reporting is a paid feature), R5 escalation, and R10 in spirit: since 1.6 - R4: no alerts on request-count or byte thresholds in the free edition;
it needs a `bunkerweb` container plus a `bw-scheduler` container and a reporting is a paid feature.
database, or the all-in-one image that bundles nginx, scheduler, UI and - R5: no escalation for repeat offenders.
Redis in one container. It is designed to be the front door for many - R10: since 1.6 it is not one plain sidecar. It needs a `bunkerweb`
sites, not a per-app sidecar. AGPL-3.0. 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.
- Unverified: whether several rates (minute, hour, day) can be stacked on - Unverified: whether several rates (minute, hour, day) can be stacked on
the same URL pattern; the documented design is one rate per URL pattern. the same URL pattern; the documented design is one rate per URL pattern.
- CrowdSec is the strongest detection and ban engine, but it is not a proxy. - CrowdSec is the strongest detection and ban engine, but it is not a proxy.
@@ -66,16 +74,20 @@ Closest existing options, and why each still falls short:
listed AS numbers is possible, but each is a hand-written YAML scenario, listed AS numbers is possible, but each is a hand-written YAML scenario,
it reacts after the fact by banning, and it is not an inline limiter that it reacts after the fact by banning, and it is not an inline limiter that
answers 429. answers 429.
- Fails: R10 (needs the security engine container with persistent state, log - Fails:
acquisition from traefik, a bouncer such as the traefik plugin, and YAML - R10, because it needs:
for acquisition, profiles, scenarios and notifications), bytes half of R4. - 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.
- CrowdSec plus traefik's own `rateLimit` middleware is the best combination - CrowdSec plus traefik's own `rateLimit` middleware is the best combination
with no new code. It gives inline limiting (one window per middleware, keyed with no new code. It gives inline limiting (one window per middleware, keyed
by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and
reputation. It still fails R8, the bytes half of R4, R1's three stacked reputation lookups. It still fails R8, R4's alerts on bytes, and R10 entirely:
windows only by chaining three middlewares per router, and R10 entirely: the the configuration lives in traefik labels and CrowdSec YAML on each host, not
configuration lives in traefik labels and CrowdSec YAML on each host, not in in one sidecar's environment. It meets R1's three windows only by chaining
one sidecar's environment. three middlewares per router.
## Candidate by candidate ## Candidate by candidate
@@ -87,10 +99,10 @@ Closest existing options, and why each still falls short:
cached, streamed decision list (recommended), standalone against the cached, streamed decision list (recommended), standalone against the
central API, or AppSec only. Supports captcha remediation. central API, or AppSec only. Supports captcha remediation.
- Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict). - Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict).
- Misses: the plugin itself does no rate limiting; R8; R4 bytes. - Misses: the plugin itself does no rate limiting; R8; R4's alerts on bytes.
- Configuration: traefik static config to load the plugin, dynamic config or - Configuration: traefik static config to load the plugin, dynamic config or
labels for the middleware, CrowdSec YAML for everything else. labels for the middleware, CrowdSec YAML for everything else.
- Sidecar fit: no. It lives inside traefik, plus a separate engine - Sidecar fit: no. It runs inside traefik and needs a separate engine
container. container.
- Maturity: widely used (about 900 stars, listed in the traefik plugin - Maturity: widely used (about 900 stars, listed in the traefik plugin
catalog, documented by CrowdSec itself), actively maintained. Traefik catalog, documented by CrowdSec itself), actively maintained. Traefik
@@ -115,7 +127,7 @@ Closest existing options, and why each still falls short:
- What it is: nginx-derived (Tengine) reverse proxy with a proprietary - What it is: nginx-derived (Tengine) reverse proxy with a proprietary
semantic detection engine and a web console. About 22,000 stars. semantic detection engine and a web console. About 22,000 stars.
- Covers: R9 well, basic per-IP rate limiting with block duration, bot - Covers: R9 well, basic per-IP rate limiting with block duration, bot
challenge, authentication gate. challenge, authentication in front of the app.
- Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert - Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert
messages, syslog forwarding and the better reputation database are paid messages, syslog forwarding and the better reputation database are paid
(Lite about 10 USD per month, Pro about 100 USD per month); the free (Lite about 10 USD per month, Pro about 100 USD per month); the free
@@ -128,7 +140,7 @@ Closest existing options, and why each still falls short:
- Coraza (`corazawaf/coraza`) and Coraza-based proxies - Coraza (`corazawaf/coraza`) and Coraza-based proxies
- What it is: a Go library that implements the ModSecurity rule language and - What it is: a Go library that implements the ModSecurity rule language and
runs the OWASP Core Rule Set. OWASP project, actively maintained, the runs the OWASP Core Rule Set. OWASP project, actively maintained, the
successor path now that ModSecurity is in maintenance only. successor to ModSecurity, which is now in maintenance only.
- Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy), - Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy),
`coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and `coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and
`coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set, `coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set,
@@ -142,7 +154,7 @@ Closest existing options, and why each still falls short:
- ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`) - ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`)
- What it is: official images of Apache or nginx with ModSecurity and the - What it is: official images of Apache or nginx with ModSecurity and the
Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`, Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`,
paranoia level, anomaly thresholds, many more). Current images carry paranoia level, anomaly thresholds, many more). Current images contain
ModSecurity 2.9.x on Apache and Core Rule Set 4.x. ModSecurity 2.9.x on Apache and Core Rule Set 4.x.
- Covers: R9, and R10 (single container, env vars, one backend). - Covers: R9, and R10 (single container, env vars, one backend).
- Misses: R1 to R8. - Misses: R1 to R8.
@@ -152,8 +164,8 @@ Closest existing options, and why each still falls short:
- Anubis (`TecharoHQ/anubis`), 1.27 current - Anubis (`TecharoHQ/anubis`), 1.27 current
- What it is: a single-binary reverse proxy that makes browsers solve a - What it is: a single-binary reverse proxy that makes browsers solve a
proof-of-work challenge before passing them to `TARGET`. Aimed at proof-of-work challenge before passing them to `TARGET`. Aimed at
scrapers, which is likely a large share of unwanted traffic on a public scrapers, which are likely a large share of the unwanted traffic on a
code forge. public code forge.
- Covers: R10 well (one container, env vars for listener, target, - Covers: R10 well (one container, env vars for listener, target,
difficulty, cookies). Policy rules can match on path, user agent, headers, difficulty, cookies). Policy rules can match on path, user agent, headers,
IP ranges, and with the vendor's hosted data service also AS number and IP ranges, and with the vendor's hosted data service also AS number and
@@ -180,13 +192,13 @@ Closest existing options, and why each still falls short:
temporary ban, no escalation, state lost on traefik restart) and R2 temporary ban, no escalation, state lost on traefik restart) and R2
(allowlist). Nothing else. (allowlist). Nothing else.
- `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock - `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock
plugins (country allow or deny through an external lookup API): same plugins (country allow or deny through an external lookup API): each adds
shape, each one small feature, configured in traefik, state in memory. one small feature, is configured in traefik and keeps its state in memory.
- Sidecar fit: no; they live inside traefik. - Sidecar fit: no; they live inside traefik.
- Traefik built-in middlewares - Traefik built-in middlewares
- `rateLimit` (one average-and-burst window per middleware, optional Redis - `rateLimit` (one average-and-burst window per middleware, optional Redis
in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation
or AS number awareness. lookups or AS number lookups.
- caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x - caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x
- What it is: a Caddy module with regex rules and anomaly scoring, per-IP - What it is: a Caddy module with regex rules and anomaly scoring, per-IP
and per-path rate limiting with one configurable window, IP and DNS and per-path rate limiting with one configurable window, IP and DNS
@@ -198,12 +210,12 @@ Closest existing options, and why each still falls short:
- open-appsec (Check Point) - open-appsec (Check Point)
- Machine-learning WAF agent attached to nginx, Kong, Envoy or similar, with - Machine-learning WAF agent attached to nginx, Kong, Envoy or similar, with
a declarative policy file or the vendor's cloud console. Covers R9 only; a declarative policy file or the vendor's cloud console. Covers R9 only;
rate limiting and richer features are in paid tiers. Not a sidecar in the rate limiting and richer features are in paid tiers. It attaches to an
required sense. existing proxy, so it is not the sidecar R10 asks for.
- iocaine - iocaine
- Serves generated garbage pages to clients the fronting proxy classifies as - Serves generated garbage pages to clients the fronting proxy classifies as
scrapers. Not a limiter, WAF or ban tool; out of scope except as a scrapers. Not a limiter, WAF or ban tool; out of scope, and only of
curiosity for scraper traffic. passing interest for scraper traffic.
- Pangolin - Pangolin
- A tunnelled access platform that bundles traefik and optionally CrowdSec. - A tunnelled access platform that bundles traefik and optionally CrowdSec.
Replaces the ingress rather than adding a sidecar; out of scope. Replaces the ingress rather than adding a sidecar; out of scope.
@@ -214,17 +226,17 @@ Closest existing options, and why each still falls short:
Everyone else offers one window per rule or none. Everyone else offers one window per rule or none.
- R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins. - R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins.
- R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid). - R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid).
- R4 volume and byte anomalies: none. CrowdSec can approximate request-count - R4 alerts on request counts and bytes: none. CrowdSec can approximate alerts
anomalies with a custom scenario; nothing handles bytes. on request counts with a custom scenario; no surveyed tool alerts on bytes.
- R5 temporary then permanent: CrowdSec only. BunkerWeb has fixed or permanent, - R5 temporary then permanent: CrowdSec only. BunkerWeb's ban is either one
not both by history. fixed length or permanent, and does not change with a client's history.
- R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists). - R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists).
- R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or - R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or
deny), Anubis (hosted service). deny), Anubis (hosted service).
- R8 biased thresholds: none. - R8 biased thresholds: none.
- R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open, - R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open,
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps the standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps Coraza
first). or ModSecurity).
- R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis. - R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis.
None of these covers anything beyond its one job. None of these covers anything beyond its one job.
+213 -206
View File
@@ -12,8 +12,8 @@ shipped in one image, with two commands that do this together:
- `sww rproxy` is a small reverse proxy in front of one app. traefik routes to - `sww rproxy` is a small reverse proxy in front of one app. traefik routes to
it. It asks `discriminator` about each request, passes the allowed ones to the it. It asks `discriminator` about each request, passes the allowed ones to the
app, and passes the app's answers back. It keeps nothing and never touches app, and passes the app's answers back. It keeps nothing between requests and
disk. writes nothing to disk.
- `sww discriminator` decides. It runs the checks and keeps the bans, the - `sww discriminator` decides. It runs the checks and keeps the bans, the
counters and each client's history. One `discriminator` serves many sites: a counters and each client's history. One `discriminator` serves many sites: a
client banned through one site is refused on all of them, and its requests on client banned through one site is refused on all of them, and its requests on
@@ -25,11 +25,11 @@ without an answer in time, `rproxy` passes the request to the app.
It protects against: It protects against:
- Scrapers that fetch every page of a site, all day. Each client gets a limit on - Scrapers that fetch every page of a site, all day. Each client gets a limit on
requests and on bytes per minute, per hour and per day. The defaults leave requests and on body bytes per minute, per hour and per day. The defaults
room for a person reading a site for hours. They catch a scraper that keeps up leave room for a person reading a site for hours. They catch a scraper that
one request every 2 seconds. A client over a limit is refused and banned: for keeps up one request every 2 seconds. A client that breaks a limit is banned:
a while at first, for three times as long at each repeat, and permanently when for a while at first, for three times as long at each repeat, and permanently
it keeps at it. when it keeps at it.
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The - Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
default rule file bans such a client at its first probe, and its next request default rule file bans such a client at its first probe, and its next request
during the ban makes the ban permanent. Your own rules, or a list of trap during the ban makes the ban permanent. Your own rules, or a list of trap
@@ -91,8 +91,9 @@ the app accepts connections, whether or not `discriminator` answers; in
The usual setup is three containers on one private network: The usual setup is three containers on one private network:
- the app; - the app;
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND` - `rproxy`, in front of the app, which traefik routes to:
names `discriminator` and `SWRP_UPSTREAM_URL` names the app; `SWW_RPROXY_WAF_BACKEND` names `discriminator` and `SWW_RPROXY_UPSTREAM_URL`
names the app;
- `discriminator`, with `SWW_COMMAND=discriminator` and a volume at - `discriminator`, with `SWW_COMMAND=discriminator` and a volume at
`/var/lib/sww` for its state files. `/var/lib/sww` for its state files.
@@ -101,8 +102,8 @@ Point traefik's labels at port 8080 of `rproxy`
of the app or of `discriminator`. Anything on that network can reach the app of the app or of `discriminator`. Anything on that network can reach the app
around `rproxy`, and can ask `discriminator` questions, so the network holds around `rproxy`, and can ask `discriminator` questions, so the network holds
only what you trust. Each further app adds its own container and its own only what you trust. Each further app adds its own container and its own
`rproxy`, whose `SWRP_WAF_BACKEND` names the same `discriminator`. The "Smallest `rproxy`, whose `SWW_RPROXY_WAF_BACKEND` names the same `discriminator`. The
setup" example below shows this shape. "Smallest setup" example below shows this shape.
### `rproxy` in the app's container ### `rproxy` in the app's container
@@ -115,7 +116,7 @@ by digest, and adds:
- the packages the app needs, from the nixpkgs in the image; - the packages the app needs, from the nixpkgs in the image;
- a user for the app; - a user for the app;
- a `run` script that starts the app listening on `127.0.0.1:8081`, the default - a `run` script that starts the app listening on `127.0.0.1:8081`, the default
of `SWRP_UPSTREAM_URL`. of `SWW_RPROXY_UPSTREAM_URL`.
```dockerfile ```dockerfile
# The smallwebwaf image, pinned by digest. # The smallwebwaf image, pinned by digest.
@@ -151,8 +152,8 @@ main "$@"
Leave `ENTRYPOINT` and `USER` out of the app's `Dockerfile`. The image's own Leave `ENTRYPOINT` and `USER` out of the app's `Dockerfile`. The image's own
entrypoint starts `rproxy` and the app side by side, each as its own user. Run entrypoint starts `rproxy` and the app side by side, each as its own user. Run
the app's image with `SWRP_WAF_BACKEND` set. `deploy/example-app` is such an the app's image with `SWW_RPROXY_WAF_BACKEND` set. `deploy/example-app` is such
app, and the "`rproxy` beside the app" example below runs one. an app, and the "`rproxy` beside the app" example below runs one.
The app sees the headers traefik sent, with traefik's address added to The app sees the headers traefik sent, with traefik's address added to
`X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust `X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust
@@ -172,12 +173,12 @@ does.
`https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about `https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about
the request have `"action":"forward"`. the request have `"action":"forward"`.
- To see a refusal without banning yourself, run `discriminator` with - To see a refusal without banning yourself, run `discriminator` with
`SWW_MODE=observe` and ask for `/.env`. The request reaches the app. `SWW_WAF_MODE=observe` and ask for `/.env`. The request reaches the app.
`discriminator`'s log lines have `"would_action":"banned"` and `discriminator`'s log lines have `"would_action":"temporarily_banned"` and
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as `"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
"Clients and bans" below describes. "Clients and bans" below describes.
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on - With `SWW_WAF_METRICS_TOKEN` set, this answers with the metrics, from a host
`discriminator`'s network: on `discriminator`'s network:
`curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`. `curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`.
- `make example-app` builds the image and an app on it, runs them, and checks - `make example-app` builds the image and an app on it, runs them, and checks
the whole exchange (see "Entrypoints"). the whole exchange (see "Entrypoints").
@@ -185,18 +186,18 @@ does.
## Configuration ## Configuration
Every setting is an environment variable, read once at start; changing one means Every setting is an environment variable, read once at start; changing one means
restarting the container. `rproxy` reads only the `SWRP_` settings, and restarting the container. `rproxy` reads only the `SWW_RPROXY_` settings, and
`discriminator` only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a `discriminator` only the `SWW_WAF_` ones. Every setting but
default, chosen for a service on the open internet. A setting that needs an `SWW_RPROXY_WAF_BACKEND` has a default, chosen for a service on the open
account, a key, a token or an alert destination stays off until you give one. internet. A setting that needs an account, a key, a token or an alert
destination stays off until you give one.
- Any `SWW_` setting can be given as a file instead, for secrets and long lists: - Any `SWW_WAF_` or `SWW_RPROXY_` setting can be given as a file instead, for
`SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's secrets and long lists: `SWW_WAF_ADMIN_TOKEN_FILE` names the file that holds
contents are the value, less one newline at the end. Setting both forms stops `SWW_WAF_ADMIN_TOKEN`. The file's contents are the value, less one newline at
the start. `SWW_LOG_REMOTE_TLS_CA_FILE` and the end. Setting both forms stops the start. `SWW_WAF_LOG_REMOTE_TLS_CA_FILE`
`SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no and `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have
`_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has no `_FILE` form. `SWW_COMMAND` cannot be given as a file.
one, since `rproxy` never touches disk.
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. - A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of - A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
1024: `512K`, `100M`, `5G`. 1024: `512K`, `100M`, `5G`.
@@ -210,129 +211,134 @@ account, a key, a token or an alert destination stays off until you give one.
percentage is a whole number from 0 to 100. A path prefix starts with `/`. percentage is a whole number from 0 to 100. A path prefix starts with `/`.
- `off` switches a limit, a threshold or a timeout off where the table says a - `off` switches a limit, a threshold or a timeout off where the table says a
setting takes it. setting takes it.
- The byte limits and the byte anomaly thresholds count only body bytes: the
app's response bodies, the visitors' request bodies, or both, as
`SWW_WAF_BYTES_COUNT` says. Headers are never counted. What the visitor sent
over a WebSocket counts as request bytes, and what the app sent as response
bytes.
- An invalid setting stops the process at start with one log line naming the - An invalid setting stops the process at start with one log line naming the
variable and what is wrong with the value. The settings in effect are logged variable and what is wrong with the value. The settings in effect are logged
on one line at start, with tokens, keys and webhook paths shown as `********`. on one line at start, with tokens, keys and webhook paths shown as `********`.
Every setting the code reads, with its default: Every setting the code reads, with its default:
| Variable | Default | What it does | | Variable | Default | What it does |
| ---------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. | | `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. | | `SWW_RPROXY_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. | | `SWW_RPROXY_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. | | `SWW_RPROXY_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. | | `SWW_RPROXY_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. | | `SWW_RPROXY_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`. | | `SWW_RPROXY_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`. | | `SWW_RPROXY_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`; a larger one is answered `502`, or cut off once it has started. | | `SWW_RPROXY_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` | 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`. | | `SWW_RPROXY_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. | | `SWW_RPROXY_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_WAF_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. | | `SWW_WAF_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. | | `SWW_WAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. | | `SWW_WAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. | | `SWW_WAF_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_WAF_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_WAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. | | `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. | | `SWW_WAF_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`; one more bans it. | | `SWW_WAF_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`; one more bans it. | | `SWW_WAF_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`; one more bans it. | | `SWW_WAF_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 whose requests the request and byte limits neither count nor refuse, such as `/assets/`. | | `SWW_WAF_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 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_WAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. | | `SWW_WAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. | | `SWW_WAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_WAF_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` | Which body bytes the byte limits count: `response`, `request` or `both`. | | `SWW_WAF_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 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_WAF_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_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. | | `SWW_WAF_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_WAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
| `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_WAF_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_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. | | `SWW_WAF_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_WAF_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_WAF_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`, or `off`. | | `SWW_WAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. | | `SWW_WAF_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_WAF_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_WAF_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_WAF_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_WAF_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_WAF_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_WAF_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_WAF_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. | | `SWW_WAF_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_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. | | `SWW_WAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). | | `SWW_WAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. | | `SWW_WAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_WAF_TAG_LIMIT_PERCENT` (the lowest wins). |
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. | | `SWW_WAF_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. | | `SWW_WAF_RULES_ENABLED` | `true` | `false` reads no rule file. |
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. | | `SWW_WAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. | | `SWW_WAF_CORE_RULE_SET_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. | | `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. | | `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. | | `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. | | `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. | | `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWW_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;cloud.example.com:gitea`; only those apps' fixes apply there. Unset, every app's fixes apply to every hostname. | | `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. | | `SWW_WAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. | | `SWW_WAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. | | `SWW_WAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. | | `SWW_WAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. | | `SWW_WAF_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. | | `SWW_WAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. | | `SWW_WAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. | | `SWW_WAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. | | `SWW_WAF_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. | | `SWW_WAF_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. | | `SWW_WAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. | | `SWW_WAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. | | `SWW_WAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. | | `SWW_WAF_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. | | `SWW_WAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. | | `SWW_WAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
| `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. | | `SWW_WAF_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. | | `SWW_WAF_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. | | `SWW_WAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. | | `SWW_WAF_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. | | `SWW_WAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. | | `SWW_WAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. | | `SWW_WAF_LOG_REMOTE_APP_NAME` | `SWW_WAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. | | `SWW_WAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. | | `SWW_WAF_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. | | `SWW_WAF_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. | | `SWW_WAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. | | `SWW_WAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. | | `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. | | `SWW_WAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. | | `SWW_WAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. | | `SWW_WAF_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. | | `SWW_WAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. | | `SWW_WAF_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_WAF_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_WAF_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 one client's requests carry more bytes than this in a minute, a size. | | `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one client 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_WAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one client 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_WAF_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_WAF_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 the netblock around a client carries more bytes than this in a minute, a size. | | `SWW_WAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_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 the netblock around a client carries more bytes than this in an hour, a size. | | `SWW_WAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the netblock around a client 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_WAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_WAF_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_WAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_WAF_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_WAF_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_WAF_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 one AS number carries more bytes than this in a minute, a size. | | `SWW_WAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one AS number 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_WAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one AS number 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_WAF_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_WAF_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 the whole service carries more bytes than this in a minute, a size. | | `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the whole service 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_WAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the whole service 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_WAF_WATCH_NETS` | empty | Named netblocks the `SWW_WAF_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_WAF_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_WAF_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 a watched netblock carries more bytes than this in a minute, a size. | | `SWW_WAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for a watched netblock 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. | | `SWW_WAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in [`docs/settings.md`](docs/settings.md) gives the values each setting takes in
full, and the limits that are fixed rather than settings. full, and the limits that are fixed rather than settings.
@@ -359,8 +365,8 @@ services:
rproxy: rproxy:
image: smallwebwaf image: smallwebwaf
environment: environment:
SWRP_WAF_BACKEND: http://discriminator:8080 SWW_RPROXY_WAF_BACKEND: http://discriminator:8080
SWRP_UPSTREAM_URL: http://app:8081 SWW_RPROXY_UPSTREAM_URL: http://app:8081
ports: ports:
- "8080:8080" - "8080:8080"
@@ -395,8 +401,8 @@ services:
rproxy: rproxy:
image: smallwebwaf image: smallwebwaf
environment: environment:
SWRP_WAF_BACKEND: http://discriminator:8080 SWW_RPROXY_WAF_BACKEND: http://discriminator:8080
SWRP_UPSTREAM_URL: http://app:8081 SWW_RPROXY_UPSTREAM_URL: http://app:8081
ports: ports:
- "8080:8080" - "8080:8080"
@@ -405,22 +411,22 @@ services:
environment: environment:
SWW_COMMAND: discriminator SWW_COMMAND: discriminator
# Monitoring skips every check. # Monitoring skips every check.
SWW_ALLOW_NETS: 192.0.2.10 SWW_WAF_ALLOW_NETS: 192.0.2.10
# The office and the automation host are never rate limited. # The office and the automation host are never rate limited.
SWW_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7 SWW_WAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
# A netblock that sends nothing wanted. # A netblock that sends nothing wanted.
SWW_DENY_NETS: 198.51.100.128/25 SWW_WAF_DENY_NETS: 198.51.100.128/25
# Tighter limits for everyone else: a small static site. # Tighter limits for everyone else: a small static site.
SWW_RATE_LIMIT_PER_MINUTE: "60" SWW_WAF_RATE_LIMIT_PER_MINUTE: "60"
SWW_RATE_LIMIT_PER_HOUR: "600" SWW_WAF_RATE_LIMIT_PER_HOUR: "600"
SWW_RATE_LIMIT_PER_DAY: "3000" SWW_WAF_RATE_LIMIT_PER_DAY: "3000"
# Hosting networks and one country get a share of those limits. # Hosting networks and one country get a share of those limits.
SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25 SWW_WAF_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25
# Paths only scanners ask for; one request bans the client. # Paths only scanners ask for; one request bans the client.
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php SWW_WAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php
# Answer a refused client with 429 rather than 403. # Answer a refused client with 429 rather than 403.
SWW_BAN_RESPONSE: "429" SWW_WAF_BAN_RESPONSE: "429"
SWW_LIMIT_BAN_DURATION: 30m SWW_WAF_LIMIT_BAN_DURATION: 30m
volumes: volumes:
- discriminator-state:/var/lib/sww - discriminator-state:/var/lib/sww
@@ -446,9 +452,9 @@ services:
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside. # A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
build: ./app build: ./app
environment: environment:
SWRP_WAF_BACKEND: http://discriminator:8080 SWW_RPROXY_WAF_BACKEND: http://discriminator:8080
# Answer 503 rather than pass a request on while discriminator is down. # Answer 503 rather than pass a request on while discriminator is down.
SWRP_DEFAULT_DISPOSITION: deny SWW_RPROXY_DEFAULT_DISPOSITION: deny
ports: ports:
- "8080:8080" - "8080:8080"
@@ -456,16 +462,16 @@ services:
image: smallwebwaf image: smallwebwaf
environment: environment:
SWW_COMMAND: discriminator SWW_COMMAND: discriminator
SWW_INSTANCE_NAME: fsn1/discriminator SWW_WAF_INSTANCE_NAME: fsn1/discriminator
# Refuse nothing yet; log what enforce mode would have done. # Refuse nothing yet; log what enforce mode would have done.
SWW_MODE: observe SWW_WAF_MODE: observe
# Look addresses up in a local IPinfo Lite database, never GeoJS. # Look addresses up in a local IPinfo Lite database, never GeoJS.
SWW_LOOKUP_SOURCE: file SWW_WAF_LOOKUP_SOURCE: file
SWW_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb SWW_WAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
# Alerts to an ntfy topic, with its token read from a file. # Alerts to an ntfy topic, with its token read from a file.
SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf SWW_WAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy SWW_WAF_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
SWW_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error SWW_WAF_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error
volumes: volumes:
- discriminator-state:/var/lib/sww - discriminator-state:/var/lib/sww
- /srv/ipinfo:/var/lib/ipinfo:ro - /srv/ipinfo:/var/lib/ipinfo:ro
@@ -502,18 +508,18 @@ of the request reaches the app.
1. The health check, `GET /_sww/healthz`, is answered at once. 1. The health check, `GET /_sww/healthz`, is answered at once.
2. The visitor's address is worked out from `X-Forwarded-For`, believed only 2. The visitor's address is worked out from `X-Forwarded-For`, believed only
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request as from `SWW_RPROXY_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request
the visitor sent it, with the first 64 KiB of its body. as the visitor sent it, with the first 64 KiB of its body.
`discriminator` takes the checks: `discriminator` takes the checks:
3. The tag lists. 3. The tag lists.
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`. 4. The static lists, `SWW_WAF_ALLOW_NETS` and `SWW_WAF_DENY_NETS`.
5. The bans. 5. The bans.
6. The lookup of the client's AS number and country. 6. The lookup of the client's AS number and country.
7. The country list. 7. The country list.
8. The reputation sources. 8. The reputation sources.
9. The tag refusals of `SWW_TAG_DENY`. 9. The tag refusals of `SWW_WAF_TAG_DENY`.
10. The client's limit percentage, from its tags, its AS number, its country or 10. The client's limit percentage, from its tags, its AS number, its country or
a reputation hit. a reputation hit.
11. The rate limits. 11. The rate limits.
@@ -528,23 +534,25 @@ of the request reaches the app.
17. The request is passed to the app, streaming. 17. The request is passed to the app, streaming.
18. After the answer, `rproxy` sends `discriminator` a report with the status 18. After the answer, `rproxy` sends `discriminator` a report with the status
and the bytes each way. For an allowed request, `discriminator` counts the and the body bytes each way. For an allowed request, `discriminator` counts
bytes toward the byte limits and adds the request to the client's history. the bytes `SWW_WAF_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.
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core A refused visitor is answered with `SWW_WAF_BAN_RESPONSE`. A `block` rule or a
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A false positives; such refusals count toward `SWW_WAF_ERROR_BURST_THRESHOLD`
request over the header limits of step 14 is answered `431`, and bans nobody instead. A request over the header limits of step 14 is answered `431`, and bans
either. `rproxy` itself answers `405` to a method it does not pass on, and the nobody either. `rproxy` itself answers `405` to a method it does not pass on,
status for a size or time limit, such as `413` for a body too large. and the status for a size or time limit, such as `413` for a body too large.
### Clients and bans ### Clients and bans
- A client is one IPv4 address or one IPv6 netblock of `SWW_IPV6_GROUP_PREFIX`, - A client is one IPv4 address or one IPv6 netblock of
since one abuser usually controls a whole /64. `SWW_WAF_IPV6_GROUP_PREFIX`, since one abuser usually controls a whole /64.
- Each limit is counted over a sliding window with a few integers per client. - Each limit is counted over a sliding window with a few integers per client.
The table of clients and the table of bans each have a bound The table of clients and the table of bans each have a bound
(`SWW_MAX_TRACKED_CLIENTS`, `SWW_MAX_BANS`), so memory is bounded too. (`SWW_WAF_MAX_TRACKED_CLIENTS`, `SWW_WAF_MAX_BANS`), so memory is bounded too.
- A ban refuses every request from its netblock until it ends. Requests refused - A ban refuses every request from its netblock until it ends. Requests refused
under a ban count toward no limit. A permanent ban ends only when an admin under a ban count toward no limit. A permanent ban ends only when an admin
lifts it. lifts it.
@@ -559,32 +567,30 @@ Each request gets tags from what `discriminator` knows of its client, written in
the `tags` field of its log line: the `tags` field of its log line:
- `asn=<number>` and `country=<code>`, from the lookup; - `asn=<number>` and `country=<code>`, from the lookup;
- one tag per list of `SWW_TAG_LISTS` that holds the client, such as `torexit` - one tag per list of `SWW_WAF_TAG_LISTS` that holds the client, such as
or `vpn=mullvad`; `torexit` or `vpn=mullvad`;
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists - `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
the client; the client;
- `exploitsignature`, when a rule file or the Core Rule Set matched the request. - `exploitsignature`, when a rule file or the Core Rule Set matched the request;
- `asn=<number>` and `prefix=<netblock>`, from a routewatch instance, while
`SWW_WAF_ROUTEWATCH_URL` is set.
`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a `SWW_WAF_TAG_DENY` refuses requests by tag, and `SWW_WAF_TAG_LIMIT_PERCENT`
client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md). lowers a client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
### Attack detection ### Attack detection
- The Core Rule Set reads the method, the URL with its query string, and the - The Core Rule Set reads the method, the URL with its query string, and the
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In headers of every request, and no body unless
front of a code forge, bodies carry what people write and publish, which the `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set. In front of a code forge, bodies
rules take for attacks. carry what people write and publish, which the rules take for attacks.
- `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary - `smallwebwaf` changes the Core Rule Set in eight ways so that it lets ordinary
requests to a code forge through, such as git over HTTP and sign-in through requests to a code forge through, such as git over HTTP and sign-in through
Git Credential Manager. No setting undoes the changes; "Attack detection" in Git Credential Manager. On gitea's own paths, its searches, file names and
[`SPEC.md`](SPEC.md) lists them. branch names get through too. No setting undoes the changes; "Attack
- Each supported app also has its own fixes, which let its ordinary requests detection" in [`SPEC.md`](SPEC.md) lists them.
through. gitea's let its searches, file names and branch names through. - `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's
`SWW_APPS` names the apps each hostname serves. A hostname gets only its own own fields or paths.
apps' fixes, and a hostname it does not list gets none. Unset, every app's
fixes apply to every hostname.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
fields or paths.
- Rule files are plain text, one rule to a line, edited by hand and read again - Rule files are plain text, one rule to a line, edited by hand and read again
while `discriminator` runs. The image ships one file of probes no real visitor while `discriminator` runs. The image ships one file of probes no real visitor
sends (see [`docs/rules.md`](docs/rules.md)). sends (see [`docs/rules.md`](docs/rules.md)).
@@ -592,7 +598,7 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
### State files ### State files
`discriminator` keeps everything in memory, and the five JSON files in `discriminator` keeps everything in memory, and the five JSON files in
`SWW_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`, `SWW_WAF_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
`reputation.json` and `alerts.json`. Each is written whole, to a temporary file `reputation.json` and `alerts.json`. Each is written whole, to a temporary file
renamed over the real one, so a crash leaves a complete file. `discriminator` renamed over the real one, so a crash leaves a complete file. `discriminator`
notices a file edited on disk and takes the edit in; an edit that does not parse notices a file edited on disk and takes the edit in; an edit that does not parse
@@ -629,9 +635,10 @@ is set aside as `<name>.bad`, and never stops the running process (see
- `internal/rproxy`: the reverse proxy in front of one app. - `internal/rproxy`: the reverse proxy in front of one app.
- `internal/discriminator`: `discriminator`'s server, which runs the checks and - `internal/discriminator`: `discriminator`'s server, which runs the checks and
answers its own endpoints. answers its own endpoints.
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`, - `internal/ratelimit`, `internal/bans`, `internal/rules`,
`internal/lookup`, `internal/reputation`, `internal/anomaly` and `internal/coreruleset`, `internal/lookup`, `internal/reputation`,
`internal/alerts`: one check or source each. `internal/routewatch`, `internal/anomaly` and `internal/alerts`: one check or
source each.
- `internal/state`, `internal/requestlog`, `internal/remotelog` and - `internal/state`, `internal/requestlog`, `internal/remotelog` and
`internal/metrics`: the state files, the log lines, the syslog sender and the `internal/metrics`: the state files, the log lines, the syslog sender and the
metrics. metrics.
+501 -475
View File
File diff suppressed because it is too large. Load diff
+7 -7
View File
@@ -5,16 +5,16 @@ The endpoints under `/_sww/`, what each takes and what each answers.
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work. "Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
- `GET /_sww/healthz`: `200` and `ok`, to anyone. - `GET /_sww/healthz`: `200` and `ok`, to anyone.
- `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see - `GET /_sww/metrics`: the metrics, for `SWW_WAF_METRICS_TOKEN` (see
[`metrics.md`](metrics.md)). [`metrics.md`](metrics.md)).
- `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it. - `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it.
Needs `SWW_ADMIN_TOKEN`, as do the three below. Needs `SWW_WAF_ADMIN_TOKEN`, as do the three below.
- `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`, - `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`,
`duration` and, if you like, `reason`. `netblock` is a netblock such as `duration` and, if you like, `reason`. `netblock` is a netblock such as
`203.0.113.0/24`, or a client's address, which bans its IPv4 netblock `203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
(`SWW_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such (`SWW_WAF_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is such as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying
wrong: what is wrong:
- one that is not such an object, or has another field; - one that is not such an object, or has another field;
- one longer than 4 KiB; - one longer than 4 KiB;
- one that names an IPv4-mapped netblock, or an address with a zone. - one that names an IPv4-mapped netblock, or an address with a zone.
@@ -38,8 +38,8 @@ in [`SPEC.md`](../SPEC.md)).
## Examples ## Examples
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached With `SWW_WAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator`
as `discriminator:8080` on its network: reached as `discriminator:8080` on its network:
```sh ```sh
# Every ban. # Every ban.
+8 -8
View File
@@ -9,9 +9,9 @@ back or dropped.
- `ban`: a ban `discriminator` made. - `ban`: a ban `discriminator` made.
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by - `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
a request. a request.
- `waf_block`: the Core Rule Set scored a request at or over the threshold. The - `core_rule_set_match`: the Core Rule Set scored a request at or over the
request was refused unless the alert's `detail` gives `mode`: `detect` or threshold. The request was refused unless the alert's `detail` gives `mode`:
`observe`. `detect` or `observe`.
- `anomaly`: a count over an anomaly threshold. - `anomaly`: a count over an anomaly threshold.
- `reputation_hit`: a request from a client on a blocklist, the CrowdSec - `reputation_hit`: a request from a client on a blocklist, the CrowdSec
decision list or a DNS blocklist, or with an AbuseIPDB score that is a hit. decision list or a DNS blocklist, or with an AbuseIPDB score that is a hit.
@@ -77,8 +77,8 @@ on one line:
} }
``` ```
- `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`: - `instance`: `SWW_WAF_INSTANCE_NAME`. `time`: when the alert was raised.
one of the events above. `event`: one of the events above.
- `host`: the site of the request that raised the alert, since one - `host`: the site of the request that raised the alert, since one
`discriminator` serves many sites. It is left out of `source_failure`, `discriminator` serves many sites. It is left out of `source_failure`,
`file_error` and `summary`. `file_error` and `summary`.
@@ -122,15 +122,15 @@ Slack gets the title in bold and the text below it, with `&`, `<` and `>`
escaped. ntfy gets the text as the message, with these headers: escaped. ntfy gets the text as the message, with these headers:
- `Title`: the title; - `Title`: the title;
- `Authorization`: the token, as `Bearer <token>`, while `SWW_ALERT_NTFY_TOKEN` - `Authorization`: the token, as `Bearer <token>`, while
is set; `SWW_WAF_ALERT_NTFY_TOKEN` is set;
- `Priority` and `Tags`: by event, as listed below. - `Priority` and `Tags`: by event, as listed below.
The priority and tag by event: The priority and tag by event:
- `ban`: priority `default`, tag `no_entry`. - `ban`: priority `default`, tag `no_entry`.
- `permanent_ban`: priority `high`, tag `no_entry`. - `permanent_ban`: priority `high`, tag `no_entry`.
- `waf_block`: priority `default`, tag `shield`. - `core_rule_set_match`: priority `default`, tag `shield`.
- `anomaly`: priority `high`, tag `chart_with_upwards_trend`. - `anomaly`: priority `high`, tag `chart_with_upwards_trend`.
- `reputation_hit`: priority `low`, tag `label`. - `reputation_hit`: priority `low`, tag `label`.
- `source_failure` and `file_error`: priority `high`, tag `warning`. - `source_failure` and `file_error`: priority `high`, tag `warning`.
+10 -6
View File
@@ -5,6 +5,9 @@ What `GET /_sww/metrics` answers with, at `discriminator`'s own address.
keeps the `instance` label as `exported_instance` unless the scrape sets keeps the `instance` label as `exported_instance` unless the scrape sets
`honor_labels: true`. `rproxy` has no metrics. `honor_labels: true`. `rproxy` has no metrics.
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
body bytes, never headers, whatever `SWW_WAF_BYTES_COUNT` says.
## Traffic ## Traffic
- `sww_requests_total`, `sww_request_bytes_total` and - `sww_requests_total`, `sww_request_bytes_total` and
@@ -25,11 +28,12 @@ keeps the `instance` label as `exported_instance` unless the scrape sets
## Attack detection ## Attack detection
- `sww_rule_matches_total`, by `rule_id` and `action`, and `sww_rules_loaded`; - `sww_rule_matches_total`, by `rule_id` and `action`, and `sww_rules_loaded`;
- `sww_waf_matches_total`, by `mode` and `rule_id`, for the rules that fired. - `sww_core_rule_set_matches_total`, by `mode` and `rule_id`, for the rules that
fired.
## Countries and AS numbers ## Countries and AS numbers
For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`: For the `SWW_WAF_METRICS_TOP_N` busiest of each, the rest as `other`:
- `sww_country_requests_total`, `sww_country_request_bytes_total`, - `sww_country_requests_total`, `sww_country_request_bytes_total`,
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by `sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
@@ -40,8 +44,8 @@ For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`:
## Lookups ## Lookups
- `sww_geojs_requests_total` and `sww_geojs_failures_total`; - `sww_geojs_requests_total` and `sww_geojs_failures_total`;
- `sww_geojs_unanswered_total`: the requests that needed an answer and went on - `sww_requests_without_geojs_answer_total`: the requests that needed an answer
without it; and went on without it;
- with the file source, `sww_lookup_database_last_read_timestamp_seconds` and - with the file source, `sww_lookup_database_last_read_timestamp_seconds` and
`sww_lookup_database_read_failures_total`. `sww_lookup_database_read_failures_total`.
@@ -67,8 +71,8 @@ By `file`:
## Remote log, alerts and the process ## Remote log, alerts and the process
- while a syslog server is set, `sww_remote_log_lines_sent_total`, - while a syslog server is set, `sww_remote_log_lines_sent_total`,
`sww_remote_log_lines_dropped_total` and `sww_remote_log_buffer_depth`; `sww_remote_log_lines_dropped_total` and `sww_remote_log_lines_waiting`;
- by `destination`, `sww_alerts_sent_total`, `sww_alerts_failed_total`, - by `destination`, `sww_alerts_sent_total`, `sww_alert_send_failures_total`,
`sww_alerts_suppressed_total` and `sww_alerts_dropped_total`; `sww_alerts_suppressed_total` and `sww_alerts_dropped_total`;
- Go's own `go_` metrics and the process's `process_` metrics. - Go's own `go_` metrics and the process's `process_` metrics.
+57 -44
View File
@@ -26,7 +26,7 @@ These questions write no line:
A `report` line: A `report` line:
``` ```
{"type":"request","discriminator_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/discriminator","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"waf_score":0,"duration_total":3.217,"duration_checks":0.041,"duration_waf":0.029,"duration_upstream_total":3.104} {"type":"request","discriminator_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/discriminator","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"core_rule_set_score":0,"duration_total_ms":3.217,"duration_checks_ms":0.041,"duration_core_rule_set_ms":0.029,"duration_upstream_total_ms":3.104}
``` ```
### Every line ### Every line
@@ -37,14 +37,14 @@ Every one of these lines has:
- the standard web log fields; - the standard web log fields;
- `request_id`, `peer_ip` and `client_group`; - `request_id`, `peer_ip` and `client_group`;
- `asn`, `as_name` and `country`; - `asn`, `as_name` and `country`;
- `action` and `duration_total`. - `action` and `duration_total_ms`.
### Standard web log fields ### Standard web log fields
- `time`: when `discriminator` had read the question's body, RFC 3339 with - `time`: when `discriminator` had read the question's body, RFC 3339 with
milliseconds, in UTC. The `report` line gives the same time as the `decision` milliseconds, in UTC. The `report` line gives the same time as the `decision`
line. line.
- `instance`: `SWW_INSTANCE_NAME`. - `instance`: `SWW_WAF_INSTANCE_NAME`.
- `client_ip`: the visitor's address, as `rproxy` worked it out. - `client_ip`: the visitor's address, as `rproxy` worked it out.
- `method`, `path` and `query`: the visitor's request. - `method`, `path` and `query`: the visitor's request.
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of - `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
@@ -82,8 +82,8 @@ Every one of these lines has:
- GeoJS had not answered by the time `discriminator` answered. - GeoJS had not answered by the time `discriminator` answered.
- `content_type`: the visitor's `Content-Type`. - `content_type`: the visitor's `Content-Type`.
- `content_length`: the body length the visitor announced. - `content_length`: the body length the visitor announced.
- `request_headers`: the headers listed in `SWW_LOG_REQUEST_HEADERS`, by name in - `request_headers`: the headers listed in `SWW_WAF_LOG_REQUEST_HEADERS`, by
lower case. name in lower case.
- `has_authorization` and `has_cookie`: true when the request had those headers. - `has_authorization` and `has_cookie`: true when the request had those headers.
Their values are never logged. Their values are never logged.
@@ -96,15 +96,17 @@ Every one of these lines has:
- `action`: what was done: - `action`: what was done:
- `forward`: answered `allow`, so that `rproxy` passes it to the app; - `forward`: answered `allow`, so that `rproxy` passes it to the app;
- `denied`: refused for `SWW_DENY_NETS`, for a reputation source with the - `client_denied`: refused for `SWW_WAF_DENY_NETS`, for a reputation source
action `deny`, or for a tag in `SWW_TAG_DENY`; with the action `deny`, or for a tag in `SWW_WAF_TAG_DENY`;
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a - `temporarily_banned`: refused under a ban that runs out, or for a `ban`
CrowdSec decision, each of which bans the client; rule, a trap path or a CrowdSec decision that made such a ban;
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`: - `permanently_banned`: refused under a ban that never runs out, including a
refused by that check; ban this request made or made permanent;
- `too_large`: over a header limit; - `country_denied`, `client_is_rate_limited`, `blocked_by_block_rule` and
- `admin`: answered at one of `discriminator`'s own endpoints, or `404` for `blocked_by_core_rule_set`: refused by that check;
a path under `/_sww/`. - `request_header_too_large`: over a header limit;
- `own_endpoint`: answered at one of `discriminator`'s own endpoints, or
`404` for a path under `/_sww/`.
- `would_action`: in `observe` mode, what `enforce` mode would have done with a - `would_action`: in `observe` mode, what `enforce` mode would have done with a
request it would have refused. `action` then names what was done, normally request it would have refused. `action` then names what was done, normally
`forward`. `forward`.
@@ -112,21 +114,23 @@ Every one of these lines has:
is left out when none did. is left out when none did.
- `heuristic_figures`: an object of the figures that heuristic acted on. Each - `heuristic_figures`: an object of the figures that heuristic acted on. Each
heuristic and its figures: heuristic and its figures:
- `deny_nets`: `netblock`, the entry of `SWW_DENY_NETS` the client is in; - `deny_nets`: `netblock`, the entry of `SWW_WAF_DENY_NETS` the client is
in;
- `active_ban`: `netblock`, the ban's netblock, and `ban_expires`; - `active_ban`: `netblock`, the ban's netblock, and `ban_expires`;
- `countries`: `country`, and `setting`, the country list that refused it; - `countries`: `country`, and `setting`, the country list that refused it;
- `tag_deny`: `tag`, the tag of `SWW_TAG_DENY` the request carries; - `tag_deny`: `tag`, the tag of `SWW_WAF_TAG_DENY` the request carries;
- `blocklists`: `lists`, the blocklist URLs that list the client; - `blocklists`: `lists`, the blocklist URLs that list the client;
- `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the - `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the
decision ends; decision ends;
- `dnsbl`: `zones`, the DNS blocklist zones that list the client, their keys - `dnsbl`: `zones`, the DNS blocklist zones that list the client, their keys
masked; masked;
- `abuseipdb`: `score`, and `min_score`, `SWW_ABUSEIPDB_MIN_SCORE`; - `abuseipdb`: `score`, and `min_score`, `SWW_WAF_ABUSEIPDB_MIN_SCORE`;
- `rate_limits`: `window`, `count` and `limit`; - `rate_limits`: `window`, `count` and `limit`;
- `trap_paths`: `path`, the trap path asked for; - `trap_paths`: `path`, the trap path asked for;
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it; - `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`; - `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
- `core_rule_set`: `score`, and `threshold`, `SWW_WAF_ANOMALY_THRESHOLD`; - `core_rule_set`: `score`, and `threshold`,
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`;
- `refusal_limit`: `window`, `count` and `limit`, of the error burst; - `refusal_limit`: `window`, `count` and `limit`, of the error burst;
- `byte_limits`: `window`, `count` and `limit`, the count in bytes. - `byte_limits`: `window`, `count` and `limit`, the count in bytes.
- `after_answer_heuristic`: the heuristic that banned the client after - `after_answer_heuristic`: the heuristic that banned the client after
@@ -138,32 +142,35 @@ Every one of these lines has:
`byte_limits` bans when the report comes, so only the `report` line names `byte_limits` bans when the report comes, so only the `report` line names
it. it.
- `limit_percent` and `limit_percent_setting`: the client's percentage of the - `rate_limit_percent` and `rate_limit_percent_setting`: the client's percentage
rate limits and the setting that lowered it, when one did. `bytes_percent` and of the rate limits and the setting that lowered it, when one did.
`bytes_percent_setting` do the same for the byte limits. `bytes_limit_percent` and `bytes_limit_percent_setting` do the same for the
byte limits.
- `counts`: the client's totals as rate limiting counts them, this request - `counts`: the client's totals as rate limiting counts them, this request
included. It is left out when rate limiting did not count the request. It included. It is left out when rate limiting did not count the request. It
holds: holds:
- `minute`, `hour` and `day`: requests; - `minute`, `hour` and `day`: requests;
- `minute_bytes`, `hour_bytes` and `day_bytes`: bytes. - `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
`SWW_WAF_BYTES_COUNT` names, in that window.
A count can have a fraction (see "Counting method" in A count can have a fraction (see "Counting method" in
[`SPEC.md`](../SPEC.md)). The bytes of an allowed request are counted when [`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
its report comes. So on the `decision` line, the bytes are without this response are added when its report comes. So on the `decision` line, the
request's. three byte totals leave them out.
- `limit_hit`: the limit broken: - `limit_hit`: the limit broken:
- `minute`, `hour` or `day` for a rate limit; - `minute`, `hour` or `day` for a rate limit;
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit; - `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
- `error_burst`. - `minute_refusals` for the error burst (`SWW_WAF_ERROR_BURST_THRESHOLD`).
A request whose bytes broke a limit was passed on whole, so its `action` When the bytes counted for a request and its response break a limit, the
stays `forward`. Only its `report` line gives that limit. request has been 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 - `rule_ids`: the rule file rules that matched, in order, up to the one that
refused. refused.
- `waf_score` and `waf_rule_ids`: the Core Rule Set's score and the rules that - `core_rule_set_score` and `core_rule_set_rule_ids`: the Core Rule Set's score
matched, for a request it inspected. and the rules that matched, for a request it inspected.
- `reputation`: the sources the client is on, in this order: - `reputation`: the sources the client is on, in this order:
- blocklist URLs; - blocklist URLs;
- the CrowdSec decision list's URL; - the CrowdSec decision list's URL;
@@ -171,6 +178,12 @@ Every one of these lines has:
- `abuseipdb`. - `abuseipdb`.
- `tags`: the request's tags, sorted, left out when it has none. Each tag and - `tags`: the request's tags, sorted, left out when it has none. Each tag and
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md). where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
- `routewatch_tags_missing`: why the request got no tags from routewatch,
present only while `SWW_WAF_ROUTEWATCH_URL` is set:
- `no_answer_yet`: routewatch has not answered about this client yet;
- `request_failed`: a query to routewatch failed within the last minute, and
the process log says why;
- `no_route`: routewatch knows no live route for the address.
- `offence`: `limit`, for a request that broke a limit. - `offence`: `limit`, for a request that broke a limit.
- `ban_expires`: when the client's ban ends, for a request that made one or was - `ban_expires`: when the client's ban ends, for a request that made one or was
refused under one: a time, or `permanent`. refused under one: a time, or `permanent`.
@@ -179,14 +192,14 @@ Every one of these lines has:
All in milliseconds: All in milliseconds:
- `duration_total`: - `duration_total_ms`:
- on the `decision` line, from `discriminator` having read the question's - on the `decision` line, from `discriminator` having read the question's
body to the line; body to the line;
- on the `report` line, from `rproxy` having read the request's headers to - on the `report` line, from `rproxy` having read the request's headers to
the end of the answer. the end of the answer.
- `duration_checks`: to the end of the checks; - `duration_checks_ms`: to the end of the checks;
- `duration_waf`: the part of that the Core Rule Set took; - `duration_core_rule_set_ms`: the part of that the Core Rule Set took;
- `duration_upstream_total`: on the `report` line of an allowed request, from - `duration_upstream_total_ms`: on the `report` line of an allowed request, from
the hand-over to the app to the end of the answer. the hand-over to the app to the end of the answer.
### Requests to its own endpoints ### Requests to its own endpoints
@@ -199,8 +212,8 @@ these meanings where they differ:
- `scheme`: `http`. - `scheme`: `http`.
- `time`: when the request's headers had been read. - `time`: when the request's headers had been read.
- `request_bytes`: the bytes read of a ban to add, and otherwise `0`. - `request_bytes`: the bytes read of a ban to add, and otherwise `0`.
- `action`: `admin`. - `action`: `own_endpoint`.
- `duration_total`: from the request's headers being read to the line. - `duration_total_ms`: from the request's headers being read to the line.
## `rproxy`'s line ## `rproxy`'s line
@@ -218,22 +231,22 @@ these meanings where they differ:
a WebSocket carried from the visitor. a WebSocket carried from the visitor.
- `response_bytes`: the bytes of the answer's body, with what a WebSocket - `response_bytes`: the bytes of the answer's body, with what a WebSocket
carried from the app. carried from the app.
- `duration_request`: milliseconds from the request's headers being read until - `duration_request_ms`: milliseconds from the request's headers being read
the app had been sent the whole request, or the answer started if that was until the app had been sent the whole request, or the answer started if that
sooner. It includes the wait for `discriminator`. was sooner. It includes the wait for `discriminator`.
- `duration_response`: milliseconds from then until the end of the answer. - `duration_response_ms`: milliseconds from then until the end of the answer.
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It - `disposition`: what was done with the request: `allow`, `deny` or `close`. It
is `discriminator`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is is `discriminator`'s answer, or the value of `SWW_RPROXY_DEFAULT_DISPOSITION`.
left out when: It is left out when:
- `discriminator` was not asked, as for the health check; - `discriminator` was not asked, as for the health check;
- the visitor went away while `discriminator` was being asked; - the visitor went away while `discriminator` was being asked;
- `discriminator`'s answer had no valid `X-Sww-Disposition`; - `discriminator`'s answer had no valid `X-Sww-Disposition`;
- `rproxy` could not pass on `discriminator`'s answer, and answered `502`. - `rproxy` could not pass on `discriminator`'s answer, and answered `502`.
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave - `default_disposition`: `true` when `SWW_RPROXY_DEFAULT_DISPOSITION` gave
`disposition`, and otherwise left out. `disposition`, and otherwise left out.
For example: For example:
``` ```
{"type":"request","time":"2026-10-03T12:00:00.123Z","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","client_ip":"203.0.113.9","host":"app.example","method":"GET","path":"/","query":"","status":200,"request_bytes":0,"response_bytes":5120,"duration_request":1.204,"duration_response":3.104,"disposition":"allow"} {"type":"request","time":"2026-10-03T12:00:00.123Z","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","client_ip":"203.0.113.9","host":"app.example","method":"GET","path":"/","query":"","status":200,"request_bytes":0,"response_bytes":5120,"duration_request_ms":1.204,"duration_response_ms":3.104,"disposition":"allow"}
``` ```
+265 -253
View File
@@ -4,9 +4,9 @@ Every setting, its default and the values it takes, in the groups of "Settings"
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
that take `off`, the `_FILE` form, and what each group does and why. that take `off`, the `_FILE` form, and what each group does and why.
The settings that start with `SWW_` are `discriminator`'s. Those that start with The settings that start with `SWW_WAF_` are `discriminator`'s. Those that start
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses with `SWW_RPROXY_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND`
between the two (see "The image's command" below). chooses between the two (see "The image's command" below).
## Forms of values ## Forms of values
@@ -28,330 +28,341 @@ between the two (see "The image's command" below).
## Core ## Core
- `SWW_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and a - `SWW_WAF_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:`
port, such as `:9000`. Leave the host out (see "Ports" in and a port, such as `:9000`. Leave the host out (see "Ports" in
[`SPEC.md`](../SPEC.md)). [`SPEC.md`](../SPEC.md)).
- `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name - `SWW_WAF_INSTANCE_NAME` (default: the container's host name): the `instance`
in each line `discriminator` logs, and in each alert and metric, such as name in each line `discriminator` logs, and in each alert and metric, such as
`fsn1/discriminator`. Valid UTF-8. No control character while `fsn1/discriminator`. Valid UTF-8. No control character while
`SWW_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a `SWW_WAF_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
space while `SWW_LOG_REMOTE_URL` is set and `SWW_LOG_REMOTE_APP_NAME` is not. space while `SWW_WAF_LOG_REMOTE_URL` is set and `SWW_WAF_LOG_REMOTE_APP_NAME`
- `SWW_MODE` (default `enforce`): `enforce` or `observe`. is not.
- `SWW_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 client's - `SWW_WAF_MODE` (default `enforce`): `enforce` or `observe`.
group, from 32 to 128. - `SWW_WAF_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6
- `SWW_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole client's group, from 32 to 128.
number above zero. - `SWW_WAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a
- `SWW_MAX_BANS` (default `5000`): how many of its own bans `discriminator` whole number above zero.
- `SWW_WAF_MAX_BANS` (default `5000`): how many of its own bans `discriminator`
keeps, a whole number above zero. keeps, a whole number above zero.
## Tokens ## Tokens
- `SWW_ADMIN_TOKEN` (default unset): the token for the ban endpoints and - `SWW_WAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
`/_sww/clients/<ip>`. At least 32 characters. `/_sww/clients/<ip>`. At least 32 characters.
- `SWW_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least - `SWW_WAF_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At
32 characters. least 32 characters.
## State files ## State files
- `SWW_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, an - `SWW_WAF_STATE_DIR` (default `/var/lib/sww`): the directory of the state
absolute path that exists and can be written. files, an absolute path that exists and can be written.
- `SWW_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be - `SWW_WAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
written after a ban is made, lifted or made permanent. A duration. written after a ban is made, lifted or made permanent. A duration.
- `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is - `SWW_WAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file
written. A duration. is written. A duration.
## Logging ## Logging
- `SWW_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own - `SWW_WAF_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s
messages that are written: `debug`, `info`, `warn` or `error`. own messages that are written: `debug`, `info`, `warn` or `error`.
- `SWW_LOG_REQUEST_HEADERS` (default - `SWW_WAF_LOG_REQUEST_HEADERS` (default
`accept,accept-language,accept-encoding,content-type,origin,range`): the `accept,accept-language,accept-encoding,content-type,origin,range`): the
request headers recorded in the log line, in either case. `Host` and request headers recorded in the log line, in either case. `Host` and
`Transfer-Encoding` are refused; the host is the field `host`. `Transfer-Encoding` are refused; the host is the field `host`.
- `SWW_LOG_REMOTE_URL` (default unset): a syslog server that also gets every - `SWW_WAF_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`, line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
each with a host and a port and nothing more. each with a host and a port and nothing more.
- `SWW_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates that - `SWW_WAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates
a `syslog+tls` server's certificate must chain to, instead of the host's own. that a `syslog+tls` server's certificate must chain to, instead of the host's
A file that cannot be read, or holds no certificate, is refused. own. A file that cannot be read, or holds no certificate, is refused.
- `SWW_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they wait - `SWW_WAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
to be sent, a whole number above zero. wait to be sent, a whole number above zero.
- `SWW_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`, - `SWW_WAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`, `user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
`authpriv`, `ftp`, or `local0` to `local7`. `authpriv`, `ftp`, or `local0` to `local7`.
- `SWW_LOG_REMOTE_APP_NAME` (default: `SWW_INSTANCE_NAME`): the app name in each - `SWW_WAF_LOG_REMOTE_APP_NAME` (default: `SWW_WAF_INSTANCE_NAME`): the app name
syslog record, 1 to 48 printable ASCII characters without a space. in each syslog record, 1 to 48 printable ASCII characters without a space.
## Metrics ## Metrics
- `SWW_METRICS_TOP_N` (default `50`): how many AS numbers and how many countries - `SWW_WAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
get series of their own. A whole number above zero. countries get series of their own. A whole number above zero.
## Static lists ## Static lists
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring - `SWW_WAF_ALLOW_NETS` (default empty): netblocks you trust, such as your
(see step 4 in [`SPEC.md`](../SPEC.md)). monitoring (see step 4 in [`SPEC.md`](../SPEC.md)).
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are - `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
exempt from rate limiting and the byte limits. exempt from rate limiting and the byte limits.
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step - `SWW_WAF_DENY_NETS` (default empty): netblocks whose clients are refused (see
4 in [`SPEC.md`](../SPEC.md)). step 4 in [`SPEC.md`](../SPEC.md)).
## Request rate limits, per client ## Request rate limits, per client
- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a The request past one of these limits is refused, and the client is banned.
client may make in a minute. A count or `off`. The default leaves room for a
- `SWW_WAF_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. person opening two pages at once with an empty cache, up to 60 files each.
- `SWW_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may - `SWW_WAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
make in an hour. A count or `off`. It is the main limit against scrapers. A make in an hour. A count or `off`. It is the main limit against scrapers. A
person reading a site for an hour, a page every 20 seconds, makes about 900. A person reading a site for an hour, a page every 20 seconds, makes about 900. A
scraper making one request every 2 seconds makes 1,800 an hour, more than the scraper making one request every 2 seconds makes 1,800 an hour, more than the
limit. limit.
- `SWW_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may make - `SWW_WAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may
in a day. A count or `off`. The default is four hours at the hour limit. A make in a day. A count or `off`. The default is four hours at the hour limit.
scraper making one request every 10 seconds makes 8,640 a day, more than the A scraper making one request every 10 seconds makes 8,640 a day, more than the
limit. limit.
- A CI runner asks its code forge for work all day and can pass the limits. Put - A CI runner asks its code forge for work all day and can pass the limits. Put
the runners' addresses in `SWW_RATE_LIMIT_EXEMPT_NETS`. Do the same for a the runners' addresses in `SWW_WAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
network where many people share one address, such as an office. network where many people share one address, such as an office.
- Counting cannot catch a client that stays under all three limits (see "Request - Counting cannot catch a client that stays under all three limits (see "Request
rate limits, per client" in [`SPEC.md`](../SPEC.md)). rate limits, per client" in [`SPEC.md`](../SPEC.md)).
- `SWW_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from rate - `SWW_WAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
limiting and the byte limits, such as `/assets/`. rate limiting and the byte limits, such as `/assets/`.
## Byte limits, per client ## Byte limits, per client
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR` These count body bytes, never headers. What the visitor sent over a WebSocket
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes counts as request bytes, and what the app sent as response bytes. The bytes
counted for a client in each window. A size or `off`. counted for a request and its response are added to the client's total once the
- `SWW_BYTES_COUNT` (default `both`): which body bytes the byte limits count: response has ended. The request that takes a client's total past a limit is not
`response`, `request` or `both`. cut off; the client is banned.
- `SWW_WAF_BYTES_LIMIT_PER_MINUTE` (default `off`),
`SWW_WAF_BYTES_LIMIT_PER_HOUR` (default `50G`) and
`SWW_WAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes counted for a
client in each window, a size or `off`. `SWW_WAF_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_WAF_BYTES_COUNT` (default `both`): what the byte limits and the byte
anomaly thresholds count: `response`, the app's response bodies; `request`,
the visitors' request bodies; or `both`. What the visitor sent over a
WebSocket counts as request bytes, and what the app sent as response bytes.
## Lookup of AS number and country ## Lookup of AS number and country
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops - `SWW_WAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off`
the start while a setting that needs a lookup is set: stops the start while a setting that needs a lookup is set:
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`; - `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES`;
- `SWW_ADD_LOOKUP_HEADERS`; - `SWW_WAF_ADD_LOOKUP_HEADERS`;
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming an `asn=` or `country=` - `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming a `country=` tag,
tag; or an `asn=` tag while `SWW_WAF_ROUTEWATCH_URL` is unset;
- the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`; - the two byte percentage lists and `SWW_WAF_ASN_LIMIT_PERCENT_URL`;
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`; - `SWW_WAF_UNKNOWN_LIMIT_PERCENT` below `100`;
- an anomaly threshold per AS number. - an anomaly threshold per AS number.
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its - `SWW_WAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in
`.mmdb` form, for `SWW_LOOKUP_SOURCE=file`. its `.mmdb` form, for `SWW_WAF_LOOKUP_SOURCE=file`.
- `SWW_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its - `SWW_WAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
client's first answer from GeoJS, and how long a request to GeoJS may take. A client's first answer from GeoJS, and how long a request to GeoJS may take. A
duration; never `off`. duration; never `off`.
- `SWW_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app - `SWW_WAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the
the client's AS number in `X-Client-ASN` and its country in app the client's AS number in `X-Client-ASN` and its country in
`X-Client-Country`. `X-Client-Country`.
## Country list ## Country list
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only - `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
countries whose clients get through, such as `us,de`. A country whose countries whose clients get through, such as `us,de`. A country whose
`country=` tag is in `SWW_TAG_DENY` stops the start. `country=` tag is in `SWW_WAF_TAG_DENY` stops the start.
## Request tags ## Request tags
Every request gets tags from what `discriminator` knows of its client. See Every request gets tags from what `discriminator` knows of its client. See
"Request tags" in [`SPEC.md`](../SPEC.md). "Request tags" in [`SPEC.md`](../SPEC.md).
- `SWW_TAG_LISTS` (default empty): address lists that each add a tag, as - `SWW_WAF_TAG_LISTS` (default empty): address lists that each add a tag, as
`name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the
tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or
`https` URL fetched like a blocklist. A name or URL listed twice is refused. `https` URL fetched like a blocklist. A name or URL listed twice is refused.
So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`, So is a URL that is also in `SWW_WAF_BLOCKLIST_URLS`,
or the CrowdSec decision list. `SWW_WAF_ASN_LIMIT_PERCENT_URL`, or the CrowdSec decision list.
- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as - `SWW_WAF_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. `country=` tag without a country, is refused.
- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as - `SWW_WAF_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's
tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from limits, as tag and percentage pairs, such as `vpn:50,asn=14061:25`. A
`1` to `99`, of every rate limit and byte limit. The lowest percentage among a percentage is from `1` to `99`, of every rate limit and byte limit. The lowest
client's tags applies. A tag listed twice is refused. So is percentage among a client's tags applies. A tag listed twice is refused. So is
`exploitsignature`: a request gets that tag after the rate limits have counted `exploitsignature`: a request gets that tag after the rate limits have counted
it. it.
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag - `SWW_WAF_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a
that no request can get stops the start. A request can get only these: routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a
service that gives an address's announced netblock and origin AS.
`discriminator` asks it about each client, in the background, and adds the
tags `asn=<number>` and `prefix=<netblock>` from its answer. While it is
unset, nothing asks routewatch and no request gets a `prefix=` tag.
- a name in `SWW_TAG_LISTS`; `SWW_WAF_TAG_DENY` and `SWW_WAF_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_WAF_TAG_LISTS`;
- an `asn=` or `country=` tag; - an `asn=` or `country=` tag;
- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key - a `prefix=` tag with a netblock, while `SWW_WAF_ROUTEWATCH_URL` is set. It
matches only that exact announced netblock, such as `prefix=192.0.2.0/24`;
- a `dnsbl=` tag of a zone in `SWW_WAF_DNSBL_ZONES`, in lower case, with a key
masked as the `reputation` field shows it; masked as the `reputation` field shows it;
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set; - `abuseipdb`, while `SWW_WAF_ABUSEIPDB_KEY` is set;
- `exploitsignature`, in `SWW_TAG_DENY` only, while `SWW_RULES_ENABLED` is - `exploitsignature`, in `SWW_WAF_TAG_DENY` only, while `SWW_WAF_RULES_ENABLED`
`true`, or while neither `SWW_WAF_MODE` nor `SWW_WAF_ANOMALY_THRESHOLD` is is `true`, or while neither `SWW_WAF_CORE_RULE_SET_MODE` nor
`off`. `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` is `off`.
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses `crowdsec` stops the start in both settings, because a CrowdSec decision refuses
the request first. the request first.
## Biased thresholds ## Biased thresholds
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a - `SWW_WAF_ASN_BYTES_PERCENT` and `SWW_WAF_COUNTRY_BYTES_PERCENT` (default
percentage of the byte limits alone, for the AS numbers or countries listed, empty): a percentage of the byte limits alone, for the AS numbers or countries
such as `AS14061:50` or `cn:25`. It takes the place of the percentage listed, such as `AS14061:50` or `cn:25`. It takes the place of the percentage
`SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or `SWW_WAF_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number
country listed twice is refused. or country listed twice is refused.
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the - `SWW_WAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client
lookup cannot place. the lookup cannot place.
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a - `SWW_WAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of
text file with one AS number and percentage on each line, such as 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`. `AS64496:50`. Each entry counts like an `asn=` tag in
A URL that is also in `SWW_BLOCKLIST_URLS` is refused. `SWW_WAF_TAG_LIMIT_PERCENT`. A URL that is also in `SWW_WAF_BLOCKLIST_URLS` is
refused.
## Attack detection ## Attack detection
- `SWW_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule files. - `SWW_WAF_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule
- `SWW_RULES_ENABLED` (default `true`): `false` reads no rule file. files.
- `SWW_TRAP_PATHS` (default empty): paths only scanners ask for, such as - `SWW_WAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
- `SWW_WAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`. `/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
- `SWW_WAF_MODE` (default `block`): `off`, `detect` or `block`. - `SWW_WAF_CORE_RULE_SET_MODE` (default `block`): `off`, `detect` or `block`.
- `SWW_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level, - `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` (default `1`): the Core Rule Set's
from 1 to 4. paranoia level, from 1 to 4.
- `SWW_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a - `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` (default `5`): the score at which a
match. A whole number above zero, or `off`. request is a match. A whole number above zero, or `off`.
- `SWW_WAF_DISABLED_RULES` (default - `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` (default
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules `920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
to switch off. Each a whole number above zero. An id from 900000 to 900999 is to switch off. Each a whole number above zero. An id from 900000 to 900999 is
refused: those are the rules that set the Core Rule Set up, and refused: those are the rules that set the Core Rule Set up, and
`smallwebwaf`'s own. `smallwebwaf`'s own.
- `SWW_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does - `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` (default empty): path prefixes the Core
not inspect. Rule Set does not inspect.
- `SWW_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body the - `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` (default `off`): `off`, or how much of a
Core Rule Set reads, a size of at most `64K`. A question carries no more of a request body the Core Rule Set reads, a size of at most `64K`. A question
body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one byte of carries no more of a body than 64 KiB, and the Core Rule Set reads at most 64
it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)). KiB less one byte of it, whatever the value (see "The answer" in
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
exclusions, written as Coraza directives. It is loaded before the Core Rule
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
read or does not parse stops the start, whatever `SWW_WAF_MODE` is. So does a
file that names a number from 900000 to 900999 outside a comment, alone or in
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_APPS` (default unset): the apps each hostname serves. A request gets the
fixes of its hostname's apps only (see "Attack detection" in
[`SPEC.md`](../SPEC.md)). [`SPEC.md`](../SPEC.md)).
- Entries are separated by semicolons. Each is a hostname, a colon, then one - `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule
or more apps separated by commas, for example Set exclusions, written as Coraza directives. It is loaded before the Core
`git.example.com:gitea;code.example.com:gitea`. Rule Set's rules, so it holds only `SecAction` and `SecRule` lines that
- `gitea` is the only app supported. exclude with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that
- The request's `Host` is matched by exact name, without its port and cannot be read or does not parse stops the start, whatever
without regard to case. There are no wildcards. `SWW_WAF_CORE_RULE_SET_MODE` is. So does a file that names a number from
- A hostname not listed gets no app's fixes, so every rule checks it in 900000 to 900999 outside a comment, alone or in a range. So does a directive
full. Set empty, it lists no hostname. that removes or changes rules as the file loads, such as `SecRuleRemoveById`,
- Unset, every app's fixes apply to every hostname. since none of the Core Rule Set's rules is loaded yet. It has no `_FILE` form.
- Any of these mistakes stops the start: - `SWW_WAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may
- an unknown app; get in a minute from a rule file, a trap path or the Core Rule Set, a count or
- a hostname that is not a DNS name, such as `*.example.com`; `off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
- a hostname listed twice;
- an app listed twice for one hostname;
- an entry without a colon.
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client in
a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
## Ban lengths, response and scope ## Ban lengths, response and scope
- `SWW_BAN_RESPONSE` (default `403`): `403`, `429` or `close`. - `SWW_WAF_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
- `SWW_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A - `SWW_WAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit.
duration. A duration.
- `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends - `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
within which breaking a limit again lengthens the next ban. A duration. within which breaking a limit again lengthens the next ban. A duration.
- `SWW_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; one - `SWW_WAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit;
that would be longer is permanent. A duration. one that would be longer is permanent. A duration.
- `SWW_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of - `SWW_WAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign
attack. A duration. of attack. A duration.
- `SWW_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client - `SWW_WAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
covers: the prefix length of its netblock, from 0 to 32. covers: the prefix length of its netblock, from 0 to 32.
## Reputation ## Reputation
- `SWW_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as - `SWW_WAF_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL `https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
without a user or a fragment. A URL listed twice is refused. without a user or a fragment. A URL listed twice is refused.
- `SWW_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its last - `SWW_WAF_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its
try before it is fetched again. A duration of `1h` or more. last try before it is fetched again. A duration of `1h` or more.
- `SWW_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`. - `SWW_WAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or
- `SWW_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as `log`.
- `SWW_WAF_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
`dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus `dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
is refused, whatever its case or key. is refused, whatever its case or key.
- `SWW_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through, - `SWW_WAF_DNSBL_RESOLVER` (default unset): the resolver to ask the zones
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional through, such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an
port, 53 when none is given. Unset, the host's resolver is used. optional port, 53 when none is given. Unset, the host's resolver is used.
- `SWW_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A - `SWW_WAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
control character in it is refused. control character in it is refused.
- `SWW_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score, - `SWW_WAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence
from 0 to 100, that is a hit. score, from 0 to 100, that is a hit.
- `SWW_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, a - `SWW_WAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a
whole number above zero. AbuseIPDB's free accounts may make 1,000. day, a whole number above zero. AbuseIPDB's free accounts may make 1,000.
- `SWW_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine - `SWW_WAF_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a engine you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL
user or a fragment; its decision list is at `v1/decisions` under it. Set without a user or a fragment; its decision list is at `v1/decisions` under it.
without the key, it is refused. So is a decision list URL that is also in Set without the key, it is refused. So is a decision list URL that is also in
`SWW_BLOCKLIST_URLS`, or that is `SWW_ASN_LIMIT_PERCENT_URL`. `SWW_WAF_BLOCKLIST_URLS`, or that is `SWW_WAF_ASN_LIMIT_PERCENT_URL`.
- `SWW_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for - `SWW_WAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
URL, it is refused. URL, it is refused.
- `SWW_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or - `SWW_WAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
`log`, for DNS blocklist and AbuseIPDB hits. `log`, for DNS blocklist and AbuseIPDB hits.
- `SWW_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or - `SWW_WAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
AbuseIPDB's score is used. A duration. AbuseIPDB's score is used. A duration.
- `SWW_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a check - `SWW_WAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a
with AbuseIPDB may take. A duration. check with AbuseIPDB may take. A duration.
## Alerting ## Alerting
- `SWW_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each alert - `SWW_WAF_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each
as a JSON object. An `http` or `https` URL without a user or a fragment. alert as a JSON object. An `http` or `https` URL without a user or a fragment.
- `SWW_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, such - `SWW_WAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post,
as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and value. A such as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and
value cannot hold a comma. value. A value cannot hold a comma.
- `SWW_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook that - `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook
receives each alert as a message, checked as the webhook URL is. that receives each alert as a message, checked as the webhook URL is.
- `SWW_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each alert, - `SWW_WAF_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each
as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the alert, as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked
webhook URL is. as the webhook URL is.
- `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control - `SWW_WAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
character in it is refused. character in it is refused.
- `SWW_ALERT_EVENTS` (default - `SWW_WAF_ALERT_EVENTS` (default
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`): `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error`):
the event types alerts are sent for, from those in [`alerts.md`](alerts.md) the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
but `summary`. Any other name is refused. but `summary`. Any other name is refused.
- `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its - `SWW_WAF_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
repeats are held back. A duration or `off`. repeats are held back. A duration or `off`.
- `SWW_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A - `SWW_WAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour.
whole number above zero, or `off`. A whole number above zero, or `off`.
## Anomaly thresholds ## Anomaly thresholds
Each scope has four thresholds: the most requests and the most bytes counted in Each scope has four thresholds: the most requests and the most body bytes
a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by counted in a minute and in an hour. The bytes are those `SWW_WAF_BYTES_COUNT`
default. The four thresholds per client are: names. Requests are counts, bytes are sizes. Each is `off` by default. The four
thresholds per client are:
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`; - `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`; - `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
- `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`; - `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
- `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR`. - `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_HOUR`.
The other scopes' names have another prefix before the same four endings, such The other scopes' names have another prefix before the same four endings, such
as `REQUESTS_PER_MINUTE`: as `REQUESTS_PER_MINUTE`:
- `SWW_ANOMALY_NET_...`: per netblock around a client. Two settings give the - `SWW_WAF_ANOMALY_NET_...`: per netblock around a client. Two settings give the
netblock's length: netblock's length:
- `SWW_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4 - `SWW_WAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
client; client;
- `SWW_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6 - `SWW_WAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
client. client.
- `SWW_ANOMALY_ASN_...`: per AS number. - `SWW_WAF_ANOMALY_ASN_...`: per AS number.
- `SWW_ANOMALY_TOTAL_...`: the whole service. - `SWW_WAF_ANOMALY_TOTAL_...`: the whole service.
- `SWW_WATCH_...`: each netblock in `SWW_WATCH_NETS`, as a whole. - `SWW_WAF_WATCH_...`: each netblock in `SWW_WAF_WATCH_NETS`, as a whole.
`SWW_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such as `SWW_WAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
name, or with a bad netblock, is refused. So is a name used twice. name, or with a bad netblock, is refused. So is a name used twice.
## Settings given as files ## Settings given as files
@@ -372,7 +383,7 @@ Then mount its directory into the container read-only, and name the file:
docker run \ docker run \
-v /srv/app/tokens:/etc/sww/tokens:ro \ -v /srv/app/tokens:/etc/sww/tokens:ro \
-e SWW_COMMAND=discriminator \ -e SWW_COMMAND=discriminator \
-e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \ -e SWW_WAF_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
... ...
``` ```
@@ -396,51 +407,52 @@ A few limits are fixed rather than settings:
## `discriminator`'s answer and `rproxy`'s wait ## `discriminator`'s answer and `rproxy`'s wait
`SWW_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. Keep `SWW_WAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source.
it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits Keep it well under `rproxy`'s `SWW_RPROXY_TIMEOUT_MS`, as the default is. No
for `SWW_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked question waits for `SWW_WAF_REPUTATION_TIMEOUT`, since the DNS blocklists and
in the background (see "Answering in time" under "`discriminator`" in AbuseIPDB are asked in the background (see "Answering in time" under
[`SPEC.md`](../SPEC.md)). "`discriminator`" in [`SPEC.md`](../SPEC.md)).
## `rproxy` ## `rproxy`
`sww rproxy` reads the settings below and no others. They are read once at `sww rproxy` reads the settings below and no others. They are read once at
start, and an invalid value stops the start. None can be given as a file, since start, and an invalid value stops the start. Any can be given as a file, as a
`rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what `SWW_WAF_` setting can. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what they do.
they do.
A time here is a whole number of milliseconds above zero, such as `2000`. A time here is a whole number of milliseconds above zero, such as `2000`.
- `SWRP_WAF_BACKEND` (required, no default): `discriminator`'s URL. An `http` or - `SWW_RPROXY_WAF_BACKEND` (required, no default): `discriminator`'s URL. An
`https` URL with a host and an optional port, and nothing more. `rproxy` `http` or `https` URL with a host and an optional port, and nothing more.
speaks HTTP/1.1 to it. `rproxy` speaks HTTP/1.1 to it.
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for - `SWW_RPROXY_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for
`discriminator`'s answer, to a question and to a report. A time; never `off`. `discriminator`'s answer, to a question and to a report. A time; never `off`.
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when - `SWW_RPROXY_DEFAULT_DISPOSITION` (default `allow`): what is done with a
`discriminator` cannot be connected to, does not answer at all, or does not request when `discriminator` cannot be connected to, does not answer at all,
answer within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` or does not answer within `SWW_RPROXY_TIMEOUT_MS`. `allow` passes it to the
answers `503`. It does not apply to an answer `discriminator` gives, such as app, and `deny` answers `503`. It does not apply to an answer `discriminator`
an error. gives, such as an error.
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or - `SWW_RPROXY_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A
`off`. size or `off`.
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or - `SWW_RPROXY_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A
`off`. size or `off`.
- `SWRP_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may take to - `SWW_RPROXY_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may
send its request line and headers. It then also bounds, from the end of the take to send its request line and headers. It then also bounds, from the end
headers, the time until the app has been sent the whole request. A time or of the headers, the time until the app has been sent the whole request. A time
`off`. or `off`.
- `SWRP_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may take, - `SWW_RPROXY_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may
from the end of the request to its last byte reaching the visitor. A time or take, from the end of the request to its last byte reaching the visitor. A
`off`. time or `off`.
- `SWRP_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as `:9000`. - `SWW_RPROXY_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as
- `SWRP_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or `:9000`.
`https` URL with a host and an optional port, and nothing more. - `SWW_RPROXY_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An
- `SWRP_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`): `http` or `https` URL with a host and an optional port, and nothing more.
the netblocks whose `X-Forwarded-For` is believed. Set but empty, it trusts - `SWW_RPROXY_TRUSTED_PROXIES` (default
nothing. A client outside these netblocks cannot claim another address. Where `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`): the netblocks whose
other containers reach `rproxy` directly, or visitors on a private network `X-Forwarded-For` is believed. Set but empty, it trusts nothing. A client
reach traefik, they can name another address in the header. Narrowing the outside these netblocks cannot claim another address. Where other containers
setting to traefik's own network closes that gap. reach `rproxy` directly, or visitors on a private network reach traefik, they
can name another address in the header. Narrowing the setting to traefik's own
network closes that gap.
## The image's command ## The image's command
@@ -452,14 +464,14 @@ check checks: `rproxy` or `discriminator` (see "Deployment" in
Nothing reads these names any more: Nothing reads these names any more:
- `SWW_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`. - `SWW_WAF_UPSTREAM_URL` is `SWW_RPROXY_UPSTREAM_URL`.
- `SWW_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`. - `SWW_WAF_TRUSTED_PROXIES` is `SWW_RPROXY_TRUSTED_PROXIES`.
- `SWW_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`. - `SWW_WAF_REQUEST_MAX_BYTES` is `SWW_RPROXY_MAX_REQUEST_SIZE`.
- `SWW_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`. - `SWW_WAF_RESPONSE_MAX_BYTES` is `SWW_RPROXY_MAX_RESPONSE_SIZE`.
- `SWW_CLIENT_REQUEST_TIMEOUT` and `SWW_UPSTREAM_REQUEST_TIMEOUT` are - `SWW_WAF_CLIENT_REQUEST_TIMEOUT` and `SWW_WAF_UPSTREAM_REQUEST_TIMEOUT` are
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds. `SWW_RPROXY_MAX_REQUEST_TIME_MS`, in milliseconds.
- `SWW_CLIENT_RESPONSE_TIMEOUT` and `SWW_UPSTREAM_RESPONSE_TIMEOUT` are - `SWW_WAF_CLIENT_RESPONSE_TIMEOUT` and `SWW_WAF_UPSTREAM_RESPONSE_TIMEOUT` are
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds. `SWW_RPROXY_MAX_RESPONSE_TIME_MS`, in milliseconds.
- `SWW_CLIENT_IDLE_TIMEOUT` and `SWW_CLIENT_REQUEST_HEADER_MAX_BYTES` have no - `SWW_WAF_CLIENT_IDLE_TIMEOUT` and `SWW_WAF_CLIENT_REQUEST_HEADER_MAX_BYTES`
setting. `rproxy` closes a kept-open connection idle for 120 seconds, and have no setting. `rproxy` closes a kept-open connection idle for 120 seconds,
takes a request line and headers of at most 32 KiB. and takes a request line and headers of at most 32 KiB.
+12 -11
View File
@@ -1,13 +1,13 @@
# State files # State files
The five JSON files in `SWW_STATE_DIR`, and what each holds. "Persistent state" The five JSON files in `SWW_WAF_STATE_DIR`, and what each holds. "Persistent
in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has state" in [`SPEC.md`](../SPEC.md) says how they are written, read and edited.
a top-level `version` of 1. Each has a top-level `version` of 1.
## `bans.json` ## `bans.json`
Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those Active and past bans: every ban an admin made, and up to `SWW_WAF_MAX_BANS` of
`discriminator` made. Per entry: those `discriminator` made. Per entry:
- `netblock`, `start` and `expires` (`null` for a permanent ban); - `netblock`, `start` and `expires` (`null` for a permanent ban);
- `cause`: `attack`, `limit`, `admin` or `crowdsec`; - `cause`: `attack`, `limit`, `admin` or `crowdsec`;
@@ -61,10 +61,11 @@ since it was first seen:
- the first and last time seen; - the first and last time seen;
- the AS number, AS name and country last looked up, and when; - the AS number, AS name and country last looked up, and when;
- total requests, and how many were forwarded and how many refused; - total requests, and how many were forwarded and how many refused;
- bytes in each direction; - body bytes of its requests, and of the responses it was sent;
- responses by status class; - responses by status class;
- offences by kind: `limit`, `attack`, `rule_blocked` and `waf_blocked`. A file - offences by kind: `limit`, `attack`, `blocked_by_block_rule` and
may also hold `token_refused`, which is kept, and which nothing adds to. `blocked_by_core_rule_set`. A file may also hold `token_refused`, which is
kept, and which nothing adds to.
## `lookups.json` ## `lookups.json`
@@ -93,10 +94,10 @@ GeoJS answers, one per line:
## Sizes ## Sizes
- `clients.json`: about 1 KiB per client, about 20 MiB at the default - `clients.json`: about 1 KiB per client, about 20 MiB at the default
`SWW_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of `SWW_WAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB
disk writes a day. of disk writes a day.
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at - `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
the default `SWW_MAX_BANS`. Written every 15 minutes, a full file comes to the default `SWW_WAF_MAX_BANS`. Written every 15 minutes, a full file comes to
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
more. more.
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when - `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
+10
View File
@@ -10,12 +10,14 @@ require (
github.com/maxmind/mmdbwriter v1.2.0 github.com/maxmind/mmdbwriter v1.2.0
github.com/oschwald/maxminddb-golang/v2 v2.7.0 github.com/oschwald/maxminddb-golang/v2 v2.7.0
github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_golang v1.24.1
github.com/spf13/viper v1.21.0
) )
require ( require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/corazawaf/libinjection-go v0.3.3 // indirect github.com/corazawaf/libinjection-go v0.3.3 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect
@@ -25,14 +27,22 @@ require (
github.com/kylelemons/godebug v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/magefile/mage v1.17.0 // indirect github.com/magefile/mage v1.17.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/net v0.58.0 // indirect golang.org/x/net v0.58.0 // indirect
golang.org/x/sync v0.23.0 // indirect golang.org/x/sync v0.23.0 // indirect
+24
View File
@@ -12,8 +12,12 @@ github.com/corazawaf/libinjection-go v0.3.3 h1:NhbXKRfRpqKzBMzv8zpCcnjyEw7BCVhBO
github.com/corazawaf/libinjection-go v0.3.3/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk= github.com/corazawaf/libinjection-go v0.3.3/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0=
github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -34,6 +38,10 @@ github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPs
github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0= github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM= github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM=
@@ -58,8 +66,24 @@ github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+42 -40
View File
@@ -1,12 +1,12 @@
// Package alerts sends alerts on bans, on traffic over an anomaly // Package alerts sends alerts on bans, on traffic over an anomaly
// threshold, on a source that fails and on a file with an error to each // threshold, on a source that fails and on a file with an error to each
// destination set: to the webhook // destination set: to the webhook
// SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON // SWW_WAF_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
// object" in docs/alerts.md describes, to the Slack incoming // object" in docs/alerts.md describes, to the Slack incoming
// webhook SWW_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the // webhook SWW_WAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
// ntfy topic SWW_ALERT_NTFY_URL names. A repeat within // ntfy topic SWW_WAF_ALERT_NTFY_URL names. A repeat within
// SWW_ALERT_COOLDOWN is held back, and so is an alert past // SWW_WAF_ALERT_COOLDOWN is held back, and so is an alert past
// SWW_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a // SWW_WAF_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
// bounded queue of each destination's own, so that a destination that is // bounded queue of each destination's own, so that a destination that is
// slow or unreachable holds up neither the others nor any request. The // slow or unreachable holds up neither the others nor any request. The
// state is written to alerts.json and read from it by the state package. // state is written to alerts.json and read from it by the state package.
@@ -34,7 +34,7 @@ import (
"time" "time"
) )
// The events an alert is for, as SWW_ALERT_EVENTS names them. // The events an alert is for, as SWW_WAF_ALERT_EVENTS names them.
const ( const (
// EventBan is a ban sww made. // EventBan is a ban sww made.
EventBan = "ban" EventBan = "ban"
@@ -44,10 +44,10 @@ const (
// EventAnomaly is a count of requests or bytes over an anomaly // EventAnomaly is a count of requests or bytes over an anomaly
// threshold. // threshold.
EventAnomaly = "anomaly" EventAnomaly = "anomaly"
// EventWAFBlock is a request the Core Rule Set scored at or over // EventCoreRuleSetMatch is a request the Core Rule Set scored at or over
// SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, refused in block mode, let through in
// detect mode. // detect mode.
EventWAFBlock = "waf_block" EventCoreRuleSetMatch = "core_rule_set_match"
// EventReputationHit is a request whose client a blocklist, the // EventReputationHit is a request whose client a blocklist, the
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score // CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
// is a hit. // is a hit.
@@ -61,18 +61,18 @@ const (
// cannot be read, or a state file that cannot be written. // cannot be read, or a state file that cannot be written.
EventFileError = "file_error" EventFileError = "file_error"
// EventSummary is the summary sent as an hour ends: of the alerts held // EventSummary is the summary sent as an hour ends: of the alerts held
// back in it past SWW_ALERT_MAX_PER_HOUR, and of the repeats held // back in it past SWW_WAF_ALERT_MAX_PER_HOUR, and of the repeats held
// back by the cooldowns dropped as it ends, which no alert let through // back by the cooldowns dropped as it ends, which no alert let through
// has given. It is sent with SWW_ALERT_MAX_PER_HOUR off too, for // has given. It is sent with SWW_WAF_ALERT_MAX_PER_HOUR off too, for
// those repeats. SWW_ALERT_EVENTS does not name it. // those repeats. SWW_WAF_ALERT_EVENTS does not name it.
EventSummary = "summary" EventSummary = "summary"
) )
// Events returns every event SWW_ALERT_EVENTS can name, which is its // Events returns every event SWW_WAF_ALERT_EVENTS can name, which is its
// default. // default.
func Events() []string { func Events() []string {
return []string{ return []string{
EventBan, EventPermanentBan, EventWAFBlock, EventAnomaly, EventBan, EventPermanentBan, EventCoreRuleSetMatch, EventAnomaly,
EventReputationHit, EventSourceFailure, EventFileError, EventReputationHit, EventSourceFailure, EventFileError,
} }
} }
@@ -80,12 +80,12 @@ func Events() []string {
// The destinations alerts are sent to, as the metrics and alerts.json // The destinations alerts are sent to, as the metrics and alerts.json
// name them. // name them.
const ( const (
// DestinationWebhook is the webhook SWW_ALERT_WEBHOOK_URL names. // DestinationWebhook is the webhook SWW_WAF_ALERT_WEBHOOK_URL names.
DestinationWebhook = "webhook" DestinationWebhook = "webhook"
// DestinationSlack is the Slack incoming webhook // DestinationSlack is the Slack incoming webhook
// SWW_ALERT_SLACK_WEBHOOK_URL names. // SWW_WAF_ALERT_SLACK_WEBHOOK_URL names.
DestinationSlack = "slack" DestinationSlack = "slack"
// DestinationNtfy is the ntfy topic SWW_ALERT_NTFY_URL names. // DestinationNtfy is the ntfy topic SWW_WAF_ALERT_NTFY_URL names.
DestinationNtfy = "ntfy" DestinationNtfy = "ntfy"
) )
@@ -121,26 +121,26 @@ var (
// NtfyURL set, no alert is sent. // NtfyURL set, no alert is sent.
type Params struct { type Params struct {
// WebhookURL is where each alert is posted as JSON // WebhookURL is where each alert is posted as JSON
// (SWW_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders // (SWW_WAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
// are sent with each (SWW_ALERT_WEBHOOK_HEADERS). // are sent with each (SWW_WAF_ALERT_WEBHOOK_HEADERS).
WebhookURL *url.URL WebhookURL *url.URL
WebhookHeaders http.Header WebhookHeaders http.Header
// SlackURL is the Slack incoming webhook each alert is posted to as a // SlackURL is the Slack incoming webhook each alert is posted to as a
// message (SWW_ALERT_SLACK_WEBHOOK_URL), nil while it is unset. // message (SWW_WAF_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
SlackURL *url.URL SlackURL *url.URL
// NtfyURL is the ntfy topic each alert is published to // NtfyURL is the ntfy topic each alert is published to
// (SWW_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless // (SWW_WAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
// empty, is sent with each as a bearer token (SWW_ALERT_NTFY_TOKEN). // empty, is sent with each as a bearer token (SWW_WAF_ALERT_NTFY_TOKEN).
NtfyURL *url.URL NtfyURL *url.URL
NtfyToken string NtfyToken string
// Events are the events alerts are sent for (SWW_ALERT_EVENTS). // Events are the events alerts are sent for (SWW_WAF_ALERT_EVENTS).
Events []string Events []string
// Cooldown is how long a repeat of an alert is held back // Cooldown is how long a repeat of an alert is held back
// (SWW_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts // (SWW_WAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
// sent in an hour (SWW_ALERT_MAX_PER_HOUR), 0 for no limit. // sent in an hour (SWW_WAF_ALERT_MAX_PER_HOUR), 0 for no limit.
Cooldown time.Duration Cooldown time.Duration
MaxPerHour int MaxPerHour int
// Instance is SWW_INSTANCE_NAME, which every alert gives. // Instance is SWW_WAF_INSTANCE_NAME, which every alert gives.
Instance string Instance string
// Now tells the time of an alert, normally time.Now in UTC. // Now tells the time of an alert, normally time.Now in UTC.
Now func() time.Time Now func() time.Time
@@ -220,7 +220,7 @@ type State struct {
// requests to it failed, and how many alerts were dropped from its full // requests to it failed, and how many alerts were dropped from its full
// queue or given up as it refused them. // queue or given up as it refused them.
type Counts struct { type Counts struct {
Sent, Failed, Dropped int64 Sent, SendFailures, Dropped int64
} }
// Queue takes the alerts raised, holds back those it must, and sends the // Queue takes the alerts raised, holds back those it must, and sends the
@@ -264,7 +264,7 @@ type destination struct {
// waiting are the alerts waiting to be sent, oldest first. // waiting are the alerts waiting to be sent, oldest first.
waiting []*Alert waiting []*Alert
sent, failed, dropped atomic.Int64 sent, sendFailures, dropped atomic.Int64
} }
// cooldownKey is what makes an alert a repeat of another: the same event // cooldownKey is what makes an alert a repeat of another: the same event
@@ -301,17 +301,17 @@ func New(params Params) *Queue {
} }
if params.WebhookURL != nil { if params.WebhookURL != nil {
q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL", q.addDestination(DestinationWebhook, "SWW_WAF_ALERT_WEBHOOK_URL",
params.WebhookURL, q.webhookMessage) params.WebhookURL, q.webhookMessage)
} }
if params.SlackURL != nil { if params.SlackURL != nil {
q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL", q.addDestination(DestinationSlack, "SWW_WAF_ALERT_SLACK_WEBHOOK_URL",
params.SlackURL, slackMessage) params.SlackURL, slackMessage)
} }
if params.NtfyURL != nil { if params.NtfyURL != nil {
q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL", q.addDestination(DestinationNtfy, "SWW_WAF_ALERT_NTFY_URL",
params.NtfyURL, q.ntfyMessage) params.NtfyURL, q.ntfyMessage)
} }
@@ -319,7 +319,7 @@ func New(params Params) *Queue {
} }
// Raise sends alert, which names its event and what is particular to it, // Raise sends alert, which names its event and what is particular to it,
// unless no destination is set or SWW_ALERT_EVENTS leaves its event // unless no destination is set or SWW_WAF_ALERT_EVENTS leaves its event
// out. It gives alert the instance and the time. An alert that repeats // out. It gives alert the instance and the time. An alert that repeats
// the last one let through less than Cooldown before is held back and // the last one let through less than Cooldown before is held back and
// counted. The next one let through gives that count, unless an hour of // counted. The next one let through gives that count, unless an hour of
@@ -363,7 +363,7 @@ func (q *Queue) Raise(alert Alert) {
} }
// WouldSend reports whether Raise would let an alert for event on // WouldSend reports whether Raise would let an alert for event on
// netblock through now: a destination is set, SWW_ALERT_EVENTS chooses // netblock through now: a destination is set, SWW_WAF_ALERT_EVENTS chooses
// event, no alert for event on netblock was let through less than // event, no alert for event on netblock was let through less than
// Cooldown before, and fewer than MaxPerHour alerts have been let through // Cooldown before, and fewer than MaxPerHour alerts have been let through
// in the hour under way. Unlike Raise, it counts nothing. // in the hour under way. Unlike Raise, it counts nothing.
@@ -426,7 +426,9 @@ func (q *Queue) Counts(name string) Counts {
for _, d := range q.destinations { for _, d := range q.destinations {
if d.name == name { if d.name == name {
return Counts{ return Counts{
Sent: d.sent.Load(), Failed: d.failed.Load(), Dropped: d.dropped.Load(), Sent: d.sent.Load(),
SendFailures: d.sendFailures.Load(),
Dropped: d.dropped.Load(),
} }
} }
} }
@@ -606,13 +608,13 @@ func (q *Queue) endHour(now time.Time) {
if heldBack > 0 { if heldBack > 0 {
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+ reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
"past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack, "past the %d an hour SWW_WAF_ALERT_MAX_PER_HOUR allows", heldBack,
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour)) q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
} }
if repeats > 0 { if repeats > 0 {
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+ reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
"SWW_ALERT_COOLDOWN that no later alert gives", repeats)) "SWW_WAF_ALERT_COOLDOWN that no later alert gives", repeats))
} }
if len(reasons) > 0 { if len(reasons) > 0 {
@@ -675,7 +677,7 @@ func (d *destination) run(ctx context.Context) {
retryAt = time.Time{} retryAt = time.Time{}
case errors.Is(err, errRefused): case errors.Is(err, errRefused):
d.remove(alert) d.remove(alert)
d.failed.Add(1) d.sendFailures.Add(1)
d.dropped.Add(1) d.dropped.Add(1)
retryDelay = 0 retryDelay = 0
@@ -684,7 +686,7 @@ func (d *destination) run(ctx context.Context) {
d.processLog.Warn("gave up an alert "+d.setting+" refused", d.processLog.Warn("gave up an alert "+d.setting+" refused",
"event", alert.Event, "error", err.Error()) "event", alert.Event, "error", err.Error())
case ctx.Err() == nil: // not cut off as sww stops case ctx.Err() == nil: // not cut off as sww stops
d.failed.Add(1) d.sendFailures.Add(1)
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay), retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
maxRetryDelay) maxRetryDelay)
@@ -871,7 +873,7 @@ func ntfyPriority(event string) string {
return "high" return "high"
case EventReputationHit: case EventReputationHit:
return "low" return "low"
default: // ban, waf_block and summary default: // ban, core_rule_set_match and summary
return "default" return "default"
} }
} }
@@ -882,7 +884,7 @@ func ntfyTag(event string) string {
switch event { switch event {
case EventBan, EventPermanentBan: case EventBan, EventPermanentBan:
return "no_entry" return "no_entry"
case EventWAFBlock: case EventCoreRuleSetMatch:
return "shield" return "shield"
case EventAnomaly: case EventAnomaly:
return "chart_with_upwards_trend" return "chart_with_upwards_trend"
+17 -17
View File
@@ -132,7 +132,7 @@ func TestWouldSendOnlyForTheChosenEvents(t *testing.T) {
if q.WouldSend(alerts.EventBan, netblock(1)) || if q.WouldSend(alerts.EventBan, netblock(1)) ||
q.WouldSend(alerts.EventPermanentBan, netblock(1)) { q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
t.Error("a ban alert would be sent, though SWW_ALERT_EVENTS leaves it out") t.Error("a ban alert would be sent, though SWW_WAF_ALERT_EVENTS leaves it out")
} }
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) { if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
@@ -184,8 +184,8 @@ func TestWouldSendWithOnlySlackOrOnlyNtfySet(t *testing.T) {
onlyNtfy.NtfyURL = parseURL(ntfyURL) onlyNtfy.NtfyURL = parseURL(ntfyURL)
for setting, params := range map[string]alerts.Params{ for setting, params := range map[string]alerts.Params{
"SWW_ALERT_SLACK_WEBHOOK_URL": onlySlack, "SWW_WAF_ALERT_SLACK_WEBHOOK_URL": onlySlack,
"SWW_ALERT_NTFY_URL": onlyNtfy, "SWW_WAF_ALERT_NTFY_URL": onlyNtfy,
} { } {
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) { if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
t.Errorf("a ban alert would not be sent with only %s set", setting) t.Errorf("a ban alert would not be sent with only %s set", setting)
@@ -359,7 +359,7 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) {
"as_name": "", "as_name": "",
"country": "", "country": "",
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " + "reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
"past the 2 an hour SWW_ALERT_MAX_PER_HOUR allows", "past the 2 an hour SWW_WAF_ALERT_MAX_PER_HOUR allows",
"detail": map[string]any{ "detail": map[string]any{
"hour": started, "hour": started,
"count": float64(3), "count": float64(3),
@@ -525,7 +525,7 @@ func TestFailedRequestIsSentAgainWithBackoff(t *testing.T) {
wantCounts(t, q, 0, int64(len(want)), 0, 0) wantCounts(t, q, 0, int64(len(want)), 0, 0)
if !strings.Contains(log.String(), if !strings.Contains(log.String(),
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) { `"msg":"sending an alert to SWW_WAF_ALERT_WEBHOOK_URL failed"`) {
t.Errorf("process log %q names no failure", log.String()) t.Errorf("process log %q names no failure", log.String())
} }
@@ -595,7 +595,7 @@ func TestRefusedAlertIsGivenUpAndTheNextSent(t *testing.T) {
wantCounts(t, q, 1, 3, 0, 1) wantCounts(t, q, 1, 3, 0, 1)
if !strings.Contains(log.String(), if !strings.Contains(log.String(),
`"msg":"gave up an alert SWW_ALERT_WEBHOOK_URL refused"`) { `"msg":"gave up an alert SWW_WAF_ALERT_WEBHOOK_URL refused"`) {
t.Errorf("process log %q names no alert given up", log.String()) t.Errorf("process log %q names no alert given up", log.String())
} }
}) })
@@ -619,7 +619,7 @@ func TestFailedRequestIsLoggedWithoutTheURL(t *testing.T) {
logged := log.String() logged := log.String()
if !strings.Contains(logged, if !strings.Contains(logged,
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) || `"msg":"sending an alert to SWW_WAF_ALERT_WEBHOOK_URL failed"`) ||
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") { strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
t.Errorf("process log %q names no failure, or names the URL", logged) t.Errorf("process log %q names no failure, or names the URL", logged)
} }
@@ -782,8 +782,8 @@ func TestSlackAndNtfyAreSentTheSummaryAndTheRepeatsHeldBack(t *testing.T) {
} }
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " + const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
"past the 1 an hour SWW_ALERT_MAX_PER_HOUR allows; 1 repeats held back " + "past the 1 an hour SWW_WAF_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
"by SWW_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1" "by SWW_WAF_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary) wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary) wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
@@ -897,11 +897,11 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
synctest.Wait() synctest.Wait()
wantDestinationCounts(t, q, alerts.DestinationSlack, wantDestinationCounts(t, q, alerts.DestinationSlack,
alerts.Counts{Sent: 3, Failed: 1}) alerts.Counts{Sent: 3, SendFailures: 1})
logged := log.String() logged := log.String()
if !strings.Contains(logged, if !strings.Contains(logged,
`"msg":"sending an alert to SWW_ALERT_SLACK_WEBHOOK_URL failed"`) || `"msg":"sending an alert to SWW_WAF_ALERT_SLACK_WEBHOOK_URL failed"`) ||
strings.Contains(logged, "hooks.slack.example") || strings.Contains(logged, "hooks.slack.example") ||
strings.Contains(logged, "T0123") { strings.Contains(logged, "T0123") {
t.Errorf("process log %q names no failure, or names the URL", logged) t.Errorf("process log %q names no failure, or names the URL", logged)
@@ -934,7 +934,7 @@ func TestDropsAreCountedForEachDestination(t *testing.T) {
for destination, want := range map[string]alerts.Counts{ for destination, want := range map[string]alerts.Counts{
alerts.DestinationWebhook: {Sent: all}, alerts.DestinationWebhook: {Sent: all},
alerts.DestinationSlack: {Dropped: 1}, alerts.DestinationSlack: {Dropped: 1},
alerts.DestinationNtfy: {Failed: all, Dropped: all}, alerts.DestinationNtfy: {SendFailures: all, Dropped: all},
} { } {
wantDestinationCounts(t, q, destination, want) wantDestinationCounts(t, q, destination, want)
} }
@@ -1223,12 +1223,12 @@ func wantEvents(t *testing.T, webhook *standIn, want ...string) {
// requests to it that it counts as failed, the alerts it counts as held // requests to it that it counts as failed, the alerts it counts as held
// back, and those it counts as dropped for the webhook. // back, and those it counts as dropped for the webhook.
func wantCounts( func wantCounts(
t *testing.T, q *alerts.Queue, sent, failed, suppressed, dropped int64, t *testing.T, q *alerts.Queue, sent, sendFailures, suppressed, dropped int64,
) { ) {
t.Helper() t.Helper()
wantDestinationCounts(t, q, alerts.DestinationWebhook, wantDestinationCounts(t, q, alerts.DestinationWebhook,
alerts.Counts{Sent: sent, Failed: failed, Dropped: dropped}) alerts.Counts{Sent: sent, SendFailures: sendFailures, Dropped: dropped})
if q.Suppressed() != suppressed { if q.Suppressed() != suppressed {
t.Errorf("%d alerts held back, want %d", q.Suppressed(), suppressed) t.Errorf("%d alerts held back, want %d", q.Suppressed(), suppressed)
@@ -1254,7 +1254,7 @@ type eventMessage struct {
title, priorityAndTag, text string title, priorityAndTag, text string
} }
// anAlertForEachEvent returns an alert for each event SWW_ALERT_EVENTS // anAlertForEachEvent returns an alert for each event SWW_WAF_ALERT_EVENTS
// names, the first in observe mode, each with its message. // names, the first in observe mode, each with its message.
func anAlertForEachEvent() []eventMessage { func anAlertForEachEvent() []eventMessage {
return []eventMessage{ return []eventMessage{
@@ -1281,12 +1281,12 @@ func anAlertForEachEvent() []eventMessage {
}, },
{ {
alerts.Alert{ alerts.Alert{
Event: alerts.EventWAFBlock, Client: netip.MustParseAddr("192.0.2.1"), Event: alerts.EventCoreRuleSetMatch, Client: netip.MustParseAddr("192.0.2.1"),
Netblock: netip.MustParsePrefix("192.0.2.1/32"), Netblock: netip.MustParsePrefix("192.0.2.1/32"),
Reason: "refused by the Core Rule Set", Reason: "refused by the Core Rule Set",
Host: "app.example", Host: "app.example",
}, },
"fsn1app1/blog: waf_block", "default shield", "fsn1app1/blog: core_rule_set_match", "default shield",
"refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" + "refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" +
"netblock: 192.0.2.1/32", "netblock: 192.0.2.1/32",
}, },
+16 -16
View File
@@ -28,22 +28,22 @@ const maxCounters = 20000
// alerts name them. // alerts name them.
const ( const (
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of // ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
// SWW_IPV6_GROUP_PREFIX. // SWW_WAF_IPV6_GROUP_PREFIX.
ScopeClient = "client" ScopeClient = "client"
// ScopeNet is the netblock around a client, SWW_ANOMALY_NET_V4_PREFIX // ScopeNet is the netblock around a client, SWW_WAF_ANOMALY_NET_V4_PREFIX
// or SWW_ANOMALY_NET_V6_PREFIX long. // or SWW_WAF_ANOMALY_NET_V6_PREFIX long.
ScopeNet = "net" ScopeNet = "net"
// ScopeASN is an AS number. // ScopeASN is an AS number.
ScopeASN = "asn" ScopeASN = "asn"
// ScopeTotal is the whole service. // ScopeTotal is the whole service.
ScopeTotal = "total" ScopeTotal = "total"
// ScopeWatch is a named netblock of SWW_WATCH_NETS. // ScopeWatchedNet is a named netblock of SWW_WAF_WATCH_NETS.
ScopeWatch = "watch" ScopeWatchedNet = "watched_net"
) )
// Scopes returns every scope. // Scopes returns every scope.
func Scopes() []string { func Scopes() []string {
return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatch} return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatchedNet}
} }
// The windows a counter counts in, as the alerts name them. // The windows a counter counts in, as the alerts name them.
@@ -62,7 +62,7 @@ type Thresholds struct {
BytesPerHour int64 BytesPerHour int64
} }
// NamedNetblock is a netblock SWW_WATCH_NETS names. // NamedNetblock is a netblock SWW_WAF_WATCH_NETS names.
type NamedNetblock struct { type NamedNetblock struct {
Name string Name string
Netblock netip.Prefix Netblock netip.Prefix
@@ -70,14 +70,14 @@ type NamedNetblock struct {
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
// The thresholds of each scope: SWW_ANOMALY_CLIENT_*, // The thresholds of each scope: SWW_WAF_ANOMALY_CLIENT_*,
// SWW_ANOMALY_NET_*, SWW_ANOMALY_ASN_*, SWW_ANOMALY_TOTAL_* and // SWW_WAF_ANOMALY_NET_*, SWW_WAF_ANOMALY_ASN_*, SWW_WAF_ANOMALY_TOTAL_* and
// SWW_WATCH_*. // SWW_WAF_WATCH_*.
Client, Net, ASN, Total, Watch Thresholds Client, Net, ASN, Total, Watch Thresholds
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a // NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
// client (SWW_ANOMALY_NET_V4_PREFIX and SWW_ANOMALY_NET_V6_PREFIX). // client (SWW_WAF_ANOMALY_NET_V4_PREFIX and SWW_WAF_ANOMALY_NET_V6_PREFIX).
NetV4Prefix, NetV6Prefix int NetV4Prefix, NetV6Prefix int
// NamedNetblocks are SWW_WATCH_NETS. // NamedNetblocks are SWW_WAF_WATCH_NETS.
NamedNetblocks []NamedNetblock NamedNetblocks []NamedNetblock
// Alerts receive the anomaly alerts. // Alerts receive the anomaly alerts.
Alerts *alerts.Queue Alerts *alerts.Queue
@@ -107,13 +107,13 @@ type Request struct {
Host string Host string
// Client is the client's address, and ClientGroup the client it is // Client is the client's address, and ClientGroup the client it is
// counted as: its IPv4 address, or the IPv6 netblock of // counted as: its IPv4 address, or the IPv6 netblock of
// SWW_IPV6_GROUP_PREFIX its address is in. // SWW_WAF_IPV6_GROUP_PREFIX its address is in.
Client netip.Addr Client netip.Addr
ClientGroup netip.Prefix ClientGroup netip.Prefix
// ASN, ASName and Country are the client's as looked up, each "" when // ASN, ASName and Country are the client's as looked up, each "" when
// unknown. // unknown.
ASN, ASName, Country string ASN, ASName, Country string
// Bytes are the request's bytes, as SWW_BYTES_COUNT counts them. // Bytes are the request's bytes, as SWW_WAF_BYTES_COUNT counts them.
Bytes int64 Bytes int64
} }
@@ -257,7 +257,7 @@ func (c *Counters) scopesOf(r Request) []scope {
for _, named := range p.NamedNetblocks { for _, named := range p.NamedNetblocks {
if named.Netblock.Contains(client) { if named.Netblock.Contains(client) {
all = append(all, scope{ all = append(all, scope{
key{scope: ScopeWatch, netblock: named.Netblock, name: named.Name}, p.Watch, key{scope: ScopeWatchedNet, netblock: named.Netblock, name: named.Name}, p.Watch,
}) })
} }
} }
@@ -391,7 +391,7 @@ func alertFor(r Request, k key, o overThreshold) alerts.Alert {
detail["asn"] = k.asn detail["asn"] = k.asn
case ScopeTotal: case ScopeTotal:
counted = "the whole service" counted = "the whole service"
default: // watch default: // watched_net
counted = "the named netblock " + k.name + ", " + k.netblock.String() counted = "the named netblock " + k.name + ", " + k.netblock.String()
detail["name"] = k.name detail["name"] = k.name
} }
+59 -14
View File
@@ -13,7 +13,7 @@ func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
t.Parallel() t.Parallel()
netblock := netip.MustParsePrefix("203.0.113.0/24") netblock := netip.MustParsePrefix("203.0.113.0/24")
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{{Netblock: netblock, Start: midnight()}}) ledger.Load([]bans.Ban{{Netblock: netblock, Start: midnight()}})
if got := ledger.Bans(netblock)[0].Cause; got != bans.CauseAdmin { if got := ledger.Bans(netblock)[0].Cause; got != bans.CauseAdmin {
@@ -24,9 +24,9 @@ func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) { func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
t.Parallel() t.Parallel()
rules := defaultRules() params := defaultParams()
rules.MaxBans = 1 params.MaxBans = 1
ledger := bans.New(rules) ledger := bans.New(params)
adminsOnly := netip.MustParsePrefix("198.51.100.0/24") adminsOnly := netip.MustParsePrefix("198.51.100.0/24")
both := netip.MustParsePrefix("203.0.113.1/32") both := netip.MustParsePrefix("203.0.113.1/32")
second := netip.MustParsePrefix("203.0.113.2/32") second := netip.MustParsePrefix("203.0.113.2/32")
@@ -64,9 +64,9 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) { func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
t.Parallel() t.Parallel()
rules := defaultRules() params := defaultParams()
rules.MaxBans = 2 params.MaxBans = 2
ledger := bans.New(rules) ledger := bans.New(params)
netblock := netip.MustParsePrefix("203.0.113.1/32") netblock := netip.MustParsePrefix("203.0.113.1/32")
other := netip.MustParsePrefix("203.0.113.2/32") other := netip.MustParsePrefix("203.0.113.2/32")
@@ -93,7 +93,7 @@ func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) { func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.1/32") netblock := netip.MustParsePrefix("203.0.113.1/32")
// The netblock breaks a limit twice, the second time after its first // The netblock breaks a limit twice, the second time after its first
@@ -117,7 +117,7 @@ func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
func TestReasonOfTheBansSwwMakes(t *testing.T) { func TestReasonOfTheBansSwwMakes(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(), limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"}) bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"})
@@ -150,7 +150,7 @@ func TestLiftedBanForALimitRefusesNothingAndMakesNoBanLonger(t *testing.T) {
Lifted: midnight().Add(10 * time.Minute), Lifted: midnight().Add(10 * time.Minute),
} }
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{lifted}) ledger.Load([]bans.Ban{lifted})
// While it would still last, it refuses nothing, and a limit broken // While it would still last, it refuses nothing, and a limit broken
@@ -181,7 +181,7 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
// A permanent ban for a clear sign of attack, lifted. // A permanent ban for a clear sign of attack, lifted.
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{{ ledger.Load([]bans.Ban{{
Netblock: netblock, Netblock: netblock,
Start: midnight(), Start: midnight(),
@@ -211,7 +211,7 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) { func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(), made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
bans.Notes{}) bans.Notes{})
atStart := bans.Ban{ atStart := bans.Ban{
@@ -246,7 +246,7 @@ func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) {
t.Parallel() t.Parallel()
netblock := netip.MustParsePrefix("203.0.113.0/24") netblock := netip.MustParsePrefix("203.0.113.0/24")
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
// An hour's ban for a broken limit. // An hour's ban for a broken limit.
ledger.BanForLimit(netblock, midnight(), bans.Notes{}) ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -292,7 +292,7 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
wide := netip.MustParsePrefix("203.0.113.0/24") wide := netip.MustParsePrefix("203.0.113.0/24")
other := netip.MustParsePrefix("203.0.113.10/32") other := netip.MustParsePrefix("203.0.113.10/32")
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{ ledger.Load([]bans.Ban{
// Ended an hour ago. // Ended an hour ago.
{ {
@@ -341,3 +341,48 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
wantChanged(t, ledger, false) wantChanged(t, ledger, false)
} }
func TestPermanentBanOnAWiderNetblockIsReturnedOverATemporaryBan(t *testing.T) {
t.Parallel()
client := netip.MustParseAddr("203.0.113.9")
ledger := bans.New(defaultParams())
// The temporary ban is made first, so that its netblock is looked at first.
ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(), bans.Notes{})
ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.0/24"), midnight(), time.Time{},
"for good")
wantPermanentBan(t, ledger, client, midnight().Add(time.Minute))
}
func TestOlderPermanentBanIsReturnedOverANewerTemporaryBan(t *testing.T) {
t.Parallel()
netblock := netip.MustParsePrefix("203.0.113.0/24")
ledger := bans.New(defaultParams())
ledger.BanForAdmin(netblock, midnight(), time.Time{}, "for good")
ledger.BanForAdmin(netblock, midnight().Add(time.Minute), midnight().Add(time.Hour),
"for an hour")
wantPermanentBan(t, ledger, netblock.Addr(), midnight().Add(2*time.Minute))
}
// wantPermanentBan checks that Find and Check return a permanent ban for
// client at now.
func wantPermanentBan(
t *testing.T, ledger *bans.Ledger, client netip.Addr, now time.Time,
) {
t.Helper()
for name, look := range map[string]func(netip.Addr, time.Time) (bans.Ban, bool, bool){
"Find": ledger.Find,
"Check": ledger.Check,
} {
ban, banned, _ := look(client, now)
if !banned || !ban.Permanent() {
t.Errorf("%s returned %+v and banned %v, want a permanent ban", name, ban, banned)
}
}
}
+43 -21
View File
@@ -40,8 +40,8 @@ const repeatFactor = 3
// maxTextBytes is how much of each text in a ban's notes is kept. // maxTextBytes is how much of each text in a ban's notes is kept.
const maxTextBytes = 256 const maxTextBytes = 256
// Rules are how long a ban lasts, and how many bans are held. // Params are how long a ban lasts, and how many bans are held.
type Rules struct { type Params struct {
// LimitBanDuration is how long a first ban for a broken limit lasts. // LimitBanDuration is how long a first ban for a broken limit lasts.
LimitBanDuration time.Duration LimitBanDuration time.Duration
// LimitBanRepeatWindow is how soon after the end of the netblock's // LimitBanRepeatWindow is how soon after the end of the netblock's
@@ -123,7 +123,7 @@ type Notes struct {
LimitPercentSetting string `json:"limit_percent_setting,omitempty"` LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
// RuleID and Target are, for a ban for a clear sign of attack, the id // RuleID and Target are, for a ban for a clear sign of attack, the id
// of the rule file rule that matched, and its target; TrapPath is, for // of the rule file rule that matched, and its target; TrapPath is, for
// one for a request for a path in SWW_TRAP_PATHS, that path as the // one for a request for a path in SWW_WAF_TRAP_PATHS, that path as the
// client sent it. // client sent it.
RuleID string `json:"rule_id,omitempty"` RuleID string `json:"rule_id,omitempty"`
Target string `json:"target,omitempty"` Target string `json:"target,omitempty"`
@@ -180,7 +180,7 @@ type Request struct {
// Ledger holds the bans. It is safe for concurrent use. // Ledger holds the bans. It is safe for concurrent use.
type Ledger struct { type Ledger struct {
rules Rules params Params
// changed receives a value when a ban is made, unless one is waiting // changed receives a value when a ban is made, unless one is waiting
// already. // already.
changed chan struct{} changed chan struct{}
@@ -190,7 +190,7 @@ type Ledger struct {
// Find make each netblock they find the most recently seen. // Find make each netblock they find the most recently seen.
netblocks *simplelru.LRU[netip.Prefix, *[]Ban] netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
// held is how many bans netblocks holds whose cause is not CauseAdmin, // held is how many bans netblocks holds whose cause is not CauseAdmin,
// at most rules.MaxBans. // at most params.MaxBans.
held int held int
// made is how many bans have been made since the start, by cause: by // made is how many bans have been made since the start, by cause: by
// the ledger, and by an admin, through BanForAdmin or in an edit of // the ledger, and by an admin, through BanForAdmin or in an edit of
@@ -199,13 +199,13 @@ type Ledger struct {
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6 // v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
// netblocks that have been banned. Check looks for a ban at each of // netblocks that have been banned. Check looks for a ban at each of
// them, so that a ban read from bans.json refuses every client in its // them, so that a ban read from bans.json refuses every client in its
// netblock even when it was made with another SWW_BAN_SCOPE_V4_PREFIX, // netblock even when it was made with another SWW_WAF_BAN_SCOPE_V4_PREFIX,
// or another length of an IPv6 client's netblock. // or another length of an IPv6 client's netblock.
v4Lengths, v6Lengths []int v4Lengths, v6Lengths []int
} }
// New returns a Ledger with no ban yet. // New returns a Ledger with no ban yet.
func New(rules Rules) *Ledger { func New(params Params) *Ledger {
// The ledger drops bans itself, and never those whose cause is // The ledger drops bans itself, and never those whose cause is
// CauseAdmin, however many there are, so the LRU has no limit of its // CauseAdmin, however many there are, so the LRU has no limit of its
// own: it keeps the netblocks in the order they were last seen. // own: it keeps the netblocks in the order they were last seen.
@@ -215,7 +215,7 @@ func New(rules Rules) *Ledger {
} }
return &Ledger{ return &Ledger{
rules: rules, params: params,
changed: make(chan struct{}, 1), changed: make(chan struct{}, 1),
netblocks: netblocks, netblocks: netblocks,
made: map[string]int{}, made: map[string]int{},
@@ -274,16 +274,27 @@ func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool, bool) {
// activeBan returns the ban in bans, a netblock's bans oldest first, that // activeBan returns the ban in bans, a netblock's bans oldest first, that
// is active at now, or nil when none is. If several are, it returns the // is active at now, or nil when none is. If several are, it returns the
// one that started last. Every ban is looked at, since a ban an admin adds // newest permanent one, or the newest when none is permanent. Every ban is
// to bans.json can start before the netblock's others and outlast them. // looked at, since a ban an admin adds to bans.json can start before the
// netblock's others and outlast them.
func activeBan(bans []Ban, now time.Time) *Ban { func activeBan(bans []Ban, now time.Time) *Ban {
var newest *Ban
for i := len(bans) - 1; i >= 0; i-- { for i := len(bans) - 1; i >= 0; i-- {
if bans[i].ActiveAt(now) { if !bans[i].ActiveAt(now) {
continue
}
if bans[i].Permanent() {
return &bans[i] return &bans[i]
} }
if newest == nil {
newest = &bans[i]
}
} }
return nil return newest
} }
// BanForLimit bans netblock at now for a broken limit, with notes, and // BanForLimit bans netblock at now for a broken limit, with notes, and
@@ -743,13 +754,16 @@ func (l *Ledger) markChanged() {
} }
// active returns the ban active at now on a netblock client is in, or // active returns the ban active at now on a netblock client is in, or
// nil. // nil. If several netblocks have one, it returns a permanent ban when
// there is one.
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban { func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
lengths := l.v6Lengths lengths := l.v6Lengths
if client.Is4() { if client.Is4() {
lengths = l.v4Lengths lengths = l.v4Lengths
} }
var first *Ban
for _, length := range lengths { for _, length := range lengths {
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked()) bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
if !found { if !found {
@@ -757,12 +771,20 @@ func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
} }
ban := activeBan(*bans, now) ban := activeBan(*bans, now)
if ban != nil { if ban == nil {
continue
}
if ban.Permanent() {
return ban return ban
} }
if first == nil {
first = ban
}
} }
return nil return first
} }
// covering returns the bans of each netblock held that client is in, // covering returns the bans of each netblock held that client is in,
@@ -790,7 +812,7 @@ func (l *Ledger) covering(client netip.Addr) []*[]Ban {
// unless ban's cause is CauseAdmin, which does not count toward MaxBans. // unless ban's cause is CauseAdmin, which does not count toward MaxBans.
func (l *Ledger) add(ban Ban) { func (l *Ledger) add(ban Ban) {
counted := ban.Cause != CauseAdmin counted := ban.Cause != CauseAdmin
if counted && l.held == l.rules.MaxBans { if counted && l.held == l.params.MaxBans {
l.dropOne() l.dropOne()
} }
@@ -825,7 +847,7 @@ func (l *Ledger) add(ban Ban) {
// new ban longer. A ban an admin adds to bans.json can start after // new ban longer. A ban an admin adds to bans.json can start after
// another and end before it, so that one is looked for among them all. // another and end before it, so that one is looked for among them all.
func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time { func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
length := l.rules.LimitBanDuration length := l.params.LimitBanDuration
var last *Ban var last *Ban
@@ -836,18 +858,18 @@ func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
} }
} }
if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow { if last != nil && now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow {
lastLength := last.Expires.Sub(last.Start) lastLength := last.Expires.Sub(last.Start)
// This is repeatFactor * lastLength > MaxBanDuration, written so // This is repeatFactor * lastLength > MaxBanDuration, written so
// that it cannot overflow. // that it cannot overflow.
if lastLength > l.rules.MaxBanDuration/repeatFactor { if lastLength > l.params.MaxBanDuration/repeatFactor {
return time.Time{} return time.Time{}
} }
length = repeatFactor * lastLength length = repeatFactor * lastLength
} }
if length > l.rules.MaxBanDuration { if length > l.params.MaxBanDuration {
return time.Time{} return time.Time{}
} }
@@ -865,7 +887,7 @@ func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time {
} }
} }
return now.Add(l.rules.AttackBanDuration) return now.Add(l.params.AttackBanDuration)
} }
// dropOne drops the earliest ban whose cause is not CauseAdmin of the // dropOne drops the earliest ban whose cause is not CauseAdmin of the
+27 -27
View File
@@ -15,7 +15,7 @@ const day = 24 * time.Hour
func TestRepeatsTripleUntilPermanent(t *testing.T) { func TestRepeatsTripleUntilPermanent(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
now := midnight() now := midnight()
@@ -62,7 +62,7 @@ func TestRepeatWindowRunsOut(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{}) first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -80,9 +80,9 @@ func TestRepeatWindowRunsOut(t *testing.T) {
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) { func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
t.Parallel() t.Parallel()
rules := defaultRules() params := defaultParams()
rules.LimitBanDuration = rules.MaxBanDuration + time.Hour params.LimitBanDuration = params.MaxBanDuration + time.Hour
ledger := bans.New(rules) ledger := bans.New(params)
ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(), ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
bans.Notes{}) bans.Notes{})
@@ -97,9 +97,9 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
// With bans of up to 100,000 days, the 14th ban in a row, of 3^13 // With bans of up to 100,000 days, the 14th ban in a row, of 3^13
// hours, is within the maximum, and three times as long would not fit // hours, is within the maximum, and three times as long would not fit
// in a time.Duration. The 15th is permanent. // in a time.Duration. The 15th is permanent.
rules := defaultRules() params := defaultParams()
rules.MaxBanDuration = 100000 * day params.MaxBanDuration = 100000 * day
ledger := bans.New(rules) ledger := bans.New(params)
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
now := midnight() now := midnight()
@@ -121,7 +121,7 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) { func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
first, made := ledger.BanForLimit(netblock, midnight(), bans.Notes{}) first, made := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -146,7 +146,7 @@ func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) { func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5}) ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
@@ -178,7 +178,7 @@ func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
func TestFindCountsNothing(t *testing.T) { func TestFindCountsNothing(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5}) ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
@@ -200,9 +200,9 @@ func TestFindCountsNothing(t *testing.T) {
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) { func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
t.Parallel() t.Parallel()
rules := defaultRules() params := defaultParams()
rules.MaxBans = 3 params.MaxBans = 3
ledger := bans.New(rules) ledger := bans.New(params)
a := netip.MustParsePrefix("203.0.113.1/32") a := netip.MustParsePrefix("203.0.113.1/32")
b := netip.MustParsePrefix("203.0.113.2/32") b := netip.MustParsePrefix("203.0.113.2/32")
c := netip.MustParsePrefix("203.0.113.3/32") c := netip.MustParsePrefix("203.0.113.3/32")
@@ -239,9 +239,9 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
// With room for one ban, the netblock's ended ban goes to make room for // With room for one ban, the netblock's ended ban goes to make room for
// its new one, whose notes still count it. // its new one, whose notes still count it.
rules := defaultRules() params := defaultParams()
rules.MaxBans = 1 params.MaxBans = 1
ledger := bans.New(rules) ledger := bans.New(params)
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{}) first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -258,7 +258,7 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) { func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
notes := bans.Notes{RuleID: "env-file", Target: "path"} notes := bans.Notes{RuleID: "env-file", Target: "path"}
@@ -304,7 +304,7 @@ func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) { func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
// A ban for a broken limit before does not count. // A ban for a broken limit before does not count.
@@ -336,7 +336,7 @@ func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) { func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{}) first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
wantChanged(t, ledger, true) wantChanged(t, ledger, true)
@@ -380,7 +380,7 @@ func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) { func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
now := midnight() now := midnight()
@@ -414,7 +414,7 @@ func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) { func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
// Three times the seven days would be permanent; a limit broken as the // Three times the seven days would be permanent; a limit broken as the
@@ -436,7 +436,7 @@ func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
func TestRequestTextsAreCutTo256Bytes(t *testing.T) { func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
long := strings.Repeat("a", 300) long := strings.Repeat("a", 300)
request := bans.Request{ request := bans.Request{
@@ -458,7 +458,7 @@ func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) { func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
other := netip.MustParsePrefix("198.51.100.7/32") other := netip.MustParsePrefix("198.51.100.7/32")
@@ -494,9 +494,9 @@ func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
} }
} }
// defaultRules are the rules at the settings' defaults. // defaultParams are the params at the settings' defaults.
func defaultRules() bans.Rules { func defaultParams() bans.Params {
return bans.Rules{ return bans.Params{
LimitBanDuration: time.Hour, LimitBanDuration: time.Hour,
LimitBanRepeatWindow: day, LimitBanRepeatWindow: day,
MaxBanDuration: 7 * day, MaxBanDuration: 7 * day,
+3 -3
View File
@@ -15,7 +15,7 @@ const scenario = "crowdsecurity/ssh-bf"
func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) { func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
expires := midnight().Add(4 * time.Hour) expires := midnight().Add(4 * time.Hour)
@@ -49,7 +49,7 @@ func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) { func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
expires := midnight().Add(4 * time.Hour) expires := midnight().Add(4 * time.Hour)
ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, bans.Notes{}) ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, bans.Notes{})
@@ -72,7 +72,7 @@ func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) { func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
// Three times the three days would be permanent; a limit broken as the // Three times the three days would be permanent; a limit broken as the
+14 -14
View File
@@ -14,7 +14,7 @@ import (
func TestChangedAfterABanIsMade(t *testing.T) { func TestChangedAfterABanIsMade(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
wantChanged(t, ledger, false) wantChanged(t, ledger, false)
@@ -38,7 +38,7 @@ func TestChangedAfterABanIsMade(t *testing.T) {
func TestSnapshotListsEveryBanByNetblock(t *testing.T) { func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
v6 := netip.MustParsePrefix("2001:db8::/64") v6 := netip.MustParsePrefix("2001:db8::/64")
high := netip.MustParsePrefix("203.0.113.10/32") high := netip.MustParsePrefix("203.0.113.10/32")
low := netip.MustParsePrefix("203.0.113.9/32") low := netip.MustParsePrefix("203.0.113.9/32")
@@ -67,14 +67,14 @@ func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
func TestLoadedBansCarryOn(t *testing.T) { func TestLoadedBansCarryOn(t *testing.T) {
t.Parallel() t.Parallel()
before := bans.New(defaultRules()) before := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
ban, _ := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1}) ban, _ := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1})
// Loaded into a new ledger, as across a restart, the ban still refuses // Loaded into a new ledger, as across a restart, the ban still refuses
// while it lasts, and once it has ended a broken limit bans for three // while it lasts, and once it has ended a broken limit bans for three
// times as long, with the loaded ban counted among the earlier ones. // times as long, with the loaded ban counted among the earlier ones.
after := bans.New(defaultRules()) after := bans.New(defaultParams())
after.Load(before.Snapshot()) after.Load(before.Snapshot())
_, banned, _ := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second)) _, banned, _ := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
@@ -96,7 +96,7 @@ func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) {
// Two entries as an admin might write them, with addresses not masked // Two entries as an admin might write them, with addresses not masked
// to their lengths, the IPv6 one shorter than the /64 an IPv6 client's // to their lengths, the IPv6 one shorter than the /64 an IPv6 client's
// ban covers, beside a ban the ledger makes on one IPv4 address. // ban covers, beside a ban the ledger makes on one IPv4 address.
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{ ledger.Load([]bans.Ban{
{Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()}, {Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()},
{Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()}, {Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()},
@@ -145,7 +145,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
Expires: midnight().Add(time.Hour), Expires: midnight().Add(time.Hour),
} }
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{permanent, ended}) ledger.Load([]bans.Ban{permanent, ended})
now := midnight().Add(2 * time.Hour) now := midnight().Add(2 * time.Hour)
@@ -189,7 +189,7 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
Expires: midnight().Add(2 * time.Hour), Expires: midnight().Add(2 * time.Hour),
} }
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{nineHours, admins}) ledger.Load([]bans.Ban{nineHours, admins})
// Once both have ended, a limit broken within the repeat window bans // Once both have ended, a limit broken within the repeat window bans
@@ -219,9 +219,9 @@ func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
Cause: bans.CauseLimit, Cause: bans.CauseLimit,
} }
rules := defaultRules() params := defaultParams()
rules.MaxBans = 1 params.MaxBans = 1
ledger := bans.New(rules) ledger := bans.New(params)
ledger.Load([]bans.Ban{later, earlier}) ledger.Load([]bans.Ban{later, earlier})
held := ledger.Snapshot() held := ledger.Snapshot()
@@ -235,9 +235,9 @@ func TestLoadReplacesTheBansHeld(t *testing.T) {
// Room for three bans, so that the second load, were it added to the // Room for three bans, so that the second load, were it added to the
// two bans held, would drop none of them to make room. // two bans held, would drop none of them to make room.
rules := defaultRules() params := defaultParams()
rules.MaxBans = 3 params.MaxBans = 3
ledger := bans.New(rules) ledger := bans.New(params)
kept := bans.Ban{ kept := bans.Ban{
Netblock: netip.MustParsePrefix("2001:db8::/64"), Netblock: netip.MustParsePrefix("2001:db8::/64"),
Start: midnight(), Start: midnight(),
@@ -285,7 +285,7 @@ func TestLoadCutsTheTextsTo256Bytes(t *testing.T) {
}}, }},
} }
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{ban}) ledger.Load([]bans.Ban{ban})
cut := long[:256] cut := long[:256]
+344 -374
View File
File diff suppressed because it is too large. Load diff
+207 -234
View File
@@ -17,106 +17,109 @@ import (
"testing" "testing"
"time" "time"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
) )
// The settings, by name. // The settings, by name.
const ( const (
listenAddr = "SWW_LISTEN_ADDR" listenAddr = "SWW_WAF_LISTEN_ADDR"
mode = "SWW_MODE" mode = "SWW_WAF_MODE"
ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX" ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS" maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
allowNets = "SWW_ALLOW_NETS" allowNets = "SWW_WAF_ALLOW_NETS"
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS" rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWW_DENY_NETS" denyNets = "SWW_WAF_DENY_NETS"
rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE" rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
rateLimitPerHour = "SWW_RATE_LIMIT_PER_HOUR" rateLimitPerHour = "SWW_WAF_RATE_LIMIT_PER_HOUR"
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY" rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS" rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE" bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR" bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY" bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY"
bytesCount = "SWW_BYTES_COUNT" bytesCount = "SWW_WAF_BYTES_COUNT"
lookupSource = "SWW_LOOKUP_SOURCE" lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_LOOKUP_DB_PATH" lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
lookupTimeout = "SWW_LOOKUP_TIMEOUT" lookupTimeout = "SWW_WAF_LOOKUP_TIMEOUT"
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS" addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES" allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
tagLists = "SWW_TAG_LISTS" tagLists = "SWW_WAF_TAG_LISTS"
tagDeny = "SWW_TAG_DENY" tagDeny = "SWW_WAF_TAG_DENY"
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT" tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
asnBytesPercent = "SWW_ASN_BYTES_PERCENT" asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT" countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT" unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT"
asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL" asnLimitPercentURL = "SWW_WAF_ASN_LIMIT_PERCENT_URL"
blocklistURLs = "SWW_BLOCKLIST_URLS" blocklistURLs = "SWW_WAF_BLOCKLIST_URLS"
blocklistRefresh = "SWW_BLOCKLIST_REFRESH" blocklistRefresh = "SWW_WAF_BLOCKLIST_REFRESH"
blocklistAction = "SWW_BLOCKLIST_ACTION" blocklistAction = "SWW_WAF_BLOCKLIST_ACTION"
dnsblZones = "SWW_DNSBL_ZONES" dnsblZones = "SWW_WAF_DNSBL_ZONES"
dnsblResolver = "SWW_DNSBL_RESOLVER" dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
abuseIPDBKey = "SWW_ABUSEIPDB_KEY" abuseIPDBKey = "SWW_WAF_ABUSEIPDB_KEY"
abuseIPDBMinScore = "SWW_ABUSEIPDB_MIN_SCORE" abuseIPDBMinScore = "SWW_WAF_ABUSEIPDB_MIN_SCORE"
abuseIPDBDailyBudget = "SWW_ABUSEIPDB_DAILY_BUDGET" abuseIPDBDailyBudget = "SWW_WAF_ABUSEIPDB_DAILY_BUDGET"
reputationAction = "SWW_REPUTATION_ACTION" reputationAction = "SWW_WAF_REPUTATION_ACTION"
reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL" reputationCacheTTL = "SWW_WAF_REPUTATION_CACHE_TTL"
reputationTimeout = "SWW_REPUTATION_TIMEOUT" reputationTimeout = "SWW_WAF_REPUTATION_TIMEOUT"
crowdSecURL = "SWW_CROWDSEC_LAPI_URL" routeWatchURL = "SWW_WAF_ROUTEWATCH_URL"
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY" crowdSecURL = "SWW_WAF_CROWDSEC_LAPI_URL"
banResponse = "SWW_BAN_RESPONSE" crowdSecKey = "SWW_WAF_CROWDSEC_LAPI_KEY"
limitBanDuration = "SWW_LIMIT_BAN_DURATION" banResponse = "SWW_WAF_BAN_RESPONSE"
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW" limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
maxBanDuration = "SWW_MAX_BAN_DURATION" limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
attackBanDuration = "SWW_ATTACK_BAN_DURATION" maxBanDuration = "SWW_WAF_MAX_BAN_DURATION"
maxBans = "SWW_MAX_BANS" attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX" maxBans = "SWW_WAF_MAX_BANS"
stateDir = "SWW_STATE_DIR" banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
stateWriteDelay = "SWW_STATE_WRITE_DELAY" stateDir = "SWW_WAF_STATE_DIR"
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL" stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY"
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL"
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsTopN = "SWW_METRICS_TOP_N" metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
instanceName = "SWW_INSTANCE_NAME" metricsTopN = "SWW_WAF_METRICS_TOP_N"
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS" instanceName = "SWW_WAF_INSTANCE_NAME"
logLevel = "SWW_LOG_LEVEL" logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
rulesDir = "SWW_RULES_DIR" logLevel = "SWW_WAF_LOG_LEVEL"
rulesEnabled = "SWW_RULES_ENABLED" rulesDir = "SWW_WAF_RULES_DIR"
wafMode = "SWW_WAF_MODE" rulesEnabled = "SWW_WAF_RULES_ENABLED"
wafParanoiaLevel = "SWW_WAF_PARANOIA_LEVEL" wafMode = "SWW_WAF_CORE_RULE_SET_MODE"
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD" wafParanoiaLevel = "SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL"
wafDisabledRules = "SWW_WAF_DISABLED_RULES" wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD"
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS" wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
wafBodyLimit = "SWW_WAF_BODY_LIMIT" wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE" wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT"
apps = "SWW_APPS" exclusionsFile = "SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE"
trapPaths = "SWW_TRAP_PATHS" trapPaths = "SWW_WAF_TRAP_PATHS"
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD" errorBurstThreshold = "SWW_WAF_ERROR_BURST_THRESHOLD"
logRemoteURL = "SWW_LOG_REMOTE_URL" logRemoteURL = "SWW_WAF_LOG_REMOTE_URL"
logRemoteTLSCAFile = "SWW_LOG_REMOTE_TLS_CA_FILE" logRemoteTLSCAFile = "SWW_WAF_LOG_REMOTE_TLS_CA_FILE"
logRemoteBuffer = "SWW_LOG_REMOTE_BUFFER" logRemoteBuffer = "SWW_WAF_LOG_REMOTE_BUFFER"
logRemoteFacility = "SWW_LOG_REMOTE_FACILITY" logRemoteFacility = "SWW_WAF_LOG_REMOTE_FACILITY"
logRemoteAppName = "SWW_LOG_REMOTE_APP_NAME" logRemoteAppName = "SWW_WAF_LOG_REMOTE_APP_NAME"
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL" alertWebhookURL = "SWW_WAF_ALERT_WEBHOOK_URL"
alertWebhookHeaders = "SWW_ALERT_WEBHOOK_HEADERS" alertWebhookHeaders = "SWW_WAF_ALERT_WEBHOOK_HEADERS"
alertSlackWebhookURL = "SWW_ALERT_SLACK_WEBHOOK_URL" alertSlackWebhookURL = "SWW_WAF_ALERT_SLACK_WEBHOOK_URL"
alertNtfyURL = "SWW_ALERT_NTFY_URL" alertNtfyURL = "SWW_WAF_ALERT_NTFY_URL"
alertNtfyToken = "SWW_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name alertNtfyToken = "SWW_WAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
alertEvents = "SWW_ALERT_EVENTS" alertEvents = "SWW_WAF_ALERT_EVENTS"
alertCooldown = "SWW_ALERT_COOLDOWN" alertCooldown = "SWW_WAF_ALERT_COOLDOWN"
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR" alertMaxPerHour = "SWW_WAF_ALERT_MAX_PER_HOUR"
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX" anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX" anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
watchNets = "SWW_WATCH_NETS" watchNets = "SWW_WAF_WATCH_NETS"
) )
// The anomaly thresholds: each of the prefixes below, which name a scope, // The anomaly thresholds: each of the prefixes below, which name a scope,
// followed by each of the four ends. // followed by each of the four ends.
const ( const (
anomalyClient = "SWW_ANOMALY_CLIENT_" anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
anomalyNet = "SWW_ANOMALY_NET_" anomalyNet = "SWW_WAF_ANOMALY_NET_"
anomalyASN = "SWW_ANOMALY_ASN_" anomalyASN = "SWW_WAF_ANOMALY_ASN_"
anomalyTotal = "SWW_ANOMALY_TOTAL_" anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
watch = "SWW_WATCH_" watch = "SWW_WAF_WATCH_"
requestsPerMinute = "REQUESTS_PER_MINUTE" requestsPerMinute = "REQUESTS_PER_MINUTE"
requestsPerHour = "REQUESTS_PER_HOUR" requestsPerHour = "REQUESTS_PER_HOUR"
@@ -157,21 +160,21 @@ func loggedAnomalyDefaults() map[string]string {
return logged return logged
} }
// defaultAlertEvents is the default of SWW_ALERT_EVENTS, and // defaultAlertEvents is the default of SWW_WAF_ALERT_EVENTS, and
// defaultAlertCooldown that of SWW_ALERT_COOLDOWN. // defaultAlertCooldown that of SWW_WAF_ALERT_COOLDOWN.
const ( const (
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," + defaultAlertEvents = "ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit," +
"source_failure,file_error" "source_failure,file_error"
defaultAlertCooldown = "15m" defaultAlertCooldown = "15m"
) )
// defaultReputationCacheTTL is the default of SWW_REPUTATION_CACHE_TTL. // defaultReputationCacheTTL is the default of SWW_WAF_REPUTATION_CACHE_TTL.
const defaultReputationCacheTTL = "24h" const defaultReputationCacheTTL = "24h"
// defaultWAFDisabledRules is the default of SWW_WAF_DISABLED_RULES. // defaultWAFDisabledRules is the default of SWW_WAF_CORE_RULE_SET_DISABLED_RULES.
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140" const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
// defaultLogRequestHeaders is the default of SWW_LOG_REQUEST_HEADERS. // defaultLogRequestHeaders is the default of SWW_WAF_LOG_REQUEST_HEADERS.
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," + const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
"content-type,origin,range" "content-type,origin,range"
@@ -196,8 +199,8 @@ const (
otherToken = "fedcba9876543210fedcba9876543210" otherToken = "fedcba9876543210fedcba9876543210"
) )
// instance is an SWW_INSTANCE_NAME that is a valid app name too, and // instance is an SWW_WAF_INSTANCE_NAME that is a valid app name too, and
// remoteURL an SWW_LOG_REMOTE_URL, for the tests that send the lines. // remoteURL an SWW_WAF_LOG_REMOTE_URL, for the tests that send the lines.
const ( const (
instance = "fsn1app1/blog" instance = "fsn1app1/blog"
remoteURL = "syslog+udp://192.0.2.1:514" remoteURL = "syslog+udp://192.0.2.1:514"
@@ -212,7 +215,7 @@ const enabled = "true"
// disabled is false, as a setting's value. // disabled is false, as a setting's value.
const disabled = "false" const disabled = "false"
// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and // defaultLookupSource is the default of SWW_WAF_LOOKUP_SOURCE, and
// fileSource the source that is the lookup database. // fileSource the source that is the lookup database.
const ( const (
defaultLookupSource = "geojs" defaultLookupSource = "geojs"
@@ -222,18 +225,16 @@ const (
// environment is a set of environment variables, for FromEnvironment. // environment is a set of environment variables, for FromEnvironment.
type environment map[string]string type environment map[string]string
// lookupEnv reads one of the variables, as os.LookupEnv does. // viper returns a viper holding each of the variables.
func (e environment) lookupEnv(name string) (string, bool) { func (e environment) viper() *viper.Viper {
value, ok := e[name] return configtest.Viper(e)
return value, ok
} }
// fromEnvironment reads the settings from env, which must be valid. // fromEnvironment reads the settings from env, which must be valid.
func fromEnvironment(t *testing.T, env environment) *config.Config { func fromEnvironment(t *testing.T, env environment) *config.Config {
t.Helper() t.Helper()
cfg, err := config.FromEnvironment(env.lookupEnv) cfg, err := config.FromEnvironment(env.viper())
if err != nil { if err != nil {
t.Fatalf("settings %v: %v", env, err) t.Fatalf("settings %v: %v", env, err)
} }
@@ -462,7 +463,7 @@ func TestPathPrefixNotStartingWithSlashStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment( _, err := config.FromEnvironment(
environment{rateLimitExemptPaths: "/favicon.ico,assets/"}.lookupEnv) environment{rateLimitExemptPaths: "/favicon.ico,assets/"}.viper())
want := rateLimitExemptPaths + `: "assets/" is not a path prefix ` + want := rateLimitExemptPaths + `: "assets/" is not a path prefix ` +
`starting with /, such as /assets/` `starting with /, such as /assets/`
@@ -517,7 +518,7 @@ func TestInvalidTrapPathOrErrorBurstThresholdStopsTheStart(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want want := tc.name + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -582,92 +583,6 @@ func TestCoreRuleSetSettings(t *testing.T) {
} }
} }
func TestApps(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
env environment
want map[string][]string
}{
{environment{}, nil},
{environment{apps: ""}, map[string][]string{}},
{
environment{apps: "Git.Example.com:gitea; cloud.example.com : gitea "},
map[string][]string{
"git.example.com": {"gitea"}, "cloud.example.com": {"gitea"},
},
},
} {
cfg := fromEnvironment(t, tc.env)
if !reflect.DeepEqual(cfg.Apps, tc.want) {
t.Errorf("%v gave %#v, want %#v", tc.env, cfg.Apps, tc.want)
}
}
}
// Unset and set empty, SWW_APPS has opposite effects, so it logs differently.
func TestUnsetAndEmptyAppsAreLoggedDifferently(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
env environment
want string
}{
{environment{}, `"` + apps + `":null`},
{environment{apps: ""}, `"` + apps + `":""`},
} {
var out bytes.Buffer
slog.New(slog.NewJSONHandler(&out, nil)).
Info("starting", "settings", fromEnvironment(t, tc.env))
if !strings.Contains(out.String(), tc.want) {
t.Errorf("%v logged %s, want %s in it", tc.env, out.String(), tc.want)
}
}
}
func TestInvalidAppsStopsTheStart(t *testing.T) {
t.Parallel()
const (
notEntry = " is not a hostname, : and a list of apps, " +
"such as git.example.com:gitea"
notHostname = " is not a DNS name such as git.example.com"
)
for _, tc := range []struct{ value, want string }{
{"git.example.com:gitea;blog.example.com", `"blog.example.com"` + notEntry},
{"git.example.com:gitea;", `""` + notEntry},
{":gitea", `":gitea"` + notEntry},
{"git.example.com:", `"git.example.com:"` + notEntry},
{"*.example.com:gitea", `"*.example.com"` + notHostname},
{"git.example.com/:gitea", `"git.example.com/"` + notHostname},
{"git example.com:gitea", `"git example.com"` + notHostname},
{
"git.example.com:gitea,nextcloud",
`"nextcloud" is not an app sww has fixes for; the apps are gitea`,
},
{
"git.example.com:gitea;GIT.example.com:gitea",
`"git.example.com" is listed twice`,
},
{"git.example.com:gitea,gitea", `"gitea" is listed twice`},
{"git.example.com:gitea,", `"gitea," has an empty item in its list`},
} {
t.Run(tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{apps: tc.value}.lookupEnv)
want := apps + ": " + tc.want
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
}
}
func TestWAFBodyLimitOf64K(t *testing.T) { func TestWAFBodyLimitOf64K(t *testing.T) {
t.Parallel() t.Parallel()
@@ -732,7 +647,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want want := tc.name + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -857,7 +772,7 @@ func TestInvalidRemoteLogSettingStopsTheStart(t *testing.T) {
{logRemoteAppName, ""}, {logRemoteAppName, "my app"}, {logRemoteAppName, ""}, {logRemoteAppName, "my app"},
{logRemoteAppName, "gitéa"}, {logRemoteAppName, strings.Repeat("a", 49)}, {logRemoteAppName, "gitéa"}, {logRemoteAppName, strings.Repeat("a", 49)},
} { } {
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") { if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") {
t.Errorf("%s=%q: error %v, want one naming it", tc.name, tc.value, err) t.Errorf("%s=%q: error %v, want one naming it", tc.name, tc.value, err)
} }
@@ -874,7 +789,7 @@ func TestRemoteLogCAFileWithoutCertificateStopsTheStart(t *testing.T) {
t.Fatalf("write %s: %v", caFile, err) t.Fatalf("write %s: %v", caFile, err)
} }
_, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.lookupEnv) _, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.viper())
want := logRemoteTLSCAFile + `: "` + caFile + `" holds no PEM certificate` want := logRemoteTLSCAFile + `: "` + caFile + `" holds no PEM certificate`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -889,7 +804,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) {
sending := environment{logRemoteURL: remoteURL, instanceName: spaced} sending := environment{logRemoteURL: remoteURL, instanceName: spaced}
_, err := config.FromEnvironment(sending.lookupEnv) _, err := config.FromEnvironment(sending.viper())
want := logRemoteAppName + `: is unset, and ` + instanceName + want := logRemoteAppName + `: is unset, and ` + instanceName +
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` + ` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
@@ -919,7 +834,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
logRemoteURL: remoteURL, logRemoteURL: remoteURL,
instanceName: instance, instanceName: instance,
logRemoteAppName: "my app", logRemoteAppName: "my app",
}.lookupEnv) }.viper())
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` + want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
`characters without a space, such as blog` `characters without a space, such as blog`
@@ -1050,7 +965,7 @@ func TestWebhookHeadersAreLoggedMaskedAndNeverShown(t *testing.T) {
// An item that is not a header is named by its place, not shown. // An item that is not a header is named by its place, not shown.
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
alertWebhookHeaders: "X-Team:ops," + secret, alertWebhookHeaders: "X-Team:ops," + secret,
}.lookupEnv) }.viper())
want := alertWebhookHeaders + ": item 2 is not a header name followed by : " + want := alertWebhookHeaders + ": item 2 is not a header name followed by : " +
"and the header's value, such as Authorization:Bearer <token>" "and the header's value, such as Authorization:Bearer <token>"
@@ -1087,7 +1002,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
"ftp://hooks.example/services/" + secret, "ftp://hooks.example/services/" + secret,
"https://hooks.example/services/%zz" + secret, "https://hooks.example/services/%zz" + secret,
} { } {
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv) _, err := config.FromEnvironment(environment{alertWebhookURL: value}.viper())
want := alertWebhookURL + ": is not an http or https URL without a user or " + want := alertWebhookURL + ": is not an http or https URL without a user or " +
"a fragment, such as https://alerts.example/sww" "a fragment, such as https://alerts.example/sww"
@@ -1138,7 +1053,7 @@ func TestNtfyTokenWithAControlCharacterStopsTheStartWithoutShowingIt(t *testing.
"set": {alertNtfyToken: token + "\r"}, "set": {alertNtfyToken: token + "\r"},
"in a file": {alertNtfyToken + "_FILE": writeFile(t, token+"\r\n")}, "in a file": {alertNtfyToken + "_FILE": writeFile(t, token+"\r\n")},
} { } {
_, err := config.FromEnvironment(env.lookupEnv) _, err := config.FromEnvironment(env.viper())
want := alertNtfyToken + ": holds a control character, such as the " + want := alertNtfyToken + ": holds a control character, such as the " +
"carriage return of a Windows line end" "carriage return of a Windows line end"
@@ -1155,7 +1070,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts", instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts",
}.lookupEnv) }.viper())
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` + want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
`carriage return of a Windows line end, and is sent to ntfy in a header ` + `carriage return of a Windows line end, and is sent to ntfy in a header ` +
@@ -1180,7 +1095,7 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
"set": {instanceName: latin1}, "set": {instanceName: latin1},
"in a file": {instanceName + "_FILE": writeFile(t, latin1+"\n")}, "in a file": {instanceName + "_FILE": writeFile(t, latin1+"\n")},
} { } {
_, err := config.FromEnvironment(env.lookupEnv) _, err := config.FromEnvironment(env.viper())
want := instanceName + `: "caf\xe9" is not valid UTF-8` want := instanceName + `: "caf\xe9" is not valid UTF-8`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -1195,9 +1110,10 @@ func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) {
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
tagDeny: "country=cn,country=ru", tagDeny: "country=cn,country=ru",
allowedCountries: "de,RU", allowedCountries: "de,RU",
}.lookupEnv) }.viper())
if err == nil { if err == nil {
t.Fatal("ru in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY was accepted") t.Fatal("ru in SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES and " +
"SWW_WAF_TAG_DENY was accepted")
} }
if !strings.HasPrefix(err.Error(), allowedCountries+": ") || if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
@@ -1257,7 +1173,7 @@ func TestLookupDBPathGoesWithTheFileSourceAlone(t *testing.T) {
lookupDBPath + ": is set while " + lookupSource + " is off; only file reads it", lookupDBPath + ": is set while " + lookupSource + " is off; only file reads it",
}, },
} { } {
_, err := config.FromEnvironment(tc.env.lookupEnv) _, err := config.FromEnvironment(tc.env.viper())
if err == nil || err.Error() != tc.want { if err == nil || err.Error() != tc.want {
t.Errorf("settings %v: error %v, want %s", tc.env, err, tc.want) t.Errorf("settings %v: error %v, want %s", tc.env, err, tc.want)
} }
@@ -1286,7 +1202,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
lookupSource: off, name: value, lookupSource: off, name: value,
}.lookupEnv) }.viper())
if err == nil || !strings.HasPrefix(err.Error(), name+": ") || if err == nil || !strings.HasPrefix(err.Error(), name+": ") ||
!strings.Contains(err.Error(), lookupSource+" is off") { !strings.Contains(err.Error(), lookupSource+" is off") {
t.Errorf("error %v, want one naming %s and %s", err, name, lookupSource) t.Errorf("error %v, want one naming %s and %s", err, name, lookupSource)
@@ -1295,7 +1211,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
} }
// Set empty, the lists need nothing looked up, and nor does // Set empty, the lists need nothing looked up, and nor does
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the // SWW_WAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the
// lookup does not give, an anomaly threshold per AS number that is off, // lookup does not give, an anomaly threshold per AS number that is off,
// any other anomaly threshold, or a blocklist. // any other anomaly threshold, or a blocklist.
env := environment{ env := environment{
@@ -1437,7 +1353,7 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want want := tc.name + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -1490,6 +1406,63 @@ func TestTagSettingsAsSet(t *testing.T) {
} }
} }
func TestRouteWatchURLAndPrefixTags(t *testing.T) {
t.Parallel()
const rw = "https://routewatch.example"
if cfg := fromEnvironment(t, environment{}); cfg.RouteWatchURL != "" {
t.Errorf("default %s is %q, want unset", routeWatchURL, cfg.RouteWatchURL)
}
cfg := fromEnvironment(t, environment{
routeWatchURL: rw, tagDeny: "prefix=2001:DB8::/32",
tagLimitPercent: "prefix=192.0.2.0/24:50",
})
wantDeny := []string{"prefix=2001:db8::/32"}
if cfg.RouteWatchURL != rw || !slices.Equal(cfg.TagDeny, wantDeny) ||
cfg.TagLimitPercent["prefix=192.0.2.0/24"] != 50 {
t.Errorf("gave %q, %v, %v", cfg.RouteWatchURL, cfg.TagDeny, cfg.TagLimitPercent)
}
// routewatch gives asn= tags too, so an asn= tag needs no lookups while it is set.
fromEnvironment(t, environment{
lookupSource: off, routeWatchURL: rw, tagDeny: "asn=64496",
})
for _, stopped := range []struct {
env environment
name string
}{
{environment{routeWatchURL: "ftp://routewatch.example"}, routeWatchURL},
{environment{routeWatchURL: "routewatch.example"}, routeWatchURL},
{environment{tagDeny: "prefix=192.0.2.0/24"}, tagDeny},
{environment{routeWatchURL: rw, tagDeny: "prefix=nowhere"}, tagDeny},
{environment{routeWatchURL: rw, tagDeny: "prefix=192.0.2.5/24"}, tagDeny},
{
environment{routeWatchURL: rw, tagLimitPercent: "prefix=192.0.2.0/33:50"},
tagLimitPercent,
},
{environment{lookupSource: off, routeWatchURL: rw, tagDeny: "country=DE"}, tagDeny},
{environment{lookupSource: off, tagDeny: "asn=64496"}, tagDeny},
} {
_, err := config.FromEnvironment(stopped.env.viper())
if err == nil || !strings.HasPrefix(err.Error(), stopped.name+": ") {
t.Errorf("%v gave the error %v, want one naming %s", stopped.env, err, stopped.name)
}
}
badURL := environment{routeWatchURL: "ftp://routewatch.example"}
_, err := config.FromEnvironment(badURL.viper())
want := routeWatchURL + `: "ftp://routewatch.example" is not an http or https URL ` +
"without a user or a fragment, such as https://routewatch.example"
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
}
func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) { func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1536,7 +1509,7 @@ func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(tc.env.lookupEnv) _, err := config.FromEnvironment(tc.env.viper())
switch { switch {
case tc.want == "" && err != nil: case tc.want == "" && err != nil:
@@ -1574,7 +1547,7 @@ func TestErrorOfATagSettingLeavesOutAZoneKey(t *testing.T) {
{tagLimitPercent, tag + ":50," + tag + " :25", twice}, {tagLimitPercent, tag + ":50," + tag + " :25", twice},
{tagLimitPercent, tag + ":50,,", emptyItem}, {tagLimitPercent, tag + ":50,,", emptyItem},
} { } {
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want want := tc.name + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -1596,7 +1569,7 @@ func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
{"the file of AS:percent lines", asnURL, environment{asnLimitPercentURL: asnURL}}, {"the file of AS:percent lines", asnURL, environment{asnLimitPercentURL: asnURL}},
{ {
"the CrowdSec decision list", lapi + "/v1/decisions", "the CrowdSec decision list", lapi + "/v1/decisions",
environment{"SWW_CROWDSEC_LAPI_URL": lapi, "SWW_CROWDSEC_LAPI_KEY": "key"}, environment{"SWW_WAF_CROWDSEC_LAPI_URL": lapi, "SWW_WAF_CROWDSEC_LAPI_KEY": "key"},
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -1604,10 +1577,10 @@ func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
tc.env[tagLists] = "dup=" + tc.listURL tc.env[tagLists] = "dup=" + tc.listURL
want := tagLists + `: "` + tc.listURL + `" is in SWW_BLOCKLIST_URLS, is ` + want := tagLists + `: "` + tc.listURL + `" is in SWW_WAF_BLOCKLIST_URLS, is ` +
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too" "SWW_WAF_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too"
_, err := config.FromEnvironment(tc.env.lookupEnv) _, err := config.FromEnvironment(tc.env.viper())
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -1676,7 +1649,7 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want want := tc.name + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -1694,7 +1667,7 @@ const (
asnURL = "https://lists.example/asn.txt" asnURL = "https://lists.example/asn.txt"
) )
// The actions of SWW_BLOCKLIST_ACTION, as Config gives them. // The actions of SWW_WAF_BLOCKLIST_ACTION, as Config gives them.
const ( const (
actionDeny = "deny" actionDeny = "deny"
actionLimit = "limit" actionLimit = "limit"
@@ -1773,7 +1746,7 @@ func TestInvalidReputationSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want want := tc.name + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -1788,9 +1761,9 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL, blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
}.lookupEnv) }.viper())
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_BLOCKLIST_URLS too` want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_WAF_BLOCKLIST_URLS too`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -1915,7 +1888,7 @@ func TestInvalidDNSBLSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want want := tc.name + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -1983,7 +1956,7 @@ func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
{spamhaus + "," + otherZone, `"` + spamhausMasked + `" is listed twice`}, {spamhaus + "," + otherZone, `"` + spamhausMasked + `" is listed twice`},
{spamhaus + ",,", "has an empty item in its list"}, {spamhaus + ",,", "has an empty item in its list"},
} { } {
_, err := config.FromEnvironment(environment{dnsblZones: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{dnsblZones: tc.value}.viper())
want := dnsblZones + ": " + tc.want want := dnsblZones + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -2036,7 +2009,7 @@ func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want want := tc.name + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -2093,7 +2066,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
notLAPIURL = " is not an http or https URL without a user or a fragment, " + notLAPIURL = " is not an http or https URL without a user or a fragment, " +
"such as http://172.17.0.1:8080" "such as http://172.17.0.1:8080"
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` + anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
`SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too` `SWW_WAF_BLOCKLIST_URLS or is SWW_WAF_ASN_LIMIT_PERCENT_URL too`
) )
for _, tc := range []struct { for _, tc := range []struct {
@@ -2150,7 +2123,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(tc.env.lookupEnv) _, err := config.FromEnvironment(tc.env.viper())
if err == nil || err.Error() != tc.want { if err == nil || err.Error() != tc.want {
t.Errorf("error %v, want %s", err, tc.want) t.Errorf("error %v, want %s", err, tc.want)
} }
@@ -2303,14 +2276,14 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
// wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff checks that with rule // wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff checks that with rule
// files off, and either setting turning off the Core Rule Set, the tag // files off, and either setting turning off the Core Rule Set, the tag
// exploitsignature in SWW_TAG_DENY stops the start. // exploitsignature in SWW_WAF_TAG_DENY stops the start.
func wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t *testing.T) { func wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t *testing.T) {
t.Helper() t.Helper()
for _, name := range []string{wafMode, wafAnomalyThreshold} { for _, name := range []string{wafMode, wafAnomalyThreshold} {
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
tagDeny: exploitTag, rulesEnabled: disabled, name: off, tagDeny: exploitTag, rulesEnabled: disabled, name: off,
}.lookupEnv) }.viper())
if err == nil || !strings.HasPrefix(err.Error(), tagDeny+": ") { if err == nil || !strings.HasPrefix(err.Error(), tagDeny+": ") {
t.Errorf("%s=off with rule files off gave the error %v, want one naming %s", t.Errorf("%s=off with rule files off gave the error %v, want one naming %s",
name, err, tagDeny) name, err, tagDeny)
@@ -2343,7 +2316,7 @@ func wantStartStopped(t *testing.T, invalid []struct{ name, value string }) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
if err == nil { if err == nil {
t.Fatalf("%s=%q was accepted", tc.name, tc.value) t.Fatalf("%s=%q was accepted", tc.name, tc.value)
} }
@@ -2368,7 +2341,7 @@ func TestHostOrTransferEncodingStopsTheStart(t *testing.T) {
t.Run(value, func(t *testing.T) { t.Run(value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{logRequestHeaders: value}.lookupEnv) _, err := config.FromEnvironment(environment{logRequestHeaders: value}.viper())
if err == nil || err.Error() != logRequestHeaders+": "+want { if err == nil || err.Error() != logRequestHeaders+": "+want {
t.Errorf("error %v, want %s: %s", err, logRequestHeaders, want) t.Errorf("error %v, want %s: %s", err, logRequestHeaders, want)
} }
@@ -2385,7 +2358,7 @@ func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) {
t.Run(name+"="+value, func(t *testing.T) { t.Run(name+"="+value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{name: value}.lookupEnv) _, err := config.FromEnvironment(environment{name: value}.viper())
want := name + ": is shorter than 32 characters" want := name + ": is shorter than 32 characters"
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -2441,7 +2414,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
bytesLimitPerMinute + "_FILE": writeFile(t, "lots\n"), bytesLimitPerMinute + "_FILE": writeFile(t, "lots\n"),
}.lookupEnv) }.viper())
want := bytesLimitPerMinute + `: "lots" is not a size such as 512K, 100M or 5G, or off` want := bytesLimitPerMinute + `: "lots" is not a size such as 512K, 100M or 5G, or off`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -2452,7 +2425,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
logRemoteURL: remoteURL, logRemoteURL: remoteURL,
instanceName: instance, instanceName: instance,
logRemoteAppName + "_FILE": writeFile(t, "my app\n"), logRemoteAppName + "_FILE": writeFile(t, "my app\n"),
}.lookupEnv) }.viper())
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` + want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
`characters without a space, such as blog` `characters without a space, such as blog`
@@ -2467,7 +2440,7 @@ func TestSettingAndItsFileBothSetStopsTheStart(t *testing.T) {
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
metricsToken: token, metricsToken: token,
metricsToken + "_FILE": writeFile(t, token), metricsToken + "_FILE": writeFile(t, token),
}.lookupEnv) }.viper())
want := metricsToken + ": is set, and so is " + metricsToken + want := metricsToken + ": is set, and so is " + metricsToken +
"_FILE; set only one of them" "_FILE; set only one of them"
@@ -2482,7 +2455,7 @@ func TestUnreadableSettingFileStopsTheStart(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
for _, path := range []string{filepath.Join(dir, "missing"), dir} { for _, path := range []string{filepath.Join(dir, "missing"), dir} {
_, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.lookupEnv) _, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.viper())
want := metricsToken + "_FILE: cannot be read: " want := metricsToken + "_FILE: cannot be read: "
if err == nil || !strings.HasPrefix(err.Error(), want) { if err == nil || !strings.HasPrefix(err.Error(), want) {
@@ -2590,6 +2563,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
reputationAction: "limit:25", reputationAction: "limit:25",
reputationCacheTTL: defaultReputationCacheTTL, reputationCacheTTL: defaultReputationCacheTTL,
reputationTimeout: "2s", reputationTimeout: "2s",
routeWatchURL: "",
crowdSecURL: "", crowdSecURL: "",
crowdSecKey: "", crowdSecKey: "",
banResponse: "403", banResponse: "403",
@@ -2617,7 +2591,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
wafExemptPaths: "", wafExemptPaths: "",
wafBodyLimit: off, wafBodyLimit: off,
exclusionsFile: "", exclusionsFile: "",
apps: "", // logged as null, which decodes as ""
trapPaths: "", trapPaths: "",
errorBurstThreshold: "30", errorBurstThreshold: "30",
logRemoteURL: "", logRemoteURL: "",
+16
View File
@@ -0,0 +1,16 @@
// Package configtest gives the tests of the packages that read settings the
// settings they choose.
package configtest
import "github.com/spf13/viper"
// Viper returns a viper holding each of settings, as the viper config.NewViper
// returns holds a process's environment.
func Viper(settings map[string]string) *viper.Viper {
v := viper.New()
for name, value := range settings {
v.Set(name, value)
}
return v
}
+37 -48
View File
@@ -8,38 +8,39 @@ import (
"net/netip" "net/netip"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/spf13/viper"
) )
// RProxy is the settings of `sww rproxy`. A size or time limit of zero // RProxy is the settings of `sww rproxy`. A size or time limit of zero
// is off. // is off.
type RProxy struct { type RProxy struct {
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR). // ListenAddr is where rproxy listens (SWW_RPROXY_LISTEN_ADDR).
ListenAddr string ListenAddr string
// UpstreamURL is the app (SWRP_UPSTREAM_URL). // UpstreamURL is the app (SWW_RPROXY_UPSTREAM_URL).
UpstreamURL *url.URL UpstreamURL *url.URL
// TrustedProxies are the netblocks whose X-Forwarded-For is believed // TrustedProxies are the netblocks whose X-Forwarded-For is believed
// (SWRP_TRUSTED_PROXIES). // (SWW_RPROXY_TRUSTED_PROXIES).
TrustedProxies []netip.Prefix TrustedProxies []netip.Prefix
// WAFBackend is discriminator's URL (SWRP_WAF_BACKEND). // WAFBackend is discriminator's URL (SWW_RPROXY_WAF_BACKEND).
WAFBackend *url.URL WAFBackend *url.URL
// Timeout is how long rproxy waits for discriminator's answer to a question, // Timeout is how long rproxy waits for discriminator's answer to a question,
// and to a report (SWRP_TIMEOUT_MS). It cannot be off. // and to a report (SWW_RPROXY_TIMEOUT_MS). It cannot be off.
Timeout time.Duration Timeout time.Duration
// DefaultDisposition is what is done with a request when discriminator cannot be // DefaultDisposition is what is done with a request when discriminator cannot be
// connected to, does not answer at all, or does not answer within Timeout: // connected to, does not answer at all, or does not answer within Timeout:
// allow or deny (SWRP_DEFAULT_DISPOSITION). // allow or deny (SWW_RPROXY_DEFAULT_DISPOSITION).
DefaultDisposition string DefaultDisposition string
// MaxRequestSize is the largest request body (SWRP_MAX_REQUEST_SIZE), and // MaxRequestSize is the largest request body (SWW_RPROXY_MAX_REQUEST_SIZE), and
// MaxResponseSize the largest response body (SWRP_MAX_RESPONSE_SIZE). // MaxResponseSize the largest response body (SWW_RPROXY_MAX_RESPONSE_SIZE).
MaxRequestSize int64 MaxRequestSize int64
MaxResponseSize int64 MaxResponseSize int64
// MaxRequestTime bounds sending the whole request: the visitor's request // MaxRequestTime bounds sending the whole request: the visitor's request
// line and headers, and then, from their end, its body on its way to the // line and headers, and then, from their end, its body on its way to the
// app (SWRP_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from // app (SWW_RPROXY_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from
// the end of the request to its last byte reaching the visitor // the end of the request to its last byte reaching the visitor
// (SWRP_MAX_RESPONSE_TIME_MS). // (SWW_RPROXY_MAX_RESPONSE_TIME_MS).
MaxRequestTime time.Duration MaxRequestTime time.Duration
MaxResponseTime time.Duration MaxResponseTime time.Duration
@@ -58,24 +59,26 @@ var (
"is not a whole number of milliseconds above zero, such as 60000, or off") "is not a whole number of milliseconds above zero, such as 60000, or off")
) )
// RProxyFromEnvironment reads rproxy's settings with lookupEnv, normally // RProxyFromEnvironment reads rproxy's settings through v, normally the viper
// os.LookupEnv. A setting that is not set takes its default, and // NewViper returns, bound to the process's environment. A setting that is not
// SWRP_WAF_BACKEND, which has none, must be set. A setting that is set but // set takes its default, and SWW_RPROXY_WAF_BACKEND, which has none, must be
// invalid is an error that names it. Since rproxy never touches disk, its // set. A setting may instead be given as a file: the variable named by the
// settings have no _FILE form. // setting's name with _FILE added names the file, which is read now. A setting
func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, error) { // that is set but invalid is an error that names it.
env := &environment{lookupEnv: withoutFileForms(lookupEnv)} func RProxyFromEnvironment(v *viper.Viper) (*RProxy, error) {
env := &environment{v: v}
cfg := &RProxy{ cfg := &RProxy{
ListenAddr: env.address("SWRP_LISTEN_ADDR"), ListenAddr: env.address("SWW_RPROXY_LISTEN_ADDR"),
UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL), UpstreamURL: env.appURL("SWW_RPROXY_UPSTREAM_URL", defaultUpstreamURL),
TrustedProxies: env.netblocks("SWRP_TRUSTED_PROXIES", privateRanges), TrustedProxies: env.netblocks("SWW_RPROXY_TRUSTED_PROXIES", privateRanges),
WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"), WAFBackend: env.wafBackend("SWW_RPROXY_WAF_BACKEND"),
Timeout: env.milliseconds("SWRP_TIMEOUT_MS", "2000"), Timeout: env.milliseconds("SWW_RPROXY_TIMEOUT_MS", "2000"),
DefaultDisposition: env.disposition("SWRP_DEFAULT_DISPOSITION", "allow"), DefaultDisposition: env.disposition("SWW_RPROXY_DEFAULT_DISPOSITION", "allow"),
MaxRequestSize: env.size("SWRP_MAX_REQUEST_SIZE", "100M"), MaxRequestSize: env.size("SWW_RPROXY_MAX_REQUEST_SIZE", "100M"),
MaxResponseSize: env.size("SWRP_MAX_RESPONSE_SIZE", "1G"), MaxResponseSize: env.size("SWW_RPROXY_MAX_RESPONSE_SIZE", "1G"),
MaxRequestTime: env.millisecondsOrOff("SWRP_MAX_REQUEST_TIME_MS", "60000"), MaxRequestTime: env.millisecondsOrOff("SWW_RPROXY_MAX_REQUEST_TIME_MS", "60000"),
MaxResponseTime: env.millisecondsOrOff("SWRP_MAX_RESPONSE_TIME_MS", "1800000"), MaxResponseTime: env.millisecondsOrOff(
"SWW_RPROXY_MAX_RESPONSE_TIME_MS", "1800000"),
} }
if env.err != nil { if env.err != nil {
@@ -87,15 +90,15 @@ func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, erro
return cfg, nil return cfg, nil
} }
// RProxyListenAddrAndUpstreamURL reads only SWRP_LISTEN_ADDR and // RProxyListenAddrAndUpstreamURL reads only SWW_RPROXY_LISTEN_ADDR and
// SWRP_UPSTREAM_URL. The health check in rproxy's container needs no other // SWW_RPROXY_UPSTREAM_URL. The health check in rproxy's container needs no other
// setting, so it reads no other. // setting, so it reads no other.
func RProxyListenAddrAndUpstreamURL( func RProxyListenAddrAndUpstreamURL(
lookupEnv func(string) (string, bool), v *viper.Viper,
) (string, *url.URL, error) { ) (string, *url.URL, error) {
env := &environment{lookupEnv: withoutFileForms(lookupEnv)} env := &environment{v: v}
listenAddr := env.address("SWRP_LISTEN_ADDR") listenAddr := env.address("SWW_RPROXY_LISTEN_ADDR")
upstreamURL := env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL) upstreamURL := env.appURL("SWW_RPROXY_UPSTREAM_URL", defaultUpstreamURL)
if env.err != nil { if env.err != nil {
return "", nil, env.err return "", nil, env.err
@@ -104,20 +107,6 @@ func RProxyListenAddrAndUpstreamURL(
return listenAddr, upstreamURL, nil return listenAddr, upstreamURL, nil
} }
// withoutFileForms returns lookupEnv finding no setting named with _FILE, since
// rproxy never touches disk.
func withoutFileForms(
lookupEnv func(string) (string, bool),
) func(string) (string, bool) {
return func(name string) (string, bool) {
if strings.HasSuffix(name, "_FILE") {
return "", false
}
return lookupEnv(name)
}
}
// LogValue makes an RProxy log as each setting's name with the value it was // LogValue makes an RProxy log as each setting's name with the value it was
// given, or its default. // given, or its default.
func (c *RProxy) LogValue() slog.Value { func (c *RProxy) LogValue() slog.Value {
+36 -29
View File
@@ -10,20 +10,21 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
) )
// rproxy's settings, by name, and their values in these tests. // rproxy's settings, by name, and their values in these tests.
const ( const (
wafBackend = "SWRP_WAF_BACKEND" wafBackend = "SWW_RPROXY_WAF_BACKEND"
rproxyTimeout = "SWRP_TIMEOUT_MS" rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
rproxyDefault = "SWRP_DEFAULT_DISPOSITION" rproxyDefault = "SWW_RPROXY_DEFAULT_DISPOSITION"
rproxyListen = "SWRP_LISTEN_ADDR" rproxyListen = "SWW_RPROXY_LISTEN_ADDR"
rproxyUpstream = "SWRP_UPSTREAM_URL" rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL"
rproxyTrusted = "SWRP_TRUSTED_PROXIES" rproxyTrusted = "SWW_RPROXY_TRUSTED_PROXIES"
rproxyMaxRequest = "SWRP_MAX_REQUEST_SIZE" rproxyMaxRequest = "SWW_RPROXY_MAX_REQUEST_SIZE"
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE" rproxyMaxResponse = "SWW_RPROXY_MAX_RESPONSE_SIZE"
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS" rproxyRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS" rproxyAnswerTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
backendURL = "http://discriminator:8080" backendURL = "http://discriminator:8080"
rproxyDefaultAddr = ":8080" rproxyDefaultAddr = ":8080"
rproxyDefaultApp = "http://127.0.0.1:8081" rproxyDefaultApp = "http://127.0.0.1:8081"
@@ -32,11 +33,7 @@ const (
// rproxyConfig reads rproxy's settings from env. // rproxyConfig reads rproxy's settings from env.
func rproxyConfig(env map[string]string) (*config.RProxy, error) { func rproxyConfig(env map[string]string) (*config.RProxy, error) {
return config.RProxyFromEnvironment(func(name string) (string, bool) { return config.RProxyFromEnvironment(configtest.Viper(env))
value, ok := env[name]
return value, ok
})
} }
func TestRProxyDefaults(t *testing.T) { func TestRProxyDefaults(t *testing.T) {
@@ -177,31 +174,41 @@ func TestRProxyNeedsItsBackend(t *testing.T) {
} }
} }
func TestRProxyReadsNoFile(t *testing.T) { func TestRProxySettingReadFromFile(t *testing.T) {
t.Parallel() t.Parallel()
path := filepath.Join(t.TempDir(), "backend") path := filepath.Join(t.TempDir(), "backend")
err := os.WriteFile(path, []byte("http://from-a-file:8080\n"), 0o600) err := os.WriteFile(path, []byte(backendURL+"\n"), 0o600)
if err != nil { if err != nil {
t.Fatalf("write: %v", err) t.Fatalf("write: %v", err)
} }
_, err = rproxyConfig(map[string]string{wafBackend + "_FILE": path}) cfg, err := rproxyConfig(map[string]string{wafBackend + "_FILE": path})
if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") {
t.Errorf("error %v, want %s still required", err, wafBackend)
}
cfg, err := rproxyConfig(map[string]string{
wafBackend: backendURL,
rproxyTimeout + "_FILE": "/nonexistent/timeout",
})
if err != nil { if err != nil {
t.Fatalf("settings: %v, with the file left unread", err) t.Fatalf("settings: %v", err)
} }
if cfg.Timeout != 2*time.Second { if cfg.WAFBackend.String() != backendURL {
t.Errorf("timeout %v, want the default", cfg.Timeout) t.Errorf("backend %v, want %s", cfg.WAFBackend, backendURL)
}
}
func TestRProxyUnreadableSettingFileStopsTheStart(t *testing.T) {
t.Parallel()
dir := t.TempDir()
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
_, err := rproxyConfig(map[string]string{
wafBackend: backendURL,
rproxyTimeout + "_FILE": path,
})
want := rproxyTimeout + "_FILE: cannot be read: "
if err == nil || !strings.HasPrefix(err.Error(), want) {
t.Errorf("%s: error %v, want one starting %s", path, err, want)
}
} }
} }
+16
View File
@@ -0,0 +1,16 @@
package config
import "github.com/spf13/viper"
// NewViper returns the viper that reads the settings: each setting's
// environment variable, bound to its own name. Every setting of discriminator
// and rproxy is read through it, and none is read with os.Getenv or
// os.LookupEnv outside it. An environment variable that is set but empty is
// set, as one that holds a value is, which viper does not take for granted.
func NewViper() *viper.Viper {
v := viper.New()
v.AutomaticEnv()
v.AllowEmptyEnv(true)
return v
}
+34
View File
@@ -0,0 +1,34 @@
package config_test
import (
"testing"
"sneak.berlin/go/smallwebwaf/internal/config"
)
// These tests set real environment variables, so none runs in parallel.
func TestTokenSetEmptyInTheEnvironmentStopsTheStart(t *testing.T) {
t.Setenv(adminToken, "")
_, err := config.FromEnvironment(config.NewViper())
want := adminToken + ": is shorter than 32 characters"
if err == nil || err.Error() != want {
t.Fatalf("error %v, want %s", err, want)
}
}
func TestListSetEmptyInTheEnvironmentReplacesItsDefault(t *testing.T) {
t.Setenv(wafBackend, backendURL)
t.Setenv(rproxyTrusted, "")
cfg, err := config.RProxyFromEnvironment(config.NewViper())
if err != nil {
t.Fatalf("settings: %v", err)
}
if len(cfg.TrustedProxies) != 0 {
t.Errorf("trusted proxies %v, want none", cfg.TrustedProxies)
}
}
@@ -1,23 +1,21 @@
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the // Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
// method, the URL with its query and the headers of a request, and on its // method, the URL with its query and the headers of a request, and on its
// body while SWW_WAF_BODY_LIMIT is set, with the six changes sww // body while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, with the eight changes sww
// makes to it, the fixes for the apps SWW_APPS names, and the exclusions in // makes to it and the exclusions in SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, as
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings" // "Attack detection" under "Settings" in SPEC.md describes them. It reads no
// in SPEC.md describes them. It reads no response. // response.
// //
// sww writes only to its state directory, so Coraza is built with // sww writes only to its state directory, so Coraza is built with
// its no_fs_access tag, as the Dockerfile and script/build build it: of a // its no_fs_access tag, as the Dockerfile and script/build build it: of a
// file in a multipart body, Coraza then counts the bytes instead of // file in a multipart body, Coraza then counts the bytes instead of
// writing them to the system's temporary directory. // writing them to the system's temporary directory.
package waf package coreruleset
import ( import (
_ "embed" _ "embed"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"net"
"net/http" "net/http"
"net/netip" "net/netip"
"os" "os"
@@ -34,16 +32,16 @@ import (
// directives are the Core Rule Set as sww runs it, with the // directives are the Core Rule Set as sww runs it, with the
// paranoia level for %d, bodyDirectives for the first %s while // paranoia level for %d, bodyDirectives for the first %s while
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and the // SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, the exclusions file for the second, and
// directives of every app in apps for the third. // giteaExclusions for the third.
// Each rule sww adds has an id from 900000 to 900999, the ids the // Each rule sww adds has an id from 900000 to 900999, the ids the
// Core Rule Set keeps for the rules that set it up, which // Core Rule Set keeps for the rules that set it up, which
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no // SWW_WAF_CORE_RULE_SET_DISABLED_RULES and the exclusions file refuse, so that no
// setting switches one off. Coraza joins a line ending in \ to the next, // setting switches one off. Coraza joins a line ending in \ to the next,
// without the spaces at the start of the next. // without the spaces at the start of the next.
const directives = ` const directives = `
# The engine only detects. sww compares the request's anomaly # The engine only detects. sww compares the request's anomaly
# score with SWW_WAF_ANOMALY_THRESHOLD itself, in block and detect mode # score with SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD itself, in block and detect mode
# alike. It reads no body, unless bodyDirectives switch that on. # alike. It reads no body, unless bodyDirectives switch that on.
SecRuleEngine DetectionOnly SecRuleEngine DetectionOnly
SecRequestBodyAccess Off SecRequestBodyAccess Off
@@ -83,28 +81,44 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
# before the rules of phase 1 too. # before the rules of phase 1 too.
%s %s
# The apps' fixes come before the rules for the same reason. # The eighth change comes before the rules for the same reason.
%s %s
# The fifth: only the rules for requests are loaded, and no response is # The sixth: only the rules for requests are loaded, and no response is
# inspected. # inspected.
Include @owasp_crs/REQUEST-*.conf Include @owasp_crs/REQUEST-*.conf
# The third: redirect_uri is not checked for a URL naming an IP address or # The third: redirect_uri is not checked for a URL naming an IP address or
# localhost. Coraza matches a parameter name here without regard to case. # localhost. Coraza matches a parameter name here, and in the fourth,
# ARGS holds the fields of a form data or multipart body Coraza reads as # without regard to case. ARGS holds the fields of a form data or multipart
# well as the query parameters, so a field of this name is left out too. # body Coraza reads as well as the query parameters, so a field of one of
# these names is left out too.
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri" SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri" SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
# The fourth: Referer is not checked for a Unix command without arguments, # The fourth: the query parameters in which gitea sends names within a
# for Java starting a process, or for a Java class name. 944130 refused # repository or its own records, or a page of its own site, are not
# every page opened from the page of java.lang.String.md. # checked against the lists of system files, shell paths and command
# names. Coraza takes one rule id per directive.
SecRuleUpdateTargetById 930120 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
SecRuleUpdateTargetById 932160 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
# The fifth, for Referer: it is not checked for a Unix command without
# arguments, for Java starting a process, or for a Java class name. 944130
# refused every page opened from the page of java.lang.String.md. The
# cookies are left out in Inspect.
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
# The sixth: a container image push that reuses a layer names the image # The seventh: a container image push that reuses a layer names the image
# it takes the layer from in the query parameter from, which is not # it takes the layer from in the query parameter from, which is not
# checked for command names. 932260 refused the push of a layer from # checked for command names. 932260 refused the push of a layer from
# owner/python3-app. # owner/python3-app.
@@ -154,55 +168,33 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'" severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
` `
// app is an app whose ordinary requests the Core Rule Set would refuse, // giteaExclusions are the eighth change: the fields of gitea's own
// with the fixes that let them pass. // requests that skip the rules that refused them there, as Coraza
type app struct { // directives.
// directives are Coraza exclusions, loaded before the Core Rule Set's
// rules. Their first rule skips the rest unless Inspect sets the
// transaction variable app_ and the app's name, and they end with the
// marker it skips to.
directives string
// cookies are the cookies the Core Rule Set reads a request without.
cookies []string
}
//go:embed gitea.conf
var giteaDirectives string
// apps are the apps whose fixes sww has, by the name SWW_APPS gives each.
// //
//nolint:gochecknoglobals // a constant cannot be a map //go:embed gitea.conf
var apps = map[string]app{ var giteaExclusions string
"gitea": {
directives: giteaDirectives,
cookies: []string{"gitea_flash", "redirect_to"},
},
}
// AppNames returns the names of the apps whose fixes sww has, in // cookiesNotRead are the cookies the Core Rule Set reads a request
// alphabetical order. // without, the rest of the fifth change.
func AppNames() []string { //
return slices.Sorted(maps.Keys(apps)) //nolint:gochecknoglobals // a constant cannot be a list
} var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
// ParanoiaLevel is SWW_WAF_PARANOIA_LEVEL, from 1 to 4. // ParanoiaLevel is SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL, from 1 to 4.
ParanoiaLevel int ParanoiaLevel int
// DisabledRules are the ids of the rules switched off // DisabledRules are the ids of the rules switched off
// (SWW_WAF_DISABLED_RULES). // (SWW_WAF_CORE_RULE_SET_DISABLED_RULES).
DisabledRules []int DisabledRules []int
// BodyLimit is the most of a request body the Core Rule Set reads // BodyLimit is the most of a request body the Core Rule Set reads
// (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none. // (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none.
BodyLimit int64 BodyLimit int64
// ExclusionsFile is the path of a file of exclusions loaded before the // ExclusionsFile is the path of a file of exclusions loaded before the
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while // Core Rule Set's rules (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
// there is none. // there is none.
ExclusionsFile string ExclusionsFile string
// Apps are the names of the apps each hostname serves, by hostname in
// lowercase (SWW_APPS). A request gets the fixes of its hostname's
// apps only, or of every app while Apps is nil.
Apps map[string][]string
} }
// The ids of sww's own rules, which an exclusions file may not // The ids of sww's own rules, which an exclusions file may not
@@ -226,7 +218,7 @@ var errSwwRuleID = errors.New("an id of sww's own rules")
// SecRuleRemoveById. The Core Rule Set's rules load after the file, so // SecRuleRemoveById. The Core Rule Set's rules load after the file, so
// the directive finds none of them. // the directive finds none of them.
var errChangesNoRule = errors.New("the Core Rule Set's rules load after the file; " + var errChangesNoRule = errors.New("the Core Rule Set's rules load after the file; " +
"switch a rule off with SWW_WAF_DISABLED_RULES, or on some requests " + "switch a rule off with SWW_WAF_CORE_RULE_SET_DISABLED_RULES, or on some requests " +
"with a ctl exclusion") "with a ctl exclusion")
// directivesIn returns the directives in text as Coraza reads them: it // directivesIn returns the directives in text as Coraza reads them: it
@@ -292,17 +284,15 @@ func checkExclusions(exclusions string) error {
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe // CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
// for concurrent use. // for concurrent use.
type CoreRuleSet struct { type CoreRuleSet struct {
waf coraza.WAF engine coraza.WAF
bodyLimit int64 bodyLimit int64
apps map[string][]string
} }
// New returns the Core Rule Set with the six changes and the apps' fixes, // New returns the Core Rule Set with the eight changes, at params'
// at params' paranoia level, without the rules it switches off, reading // paranoia level, without the rules it switches off, reading request
// request bodies up to params' limit, and with params' exclusions file // bodies up to params' limit, and with params' exclusions file loaded
// loaded before the Core Rule Set's rules. An exclusions file is an error // before the Core Rule Set's rules. An exclusions file is an error if it
// if it cannot be read or does not parse, or if checkExclusions refuses // cannot be read or does not parse, or if checkExclusions refuses it.
// it.
func New(params Params) (*CoreRuleSet, error) { func New(params Params) (*CoreRuleSet, error) {
body := "" body := ""
if params.BodyLimit > 0 { if params.BodyLimit > 0 {
@@ -325,13 +315,8 @@ func New(params Params) (*CoreRuleSet, error) {
} }
} }
var fixes strings.Builder
for _, name := range AppNames() {
fixes.WriteString(apps[name].directives + "\n")
}
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions, text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
fixes.String()) giteaExclusions)
if len(params.DisabledRules) > 0 { if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules)) ids := make([]string, len(params.DisabledRules))
@@ -342,14 +327,14 @@ func New(params Params) (*CoreRuleSet, error) {
text += "SecRuleRemoveById " + strings.Join(ids, " ") + "\n" text += "SecRuleRemoveById " + strings.Join(ids, " ") + "\n"
} }
waf, err := coraza.NewWAF(coraza.NewWAFConfig(). engine, err := coraza.NewWAF(coraza.NewWAFConfig().
WithRootFS(coreruleset.FS). WithRootFS(coreruleset.FS).
WithDirectives(text)) WithDirectives(text))
if err != nil { if err != nil {
return nil, fmt.Errorf("load the Core Rule Set: %w", err) return nil, fmt.Errorf("load the Core Rule Set: %w", err)
} }
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit, apps: params.Apps}, nil return &CoreRuleSet{engine: engine, bodyLimit: params.BodyLimit}, nil
} }
// Result is what the Core Rule Set found in a request. // Result is what the Core Rule Set found in a request.
@@ -361,37 +346,25 @@ type Result struct {
Score int Score int
} }
// Inspect runs the Core Rule Set on r, a request from client, with the // Inspect runs the Core Rule Set on r, a request from client: on its
// fixes of the apps appsFor names for r's hostname: on its method, its URL // method, its URL with the query, and its headers, the Cookie header
// with the query, and its headers, the Cookie header without those apps' // without the cookies in cookiesNotRead, and on body, r's body as the
// cookies, and on body, r's body as the caller has it, as readBody reads // caller has it, as readBody reads it. It returns what it found, what it
// it. It returns what it found, what it read of body, and the error that // read of body, and the error that ended the reading early, if one did.
// ended the reading early, if one did.
func (c *CoreRuleSet) Inspect( func (c *CoreRuleSet) Inspect(
r *http.Request, client netip.Addr, body io.Reader, r *http.Request, client netip.Addr, body io.Reader,
) (Result, []byte, error) { ) (Result, []byte, error) {
tx := c.waf.NewTransaction() tx := c.engine.NewTransaction()
// Closing would remove the files Coraza wrote, and it writes none. // Closing would remove the files Coraza wrote, and it writes none.
defer func() { _ = tx.Close() }() defer func() { _ = tx.Close() }()
// The transaction's variables are set only through Coraza's interface
// for plugins.
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
var cookiesNotRead []string
for _, name := range c.appsFor(r.Host) {
state.Variables().TX().Set("app_"+name, []string{"1"})
cookiesNotRead = append(cookiesNotRead, apps[name].cookies...)
}
tx.ProcessConnection(client.String(), 0, "", 0) tx.ProcessConnection(client.String(), 0, "", 0)
tx.ProcessURI(r.URL.String(), r.Method, r.Proto) tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
for name, values := range r.Header { for name, values := range r.Header {
for _, value := range values { for _, value := range values {
if name == "Cookie" { if name == "Cookie" {
value = withoutCookies(value, cookiesNotRead) value = withoutCookiesNotRead(value)
if value == "" { if value == "" {
continue // it held those cookies alone continue // it held those cookies alone
} }
@@ -431,7 +404,7 @@ func (c *CoreRuleSet) Inspect(
} }
// readBody reads body, the body of the request in tx, which has run on // readBody reads body, the body of the request in tx, which has run on
// the request's headers, while SWW_WAF_BODY_LIMIT is set and the body is // the request's headers, while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set and the body is
// of a kind the Core Rule Set reads: form data and multipart, of which it // of a kind the Core Rule Set reads: form data and multipart, of which it
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only // reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
// when they are no longer than that, since they cannot be read in part. // when they are no longer than that, since they cannot be read in part.
@@ -484,26 +457,9 @@ func score(tx types.Transaction) int {
return n return n
} }
// appsFor returns the names of the apps whose fixes a request for host, // withoutCookiesNotRead returns value, a Cookie header's, without the
// its Host header, gets: every app while SWW_APPS is unset, and otherwise // cookies in cookiesNotRead.
// those SWW_APPS lists for host's name, without the port and in func withoutCookiesNotRead(value string) string {
// lowercase.
func (c *CoreRuleSet) appsFor(host string) []string {
if c.apps == nil {
return AppNames()
}
hostname, _, err := net.SplitHostPort(host)
if err != nil {
hostname = host // it has no port
}
return c.apps[strings.ToLower(hostname)]
}
// withoutCookies returns value, a Cookie header's, without the cookies
// named in cookiesNotRead.
func withoutCookies(value string, cookiesNotRead []string) string {
var kept []string var kept []string
for cookie := range strings.SplitSeq(value, ";") { for cookie := range strings.SplitSeq(value, ";") {
@@ -1,4 +1,4 @@
package waf_test package coreruleset_test
import ( import (
"net/http" "net/http"
@@ -12,10 +12,10 @@ import (
"strings" "strings"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/waf" "sneak.berlin/go/smallwebwaf/internal/coreruleset"
) )
// defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off // defaultDisabledRules are the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off
// by default. // by default.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
@@ -23,10 +23,12 @@ var defaultDisabledRules = []int{920340, 920420, 920440, 920640, 930130, 930140}
// newCoreRuleSet returns the Core Rule Set at paranoia level level, with // newCoreRuleSet returns the Core Rule Set at paranoia level level, with
// the rules in disabled switched off. // the rules in disabled switched off.
func newCoreRuleSet(t *testing.T, level int, disabled ...int) *waf.CoreRuleSet { func newCoreRuleSet(t *testing.T, level int, disabled ...int) *coreruleset.CoreRuleSet {
t.Helper() t.Helper()
crs, err := waf.New(waf.Params{ParanoiaLevel: level, DisabledRules: disabled}) crs, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: level, DisabledRules: disabled,
})
if err != nil { if err != nil {
t.Fatalf("load the Core Rule Set: %v", err) t.Fatalf("load the Core Rule Set: %v", err)
} }
@@ -49,7 +51,7 @@ func get(target string, headers ...string) request {
// inspect returns what crs finds in r, sent to git.example by a browser, // inspect returns what crs finds in r, sent to git.example by a browser,
// whose Host, User-Agent and Accept r.headers may replace. // whose Host, User-Agent and Accept r.headers may replace.
func inspect(t *testing.T, crs *waf.CoreRuleSet, r request) waf.Result { func inspect(t *testing.T, crs *coreruleset.CoreRuleSet, r request) coreruleset.Result {
t.Helper() t.Helper()
result, _ := inspectBody(t, crs, r, "") result, _ := inspectBody(t, crs, r, "")
@@ -60,8 +62,8 @@ func inspect(t *testing.T, crs *waf.CoreRuleSet, r request) waf.Result {
// inspectBody is inspect for r with body, which is announced with its // inspectBody is inspect for r with body, which is announced with its
// Content-Length unless it is "", and returns what crs read of body too. // Content-Length unless it is "", and returns what crs read of body too.
func inspectBody( func inspectBody(
t *testing.T, crs *waf.CoreRuleSet, r request, body string, t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string,
) (waf.Result, string) { ) (coreruleset.Result, string) {
t.Helper() t.Helper()
req := httptest.NewRequestWithContext(t.Context(), r.method, req := httptest.NewRequestWithContext(t.Context(), r.method,
@@ -96,7 +98,9 @@ func inspectBody(
} }
// wantResult checks what crs finds in r. // wantResult checks what crs finds in r.
func wantResult(t *testing.T, crs *waf.CoreRuleSet, r request, want waf.Result) { func wantResult(
t *testing.T, crs *coreruleset.CoreRuleSet, r request, want coreruleset.Result,
) {
t.Helper() t.Helper()
if got := inspect(t, crs, r); !reflect.DeepEqual(got, want) { if got := inspect(t, crs, r); !reflect.DeepEqual(got, want) {
@@ -106,28 +110,29 @@ func wantResult(t *testing.T, crs *waf.CoreRuleSet, r request, want waf.Result)
// matched is the result of a request that the rules ids match, each of // matched is the result of a request that the rules ids match, each of
// them a critical one, which adds 5 to the score. // them a critical one, which adds 5 to the score.
func matched(ids ...int) waf.Result { func matched(ids ...int) coreruleset.Result {
const critical = 5 const critical = 5
return waf.Result{RuleIDs: ids, Score: critical * len(ids)} return coreruleset.Result{RuleIDs: ids, Score: critical * len(ids)}
} }
// atDefaults returns the Core Rule Set as sww runs it by default. // atDefaults returns the Core Rule Set as sww runs it by default.
func atDefaults(t *testing.T) *waf.CoreRuleSet { func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
t.Helper() t.Helper()
return newCoreRuleSet(t, 1, defaultDisabledRules...) return newCoreRuleSet(t, 1, defaultDisabledRules...)
} }
// wantChange checks that crs lets through passes, a gitea request one of // wantChange checks that crs lets through passes, a gitea request one of
// the six changes or gitea's fixes are for, and still finds result in // the eight changes is for, and still finds result in refused, a request
// refused, a request like it that they are not for. // like it that the change is not for.
func wantChange( func wantChange(
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result, t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
result coreruleset.Result,
) { ) {
t.Helper() t.Helper()
wantResult(t, crs, passes, waf.Result{}) wantResult(t, crs, passes, coreruleset.Result{})
wantResult(t, crs, refused, result) wantResult(t, crs, refused, result)
} }
@@ -162,12 +167,12 @@ func TestExpectAndContentEncodingAreAllowed(t *testing.T) {
wantResult(t, crs, wantResult(t, crs,
request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}}, request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}},
waf.Result{}) coreruleset.Result{})
wantResult(t, crs, wantResult(t, crs,
request{http.MethodPost, fetch, []string{ request{http.MethodPost, fetch, []string{
fetchType, length, "Content-Encoding: gzip", fetchType, length, "Content-Encoding: gzip",
}}, }},
waf.Result{}) coreruleset.Result{})
// Every other header on the Core Rule Set's list stays refused. // Every other header on the Core Rule Set's list stays refused.
for _, header := range []string{ for _, header := range []string{
@@ -196,7 +201,7 @@ func TestTransferEncodingIsRead(t *testing.T) {
"Content-Type: application/x-git-receive-pack-request", "Content-Type: application/x-git-receive-pack-request",
"Transfer-Encoding: chunked", "Transfer-Encoding: chunked",
}}, }},
waf.Result{}) coreruleset.Result{})
} }
func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) { func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) {
@@ -250,7 +255,7 @@ func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testi
for _, value := range []struct { for _, value := range []struct {
name string name string
// result is what a parameter that is not one of gitea's gets. // result is what a parameter that is not one of gitea's gets.
result waf.Result result coreruleset.Result
}{ }{
// A file on the list of system files. // A file on the list of system files.
{".gitignore", matched(930120)}, {".gitignore", matched(930120)},
@@ -271,7 +276,7 @@ func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testi
wantChange(t, crs, get("/?path=|cat%20/etc/passwd"), get("/?q=|cat%20/etc/passwd"), wantChange(t, crs, get("/?path=|cat%20/etc/passwd"), get("/?q=|cat%20/etc/passwd"),
matched(930120, 932160)) matched(930120, 932160))
wantResult(t, crs, get("/?path=../../etc/passwd"), wantResult(t, crs, get("/?path=../../etc/passwd"),
waf.Result{RuleIDs: []int{930100, 930110}, Score: 20}) coreruleset.Result{RuleIDs: []int{930100, 930110}, Score: 20})
wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100)) wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100))
} }
@@ -341,7 +346,7 @@ func TestEmptyHeaderIsRead(t *testing.T) {
// An empty User-Agent is a notice, which adds 2. // An empty User-Agent is a notice, which adds 2.
wantResult(t, atDefaults(t), get("/", "User-Agent: "), wantResult(t, atDefaults(t), get("/", "User-Agent: "),
waf.Result{RuleIDs: []int{920330}, Score: 2}) coreruleset.Result{RuleIDs: []int{920330}, Score: 2})
} }
func TestParanoiaLevel(t *testing.T) { func TestParanoiaLevel(t *testing.T) {
@@ -350,7 +355,7 @@ func TestParanoiaLevel(t *testing.T) {
// Accept-Charset is refused from paranoia level 2. // Accept-Charset is refused from paranoia level 2.
r := get("/", "Accept-Charset: utf-8") r := get("/", "Accept-Charset: utf-8")
wantResult(t, newCoreRuleSet(t, 1), r, waf.Result{}) wantResult(t, newCoreRuleSet(t, 1), r, coreruleset.Result{})
wantResult(t, newCoreRuleSet(t, 2), r, matched(920451)) wantResult(t, newCoreRuleSet(t, 2), r, matched(920451))
} }
@@ -362,8 +367,8 @@ func TestEachDisabledRuleIsSwitchedOff(t *testing.T) {
r := request{http.MethodTrace, "/", []string{"Host: 192.0.2.1"}} r := request{http.MethodTrace, "/", []string{"Host: 192.0.2.1"}}
wantResult(t, newCoreRuleSet(t, 1), r, wantResult(t, newCoreRuleSet(t, 1), r,
waf.Result{RuleIDs: []int{911100, 920350}, Score: 8}) coreruleset.Result{RuleIDs: []int{911100, 920350}, Score: 8})
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{}) wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, coreruleset.Result{})
} }
func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) { func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
@@ -381,100 +386,10 @@ func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
matched(930120, 932160)) matched(930120, 932160))
} }
// giteaFixed are gitea requests that only gitea's fixes let through, one
// for each kind of fix, each with what the Core Rule Set finds in it
// without them.
//
//nolint:gochecknoglobals // a constant cannot be a list
var giteaFixed = []struct {
request request
result waf.Result
}{
// A search of code, on the path of gitea's search.
{get("/explore/code?q=SELECT%20*%20FROM%20users%20WHERE"), matched(942100)},
// A file name, in a parameter gitea sends names in, on any path.
{get("/?path=.gitignore"), matched(930120)},
// A file name, in a cookie of gitea's.
{
get("/owner/repo", "Cookie: gitea_flash=success%3DFile%2Bpackage.json%2Bdeleted"),
matched(930120),
},
}
// The hostnames of the tests of SWW_APPS: the one that serves gitea, and
// one that serves another app.
const (
giteaHost = "git.example"
otherHost = "blog.example"
)
// withHost returns r sent to host.
func withHost(r request, host string) request {
return request{r.method, r.target, append([]string{"Host: " + host}, r.headers...)}
}
// servingApps returns the Core Rule Set at the defaults, with SWW_APPS
// giving apps.
func servingApps(t *testing.T, apps map[string][]string) *waf.CoreRuleSet {
t.Helper()
crs, err := waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, Apps: apps,
})
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
return crs
}
func TestAppsUnsetGivesEveryHostnameEveryAppsFixes(t *testing.T) {
t.Parallel()
crs := servingApps(t, nil)
for _, fixed := range giteaFixed {
for _, host := range []string{giteaHost, otherHost} {
wantResult(t, crs, withHost(fixed.request, host), waf.Result{})
}
}
}
func TestListedHostnameGetsItsAppsFixes(t *testing.T) {
t.Parallel()
crs := servingApps(t, map[string][]string{giteaHost: {"gitea"}})
// The hostname is matched without the port and without regard to case.
for _, host := range []string{giteaHost, "GIT.Example:8443"} {
for _, fixed := range giteaFixed {
wantResult(t, crs, withHost(fixed.request, host), waf.Result{})
}
}
}
func TestUnlistedHostnameGetsTheFullCheck(t *testing.T) {
t.Parallel()
for _, apps := range []map[string][]string{
{giteaHost: {"gitea"}},
// SWW_APPS set empty lists no hostname.
{},
} {
crs := servingApps(t, apps)
for _, host := range []string{otherHost, otherHost + ":8443", "git.example.org"} {
for _, fixed := range giteaFixed {
wantResult(t, crs, withHost(fixed.request, host), fixed.result)
}
}
}
}
// withExclusions returns the Core Rule Set at the defaults, with the // withExclusions returns the Core Rule Set at the defaults, with the
// exclusions file at path. // exclusions file at path.
func withExclusions(path string) (*waf.CoreRuleSet, error) { func withExclusions(path string) (*coreruleset.CoreRuleSet, error) {
return waf.New(waf.Params{ return coreruleset.New(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path, ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path,
}) })
} }
@@ -512,7 +427,7 @@ func TestExclusionsFileIsLoadedBeforeTheRules(t *testing.T) {
func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) { func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
t.Parallel() t.Parallel()
crs, err := waf.New(waf.Params{ crs, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: []int{920500}, ParanoiaLevel: 1, DisabledRules: []int{920500},
ExclusionsFile: exclusionsFile(t, backupFileExclusion), ExclusionsFile: exclusionsFile(t, backupFileExclusion),
}) })
@@ -520,7 +435,7 @@ func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
t.Fatalf("load the exclusions: %v", err) t.Fatalf("load the exclusions: %v", err)
} }
wantResult(t, crs, get("/notes.txt~"), waf.Result{}) wantResult(t, crs, get("/notes.txt~"), coreruleset.Result{})
} }
func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) { func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) {
@@ -615,7 +530,7 @@ func giteaRequests(t *testing.T) []request {
func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) { func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel() t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
crs := atDefaults(t) crs := atDefaults(t)
@@ -640,7 +555,7 @@ func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
} }
} }
// bodyLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies. // bodyLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies.
const bodyLimit = 8 << 10 const bodyLimit = 8 << 10
// The Content-Type headers of the kinds of body the Core Rule Set reads. // The Content-Type headers of the kinds of body the Core Rule Set reads.
@@ -656,10 +571,10 @@ const injection = "1' OR '1'='1"
// readingBodies returns the Core Rule Set as sww runs it by // readingBodies returns the Core Rule Set as sww runs it by
// default, but reading bodies up to bodyLimit. // default, but reading bodies up to bodyLimit.
func readingBodies(t *testing.T) *waf.CoreRuleSet { func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
t.Helper() t.Helper()
crs, err := waf.New(waf.Params{ crs, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit, ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
}) })
if err != nil { if err != nil {
@@ -693,7 +608,8 @@ func padded(head, tail string, n int) string {
// wantBody checks what crs finds in r with body, and that what it read of // wantBody checks what crs finds in r with body, and that what it read of
// body is read. // body is read.
func wantBody( func wantBody(
t *testing.T, crs *waf.CoreRuleSet, r request, body string, want waf.Result, t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string,
want coreruleset.Result,
read string, read string,
) { ) {
t.Helper() t.Helper()
@@ -717,7 +633,7 @@ func TestBodiesAreReadOnlyWhileBodyLimitIsSet(t *testing.T) {
{jsonBody, `{"q":"` + injection + `"}`}, {jsonBody, `{"q":"` + injection + `"}`},
{xmlBody, "<q>" + injection + "</q>"}, {xmlBody, "<q>" + injection + "</q>"},
} { } {
wantBody(t, off, post(tc.header), tc.body, waf.Result{}, "") wantBody(t, off, post(tc.header), tc.body, coreruleset.Result{}, "")
wantBody(t, on, post(tc.header), tc.body, matched(942100), tc.body) wantBody(t, on, post(tc.header), tc.body, matched(942100), tc.body)
} }
} }
@@ -740,7 +656,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
} { } {
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100), wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
tc.attackFirst[:bodyLimit+1]) tc.attackFirst[:bodyLimit+1])
wantBody(t, crs, post(tc.header), tc.attackLast, waf.Result{}, wantBody(t, crs, post(tc.header), tc.attackLast, coreruleset.Result{},
tc.attackLast[:bodyLimit+1]) tc.attackLast[:bodyLimit+1])
} }
@@ -750,7 +666,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit) wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit)
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1) pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
wantBody(t, crs, post(formData), pastLimit, waf.Result{}, pastLimit) wantBody(t, crs, post(formData), pastLimit, coreruleset.Result{}, pastLimit)
} }
func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) { func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
@@ -766,7 +682,7 @@ func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
wantBody(t, crs, post(tc.header), fits, matched(942100), fits) wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
larger := padded(tc.head, tc.tail, bodyLimit+1) larger := padded(tc.head, tc.tail, bodyLimit+1)
wantBody(t, crs, post(tc.header), larger, waf.Result{}, larger) wantBody(t, crs, post(tc.header), larger, coreruleset.Result{}, larger)
} }
} }
@@ -784,7 +700,7 @@ func TestOtherBodiesAreNotRead(t *testing.T) {
"Content-Type: text/plain", "Content-Type: text/plain",
"Content-Type: application/x-git-receive-pack-request", "Content-Type: application/x-git-receive-pack-request",
} { } {
wantBody(t, crs, post(header), body, waf.Result{}, "") wantBody(t, crs, post(header), body, coreruleset.Result{}, "")
} }
} }
@@ -812,8 +728,8 @@ func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(t *testing.
// It is allowed on a body of any other kind, and on every body while no // It is allowed on a body of any other kind, and on every body while no
// body is read. // body is read.
fetch := "Content-Type: application/x-git-upload-pack-request" fetch := "Content-Type: application/x-git-upload-pack-request"
wantBody(t, crs, post(fetch, gzip), "a", waf.Result{}, "") wantBody(t, crs, post(fetch, gzip), "a", coreruleset.Result{}, "")
wantBody(t, atDefaults(t), post(formData, gzip), "a", waf.Result{}, "") wantBody(t, atDefaults(t), post(formData, gzip), "a", coreruleset.Result{}, "")
} }
func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) { func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
@@ -824,18 +740,18 @@ func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
body string body string
want waf.Result want coreruleset.Result
}{ }{
{"path=.gitignore", waf.Result{}}, {"path=.gitignore", coreruleset.Result{}},
{"q=.gitignore", matched(930120)}, {"q=.gitignore", matched(930120)},
{"redirect_uri=" + local, waf.Result{}}, {"redirect_uri=" + local, coreruleset.Result{}},
{"next=" + local, matched(931100, 934110)}, {"next=" + local, matched(931100, 934110)},
} { } {
wantBody(t, crs, post(formData), tc.body, tc.want, tc.body) wantBody(t, crs, post(formData), tc.body, tc.want, tc.body)
} }
body := field("path", ".gitignore") + end body := field("path", ".gitignore") + end
wantBody(t, crs, post(multipart), body, waf.Result{}, body) wantBody(t, crs, post(multipart), body, coreruleset.Result{}, body)
body = field("q", ".gitignore") + end body = field("q", ".gitignore") + end
wantBody(t, crs, post(multipart), body, matched(930120), body) wantBody(t, crs, post(multipart), body, matched(930120), body)
@@ -959,7 +875,7 @@ func TestCorazaWritesNoFile(t *testing.T) {
func TestBodyLimitOf1GLoads(t *testing.T) { func TestBodyLimitOf1GLoads(t *testing.T) {
t.Parallel() t.Parallel()
_, err := waf.New(waf.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30}) _, err := coreruleset.New(coreruleset.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30})
if err != nil { if err != nil {
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err) t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
} }
@@ -1,51 +1,8 @@
# gitea's fixes: some fields skip the rules that refused ordinary gitea requests. # The eighth change: on gitea's paths, some fields skip the rules that refused
# They are the fields where gitea sends a search, a file name or a branch name. # ordinary gitea requests there. They are the fields where gitea sends a search,
# Each exclusion runs with the request, before the rules it switches off, those # a file name or a branch name. Each exclusion runs with the request, before the
# of phase 1 included, so this file is loaded before the Core Rule Set's rules. # rules it switches off, those of phase 1 included, so this file is loaded before
# Each comment names a request the rule refused. # the Core Rule Set's rules. Each comment names a request the rule refused.
# A request whose hostname SWW_APPS does not list gitea for skips every rule
# below. Inspect sets tx.app_gitea on the others.
SecRule &TX:app_gitea "@eq 0" \
"id:900590,phase:1,pass,nolog,skipAfter:END_GITEA"
# The query parameters in which gitea sends names within a repository or its
# own records, or a page of its own site, are not checked against the lists of
# system files, shell paths and command names, on any path:
# 930120: .gitignore
# 932160: bin/docker-entrypoint
# 932260: docker-build
SecAction "id:900591,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:path,\
ctl:ruleRemoveTargetById=930120;ARGS:files,\
ctl:ruleRemoveTargetById=930120;ARGS:skip-to,\
ctl:ruleRemoveTargetById=930120;ARGS:sub_path,\
ctl:ruleRemoveTargetById=930120;ARGS:ref,\
ctl:ruleRemoveTargetById=930120;ARGS:sha,\
ctl:ruleRemoveTargetById=930120;ARGS:branch,\
ctl:ruleRemoveTargetById=930120;ARGS:workflow,\
ctl:ruleRemoveTargetById=930120;ARGS:artifactName,\
ctl:ruleRemoveTargetById=930120;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=932160;ARGS:path,\
ctl:ruleRemoveTargetById=932160;ARGS:files,\
ctl:ruleRemoveTargetById=932160;ARGS:skip-to,\
ctl:ruleRemoveTargetById=932160;ARGS:sub_path,\
ctl:ruleRemoveTargetById=932160;ARGS:ref,\
ctl:ruleRemoveTargetById=932160;ARGS:sha,\
ctl:ruleRemoveTargetById=932160;ARGS:branch,\
ctl:ruleRemoveTargetById=932160;ARGS:workflow,\
ctl:ruleRemoveTargetById=932160;ARGS:artifactName,\
ctl:ruleRemoveTargetById=932160;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=932260;ARGS:path,\
ctl:ruleRemoveTargetById=932260;ARGS:files,\
ctl:ruleRemoveTargetById=932260;ARGS:skip-to,\
ctl:ruleRemoveTargetById=932260;ARGS:sub_path,\
ctl:ruleRemoveTargetById=932260;ARGS:ref,\
ctl:ruleRemoveTargetById=932260;ARGS:sha,\
ctl:ruleRemoveTargetById=932260;ARGS:branch,\
ctl:ruleRemoveTargetById=932260;ARGS:workflow,\
ctl:ruleRemoveTargetById=932260;ARGS:artifactName,\
ctl:ruleRemoveTargetById=932260;ARGS:redirect_to"
# gitea's searches of code, of issues and pull requests, and of commits send the # gitea's searches of code, of issues and pull requests, and of commits send the
# text searched for in q, which skips these rules there: # text searched for in q, which skips these rules there:
@@ -198,8 +155,8 @@ SecRule REQUEST_FILENAME \
# 942140: information_schema.sql # 942140: information_schema.sql
# 944110: java/lang/Runtime.java # 944110: java/lang/Runtime.java
# 944130: java.lang.String.md # 944130: java.lang.String.md
# Each request skips the rules that refused it. Rule 900591 already has path and # Each request skips the rules that refused it. The fourth change already has
# redirect_to skip 930120, 932160 and 932260 on every path. # path and redirect_to skip 930120, 932160 and 932260 on every path.
# A file's commit graph, in file. # A file's commit graph, in file.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \ SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
@@ -324,5 +281,3 @@ SecRule REQUEST_FILENAME \
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \ "@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
"id:900640,phase:1,pass,nolog,\ "id:900640,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range" ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
SecMarker END_GITEA
@@ -1,6 +1,6 @@
# Ordinary gitea 1.27 requests that the Core Rule Set must let through at the # Ordinary gitea 1.27 requests that the Core Rule Set must let through at the
# defaults. Each line is a path and query, and may end with one header after a # defaults. Each line is a path and query, and may end with one header after a
# space. The tests of internal/waf inspect each as a GET. The Core Rule Set reads # space. The tests of internal/coreruleset inspect each as a GET. The Core Rule Set reads
# the query and the headers the same way for any method. # the query and the headers the same way for any method.
# Searches with ordinary words and symbols. # Searches with ordinary words and symbols.
+7 -7
View File
@@ -1,7 +1,7 @@
package discriminator package discriminator
// The heuristic abuseipdb. It reads SWW_ABUSEIPDB_KEY, // The heuristic abuseipdb. It reads SWW_WAF_ABUSEIPDB_KEY,
// SWW_ABUSEIPDB_MIN_SCORE and SWW_REPUTATION_ACTION. // SWW_WAF_ABUSEIPDB_MIN_SCORE and SWW_WAF_REPUTATION_ACTION.
import ( import (
"context" "context"
@@ -16,12 +16,12 @@ import (
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with // abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
// the score, when its score of the client is a hit, and refuses the request // the score, when its score of the client is a hit, and refuses the request
// when SWW_REPUTATION_ACTION is deny, as dnsbl does for a zone. While // when SWW_WAF_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
// SWW_ABUSEIPDB_KEY is unset it does nothing. A client without a score is // SWW_WAF_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
// checked in the background, by the request's address, if its history counts // checked in the background, by the request's address, if its history counts
// an offence, and the request does not wait for the answer. The score is then // an offence, and the request does not wait for the answer. The score is then
// used for each address of the client. Its figures are the score and // used for each address of the client. Its figures are the score and
// SWW_ABUSEIPDB_MIN_SCORE. // SWW_WAF_ABUSEIPDB_MIN_SCORE.
type abuseIPDB struct{} type abuseIPDB struct{}
func (abuseIPDB) name() string { return "abuseipdb" } func (abuseIPDB) name() string { return "abuseipdb" }
@@ -43,14 +43,14 @@ func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
rq.abuseIPDBHit = true rq.abuseIPDBHit = true
rq.addTag(config.AbuseIPDBTag) rq.addTag(config.AbuseIPDBTag)
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score}, rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE") "scored by AbuseIPDB at or over SWW_WAF_ABUSEIPDB_MIN_SCORE")
if rq.h.config.ReputationAction != deny { if rq.h.config.ReputationAction != deny {
return nil return nil
} }
return &verdict{ return &verdict{
action: requestlog.ActionDenied, action: requestlog.ActionClientDenied,
figures: map[string]any{ figures: map[string]any{
figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore, figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore,
}, },
+3 -3
View File
@@ -42,7 +42,7 @@ var (
// answerHealth answers a request for the health endpoint. // answerHealth answers a request for the health endpoint.
func (rq *request) answerHealth() { func (rq *request) answerHealth() {
rq.line.Action = requestlog.ActionAdmin rq.line.Action = requestlog.ActionOwnEndpoint
// Set here rather than left to Go's server, which would set it only // Set here rather than left to Go's server, which would set it only
// after the log line has taken the response's headers. // after the log line has taken the response's headers.
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8") rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
@@ -51,11 +51,11 @@ func (rq *request) answerHealth() {
// answerAdmin answers a request for one of discriminator's own endpoints. Each // answerAdmin answers a request for one of discriminator's own endpoints. Each
// needs a token, sent as Authorization: Bearer <token>: the metrics // needs a token, sent as Authorization: Bearer <token>: the metrics
// SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is // SWW_WAF_METRICS_TOKEN, the others SWW_WAF_ADMIN_TOKEN. A request without it is
// refused with 401. An endpoint whose token is unset answers 404, as any other // refused with 401. An endpoint whose token is unset answers 404, as any other
// path does. // path does.
func (rq *request) answerAdmin() { func (rq *request) answerAdmin() {
rq.line.Action = requestlog.ActionAdmin rq.line.Action = requestlog.ActionOwnEndpoint
token, answer := rq.endpoint() token, answer := rq.endpoint()
+15 -15
View File
@@ -17,8 +17,8 @@ import (
) )
const ( const (
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
// adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer // adminSecret is the SWW_WAF_ADMIN_TOKEN the tests set, and adminBearer
// how a request carries it. // how a request carries it.
adminSecret = "fedcba9876543210fedcba9876543210" adminSecret = "fedcba9876543210fedcba9876543210"
adminBearer = "Bearer " + adminSecret adminBearer = "Bearer " + adminSecret
@@ -40,7 +40,7 @@ func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
// An empty token does not match the unset one either. // An empty token does not match the unset one either.
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} { for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
for _, e := range adminEndpoints() { for _, e := range adminEndpoints() {
s.adminRequest(authorization, e.method, e.path, e.body, http.StatusNotFound) s.ownEndpointRequest(authorization, e.method, e.path, e.body, http.StatusNotFound)
} }
} }
@@ -67,7 +67,7 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
for _, authorization := range []string{ for _, authorization := range []string{
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret, "", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
} { } {
got := s.adminRequest(authorization, e.method, e.path, e.body, got := s.ownEndpointRequest(authorization, e.method, e.path, e.body,
http.StatusUnauthorized) http.StatusUnauthorized)
if got.header.Get("WWW-Authenticate") != "Bearer" { if got.header.Get("WWW-Authenticate") != "Bearer" {
t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer", t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer",
@@ -121,7 +121,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient, wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
http.StatusOK), want) http.StatusOK), want)
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned) line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
if line.BanExpires != requestlog.FormatTime(expires) { if line.BanExpires != requestlog.FormatTime(expires) {
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires) t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
} }
@@ -276,7 +276,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
// limit of two a minute, and bans it. // limit of two a minute, and bans it.
s.get(otherClient, http.StatusOK, requestlog.ActionForward) s.get(otherClient, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusOK, requestlog.ActionForward) s.get(otherClient, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited) s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
// Asked about by its address in IPv6 form too. // Asked about by its address in IPv6 form too.
for _, addr := range []string{otherClient, "::ffff:" + otherClient} { for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
@@ -329,7 +329,7 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
}) })
for _, e := range adminEndpoints() { for _, e := range adminEndpoints() {
s.adminRequest("", e.method, e.path, e.body, http.StatusUnauthorized) s.ownEndpointRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
} }
if held := server.Ledger.Snapshot(); len(held) != 0 { if held := server.Ledger.Snapshot(); len(held) != 0 {
@@ -337,12 +337,12 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
} }
} }
// adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens. // adminEndpoint is a request to an endpoint SWW_WAF_ADMIN_TOKEN opens.
type adminEndpoint struct { type adminEndpoint struct {
method, path, body string method, path, body string
} }
// adminEndpoints returns a request to each endpoint SWW_ADMIN_TOKEN // adminEndpoints returns a request to each endpoint SWW_WAF_ADMIN_TOKEN
// opens: listing the bans, banning otherClient for an hour, lifting the // opens: listing the bans, banning otherClient for an hour, lifting the
// bans on otherClient, and asking about otherClient. // bans on otherClient, and asking about otherClient.
func adminEndpoints() []adminEndpoint { func adminEndpoints() []adminEndpoint {
@@ -356,16 +356,16 @@ func adminEndpoints() []adminEndpoint {
// admin sends a request with method for path, with body, to discriminator's own // admin sends a request with method for path, with body, to discriminator's own
// address, with the admin token, and checks that it is answered with status, its // address, with the admin token, and checks that it is answered with status, its
// log line's action admin. It returns the answer. // log line's action own_endpoint. It returns the answer.
func (s *sender) admin(method, path, body string, status int) answer { func (s *sender) admin(method, path, body string, status int) answer {
s.t.Helper() s.t.Helper()
return s.adminRequest(adminBearer, method, path, body, status) return s.ownEndpointRequest(adminBearer, method, path, body, status)
} }
// adminRequest is admin with authorization as the request's Authorization header // ownEndpointRequest is admin with authorization as the request's Authorization
// unless it is "". // header unless it is "".
func (s *sender) adminRequest( func (s *sender) ownEndpointRequest(
authorization, method, path, body string, status int, authorization, method, path, body string, status int,
) answer { ) answer {
s.t.Helper() s.t.Helper()
@@ -380,7 +380,7 @@ func (s *sender) adminRequest(
line := s.out.endedLines(s.t, s.sent+1)[s.sent] line := s.out.endedLines(s.t, s.sent+1)[s.sent]
s.sent++ s.sent++
wantLine(s.t, line, status, requestlog.ActionAdmin) wantLine(s.t, line, status, requestlog.ActionOwnEndpoint)
return got return got
} }
+11 -9
View File
@@ -15,8 +15,8 @@ import (
) )
const ( const (
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL" alertWebhookURL = "SWW_WAF_ALERT_WEBHOOK_URL"
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR" alertMaxPerHour = "SWW_WAF_ALERT_MAX_PER_HOUR"
// alertInstance is the instance every alert of these tests gives. // alertInstance is the instance every alert of these tests gives.
alertInstance = "fsn1app1/blog" alertInstance = "fsn1app1/blog"
) )
@@ -31,14 +31,14 @@ func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
start := clk.Now() start := clk.Now()
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited) s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
netblock := netip.MustParsePrefix("203.0.113.0/24") netblock := netip.MustParsePrefix("203.0.113.0/24")
ban := server.Ledger.Bans(netblock)[0] ban := server.Ledger.Bans(netblock)[0]
// A request refused under the ban raises no other alert. // A request refused under the ban raises no other alert.
clk.advance(time.Minute) clk.advance(time.Minute)
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{ wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{
Netblock: netblock, Cause: bans.CauseLimit, Netblock: netblock, Cause: bans.CauseLimit,
@@ -62,22 +62,24 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
// The probe bans the client for seven days, and its next request makes // The probe bans the client for seven days, and its next request makes
// the ban permanent. The request after that changes nothing. // the ban permanent. The request after that changes nothing.
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
attackBan := server.Ledger.Bans(netblock)[0] attackBan := server.Ledger.Bans(netblock)[0]
clk.advance(time.Minute) clk.advance(time.Minute)
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
permanentBan := server.Ledger.Bans(netblock)[0] permanentBan := server.Ledger.Bans(netblock)[0]
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
// Another client's probe after its first ban has run out without a // Another client's probe after its first ban has run out without a
// request makes a permanent ban at once. // request makes a permanent ban at once.
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned) s.request(otherClient, "/.env", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
clk.advance(7 * 24 * time.Hour) clk.advance(7 * 24 * time.Hour)
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned) s.request(otherClient, "/.env", http.StatusForbidden,
requestlog.ActionPermanentlyBanned)
otherBans := server.Ledger.Bans(other) otherBans := server.Ledger.Bans(other)
+13 -13
View File
@@ -21,11 +21,11 @@ import (
// The anomaly thresholds: the prefix of a scope followed by the end of a // The anomaly thresholds: the prefix of a scope followed by the end of a
// count. // count.
const ( const (
anomalyClient = "SWW_ANOMALY_CLIENT_" anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
anomalyNet = "SWW_ANOMALY_NET_" anomalyNet = "SWW_WAF_ANOMALY_NET_"
anomalyASN = "SWW_ANOMALY_ASN_" anomalyASN = "SWW_WAF_ANOMALY_ASN_"
anomalyTotal = "SWW_ANOMALY_TOTAL_" anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
anomalyWatch = "SWW_WATCH_" anomalyWatch = "SWW_WAF_WATCH_"
requestsPerMinute = "REQUESTS_PER_MINUTE" requestsPerMinute = "REQUESTS_PER_MINUTE"
requestsPerHour = "REQUESTS_PER_HOUR" requestsPerHour = "REQUESTS_PER_HOUR"
@@ -35,9 +35,9 @@ const (
// The other anomaly settings. // The other anomaly settings.
const ( const (
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX" anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX" anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
watchNets = "SWW_WATCH_NETS" watchNets = "SWW_WAF_WATCH_NETS"
) )
const ( const (
@@ -73,7 +73,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) {
{anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}}, {anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}},
{anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil}, {anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil},
{ {
anomalyWatch, anomaly.ScopeWatch, netip.MustParsePrefix(clientsNet), anomalyWatch, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
"the named netblock office, " + clientsNet, map[string]any{"name": "office"}, "the named netblock office, " + clientsNet, map[string]any{"name": "office"},
}, },
} { } {
@@ -164,9 +164,9 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
allowed = "192.0.2.7" // in SWW_ALLOW_NETS allowed = "192.0.2.7" // in SWW_WAF_ALLOW_NETS
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS exempt = "192.0.2.10" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
denied = "192.0.2.20" // in SWW_DENY_NETS denied = "192.0.2.20" // in SWW_WAF_DENY_NETS
) )
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{ s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
@@ -187,7 +187,7 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
{allowed, "/", http.StatusOK, requestlog.ActionForward}, {allowed, "/", http.StatusOK, requestlog.ActionForward},
{exempt, "/", http.StatusOK, requestlog.ActionForward}, {exempt, "/", http.StatusOK, requestlog.ActionForward},
{client, "/static/app.js", http.StatusOK, requestlog.ActionForward}, {client, "/static/app.js", http.StatusOK, requestlog.ActionForward},
{denied, "/", http.StatusForbidden, requestlog.ActionDenied}, {denied, "/", http.StatusForbidden, requestlog.ActionClientDenied},
} { } {
for range 3 { for range 3 {
s.request(sent.from, sent.path, sent.status, sent.action) s.request(sent.from, sent.path, sent.status, sent.action)
+35 -29
View File
@@ -23,10 +23,10 @@ import (
) )
const ( const (
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time // answerTimeLimit is the default of rproxy's SWW_RPROXY_TIMEOUT_MS, the time
// discriminator has to answer. // discriminator has to answer.
answerTimeLimit = 2 * time.Second answerTimeLimit = 2 * time.Second
// defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT. // defaultLookupWait is the default of SWW_WAF_LOOKUP_TIMEOUT.
defaultLookupWait = 800 * time.Millisecond defaultLookupWait = 800 * time.Millisecond
// The header limits SPEC.md gives. // The header limits SPEC.md gives.
cookieLimit = 50 cookieLimit = 50
@@ -36,8 +36,8 @@ const (
// tooLarge is how discriminator refuses a request over the header limits. // tooLarge is how discriminator refuses a request over the header limits.
tooLarge = http.StatusRequestHeaderFieldsTooLarge tooLarge = http.StatusRequestHeaderFieldsTooLarge
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in // silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
// that unansweredGeoJSURL names too. // that silentGeoJSURL names too.
silentAbuseIPDBURL = "unanswered://abuseipdb/api/v2/check" silentAbuseIPDBURL = "silent://abuseipdb/api/v2/check"
// wholeLimit is the percentage of a rate limit that lowers it not at all. // wholeLimit is the percentage of a rate limit that lowers it not at all.
wholeLimit = 100 wholeLimit = 100
// clientsDecision is the CrowdSec decision list holding client alone. // clientsDecision is the CrowdSec decision list holding client alone.
@@ -84,10 +84,10 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny) wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
line := s.out.requestLine(t) line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge) wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
if line.DurationWAF != nil { if line.DurationCoreRuleSetMS != nil {
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF) t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationCoreRuleSetMS)
} }
} }
@@ -157,7 +157,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
if tc.refused { if tc.refused {
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny) wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge) wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
return return
} }
@@ -165,7 +165,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow) wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
wantLine(t, line, http.StatusOK, requestlog.ActionForward) wantLine(t, line, http.StatusOK, requestlog.ActionForward)
if line.DurationWAF == nil { if line.DurationCoreRuleSetMS == nil {
t.Error("the Core Rule Set did not read a request within the limits") t.Error("the Core Rule Set did not read a request within the limits")
} }
}) })
@@ -178,7 +178,7 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
for _, env := range []map[string]string{ for _, env := range []map[string]string{
{wafMode: off}, {wafMode: off},
{wafMode: "detect"}, {wafMode: "detect"},
{wafMode: block, "SWW_WAF_EXEMPT_PATHS": "/"}, {wafMode: block, "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS": "/"},
} { } {
t.Run(fmt.Sprint(env), func(t *testing.T) { t.Run(fmt.Sprint(env), func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -219,9 +219,10 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow) wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
line := s.out.requestLine(t) line := s.out.requestLine(t)
if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil { if line.WouldAction != requestlog.ActionRequestHeaderTooLarge ||
t.Errorf("would_action %q and duration_waf %v, want %q and none", line.DurationCoreRuleSetMS != nil {
line.WouldAction, line.DurationWAF, requestlog.ActionTooLarge) t.Errorf("would_action %q and duration_core_rule_set_ms %v, want %q and none",
line.WouldAction, line.DurationCoreRuleSetMS, requestlog.ActionRequestHeaderTooLarge)
} }
} }
@@ -240,7 +241,7 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny) wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny)
lines := s.out.requestLines(t, 2) lines := s.out.requestLines(t, 2)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited) wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
} }
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) { func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
@@ -249,8 +250,8 @@ func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name, path, action string name, path, action string
}{ }{
{"a trap path", "/wp-login.php", requestlog.ActionBanned}, {"a trap path", "/wp-login.php", requestlog.ActionTemporarilyBanned},
{"a block rule", "/blocked", requestlog.ActionRuleBlocked}, {"a block rule", "/blocked", requestlog.ActionBlockedByBlockRule},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -280,10 +281,10 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny) wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
line := s.out.requestLine(t) line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge) wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
if line.LimitPercent != nil { if line.RateLimitPercent != nil {
t.Errorf("limit percentage %d, want none", *line.LimitPercent) t.Errorf("limit percentage %d, want none", *line.RateLimitPercent)
} }
// The client is under no ban, and GeoJS places it in a country. // The client is under no ban, and GeoJS places it in a country.
@@ -304,7 +305,8 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny) wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited) wantLine(t, s.out.requestLine(t), http.StatusForbidden,
requestlog.ActionClientIsRateLimited)
} }
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) { func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
@@ -338,7 +340,7 @@ func newDiscriminatorInBubble(
settings := map[string]string{tagDeny: "country=KP"} settings := map[string]string{tagDeny: "country=KP"}
maps.Copy(settings, env) maps.Copy(settings, env)
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, settings) server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now, settings)
return server, out return server, out
} }
@@ -411,34 +413,38 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
action string action string
}{ }{
{ {
name: "SWW_DENY_NETS", env: map[string]string{denyNets: client}, name: "SWW_WAF_DENY_NETS", env: map[string]string{denyNets: client},
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied, prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionClientDenied,
}, },
{ {
name: "a ban", name: "a ban",
prepare: banClient, status: forbidden, action: requestlog.ActionBanned, prepare: banClient, status: forbidden, action: requestlog.ActionTemporarilyBanned,
}, },
{ {
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"}, name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited, prepare: countClient, status: forbidden,
action: requestlog.ActionClientIsRateLimited,
}, },
{ {
name: "the rate limits, with a biased threshold", name: "the rate limits, with a biased threshold",
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"}, env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited, prepare: countClient, status: forbidden,
action: requestlog.ActionClientIsRateLimited,
}, },
{ {
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL}, name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
prepare: listClient, status: forbidden, action: requestlog.ActionDenied, prepare: listClient, status: forbidden, action: requestlog.ActionClientDenied,
}, },
{ {
name: "the CrowdSec decision list", name: "the CrowdSec decision list",
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey}, env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
prepare: decideOnClient, status: forbidden, action: requestlog.ActionBanned, prepare: decideOnClient, status: forbidden,
action: requestlog.ActionTemporarilyBanned,
}, },
{ {
name: "the header limits", cookie: cookies(cookieLimit + 1), name: "the header limits", cookie: cookies(cookieLimit + 1),
prepare: leaveAsItIs, status: tooLarge, action: requestlog.ActionTooLarge, prepare: leaveAsItIs, status: tooLarge,
action: requestlog.ActionRequestHeaderTooLarge,
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
+32 -18
View File
@@ -1,7 +1,7 @@
package discriminator package discriminator
// The heuristic active_ban, and the code that makes bans, which several // The heuristic active_ban, and the code that makes bans, which several
// heuristics share. active_ban reads SWW_MODE; the bans are the ban ledger's. // heuristics share. active_ban reads SWW_WAF_MODE; the bans are the ban ledger's.
import ( import (
"context" "context"
@@ -15,7 +15,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// banResponse is a refusal answered with SWW_BAN_RESPONSE, and logged // banResponse is a refusal answered with SWW_WAF_BAN_RESPONSE, and logged
// with action. // with action.
func (rq *request) banResponse(action string) *refusal { func (rq *request) banResponse(action string) *refusal {
return &refusal{status: rq.h.config.BanResponse, action: action} return &refusal{status: rq.h.config.BanResponse, action: action}
@@ -50,13 +50,23 @@ func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
} }
return &verdict{ return &verdict{
action: requestlog.ActionBanned, action: banAction(ban.Permanent()),
figures: map[string]any{ figures: map[string]any{
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires, figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
}, },
} }
} }
// banAction is the action of a request refused under a ban or for making one:
// permanent says the ban never runs out.
func banAction(permanent bool) string {
if permanent {
return requestlog.ActionPermanentlyBanned
}
return requestlog.ActionTemporarilyBanned
}
// limitFigures are the figures of a heuristic that broke hit, a limit: its // limitFigures are the figures of a heuristic that broke hit, a limit: its
// window, the count and the limit. // window, the count and the limit.
func limitFigures(hit ratelimit.Hit) map[string]any { func limitFigures(hit ratelimit.Hit) map[string]any {
@@ -67,7 +77,7 @@ func limitFigures(hit ratelimit.Hit) map[string]any {
// countedBytes returns the request's bytes, once it has ended, as the // countedBytes returns the request's bytes, once it has ended, as the
// byte limits and the anomaly thresholds count them: the response's body // byte limits and the anomaly thresholds count them: the response's body
// bytes, the request's, or both, as SWW_BYTES_COUNT says. // bytes, the request's, or both, as SWW_WAF_BYTES_COUNT says.
func (rq *request) countedBytes() int64 { func (rq *request) countedBytes() int64 {
response, request := rq.out.bytes, rq.requestBytes response, request := rq.out.bytes, rq.requestBytes
@@ -84,7 +94,7 @@ func (rq *request) countedBytes() int64 {
// banForLimit bans the client's netblock at now for a broken limit, the // banForLimit bans the client's netblock at now for a broken limit, the
// one hit names, notes the offence for the log line and counts the hit in // one hit names, notes the offence for the log line and counts the hit in
// the metrics. status is what the client was sent, or is sent: // the metrics. status is what the client was sent, or is sent:
// SWW_BAN_RESPONSE for a request over a rate limit, what the report says for // SWW_WAF_BAN_RESPONSE for a request over a rate limit, what the report says for
// one whose bytes broke a byte limit, the refusal for one that broke the // one whose bytes broke a byte limit, the refusal for one that broke the
// error burst. The ban's notes give the client's limit percentage for a // error burst. The ban's notes give the client's limit percentage for a
// rate limit or a byte limit; the error burst is not lowered. The ban sets // rate limit or a byte limit; the error burst is not lowered. The ban sets
@@ -96,7 +106,7 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
case ratelimit.KindBytes: case ratelimit.KindBytes:
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
case ratelimit.KindRefusals: case ratelimit.KindRefusals:
rq.line.LimitHit = requestlog.LimitHitErrorBurst rq.line.LimitHit = requestlog.LimitHitMinuteRefusals
default: default:
rq.line.LimitHit = hit.Window rq.line.LimitHit = hit.Window
} }
@@ -124,9 +134,9 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
switch hit.Kind { switch hit.Kind {
case ratelimit.KindRequests: case ratelimit.KindRequests:
notes.LimitPercent, notes.LimitPercentSetting = rq.limitPercent.logged() notes.LimitPercent, notes.LimitPercentSetting = rq.rateLimitPercent.logged()
case ratelimit.KindBytes: case ratelimit.KindBytes:
notes.LimitPercent, notes.LimitPercentSetting = rq.bytesPercent.logged() notes.LimitPercent, notes.LimitPercentSetting = rq.bytesLimitPercent.logged()
} }
if rq.h.config.Observe { if rq.h.config.Observe {
@@ -151,11 +161,11 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
// attack, which notes name: the ban rule that matched, or the trap path // attack, which notes name: the ban rule that matched, or the trap path
// asked for. It fills in the rest of the notes. In observe mode it makes // asked for. It fills in the rest of the notes. In observe mode it makes
// no ban, and raises the alert for the ban it would have made, if that // no ban, and raises the alert for the ban it would have made, if that
// alert would be sent. // alert would be sent. It returns the action for that ban.
func (rq *request) banForAttack(now time.Time, notes bans.Notes) { func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
netblock := rq.h.netblock(rq.client) netblock := rq.h.netblock(rq.client)
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) { if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
return return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseAttack))
} }
notes.ASN = rq.line.ASN notes.ASN = rq.line.ASN
@@ -171,7 +181,7 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
rq.alertBan(ban) rq.alertBan(ban)
} }
return return banAction(ban.Permanent())
} }
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes) ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
@@ -180,16 +190,18 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
if made { if made {
rq.alertBan(ban) rq.alertBan(ban)
} }
return banAction(ban.Permanent())
} }
// banForCrowdSec bans the client's netblock at now until decision, // banForCrowdSec bans the client's netblock at now until decision,
// CrowdSec's decision on the client, ends. In observe mode it makes no // CrowdSec's decision on the client, ends. In observe mode it makes no
// ban, and raises the alert for the ban it would have made, if that alert // ban, and raises the alert for the ban it would have made, if that alert
// would be sent. // would be sent. It returns the action for that ban.
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) { func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) string {
netblock := rq.h.netblock(rq.client) netblock := rq.h.netblock(rq.client)
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) { if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
return return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseCrowdSec))
} }
notes := bans.Notes{ notes := bans.Notes{
@@ -208,7 +220,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
rq.alertBan(ban) rq.alertBan(ban)
} }
return return banAction(ban.Permanent())
} }
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires, ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
@@ -218,12 +230,14 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
if made { if made {
rq.alertBan(ban) rq.alertBan(ban)
} }
return banAction(ban.Permanent())
} }
// wouldAlertBan reports whether the alert for a ban on netblock for cause // wouldAlertBan reports whether the alert for a ban on netblock for cause
// made at now would be sent. In observe mode the ban the request would // made at now would be sent. In observe mode the ban the request would
// have made is worked out only then, at most once per // have made is worked out only then, at most once per
// SWW_ALERT_COOLDOWN and never with no webhook set: its notes count the // SWW_WAF_ALERT_COOLDOWN and never with no webhook set: its notes count the
// netblock's requests, which can mean going through every client. // netblock's requests, which can mean going through every client.
func (rq *request) wouldAlertBan( func (rq *request) wouldAlertBan(
netblock netip.Prefix, now time.Time, cause string, netblock netip.Prefix, now time.Time, cause string,
@@ -289,7 +303,7 @@ func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
} }
// netblock is the netblock a ban on client covers: its IPv4 address, // netblock is the netblock a ban on client covers: its IPv4 address,
// widened to SWW_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup // widened to SWW_WAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
// counts it in. // counts it in.
func (h *handler) netblock(client netip.Addr) netip.Prefix { func (h *handler) netblock(client netip.Addr) netip.Prefix {
addr := client.Unmap() addr := client.Unmap()
+38 -36
View File
@@ -26,7 +26,7 @@ const (
userAgent = "ban-test/1.0" userAgent = "ban-test/1.0"
// permanent is the log line's ban_expires for a permanent ban. // permanent is the log line's ban_expires for a permanent ban.
permanent = "permanent" permanent = "permanent"
// banResponseClose is the SWW_BAN_RESPONSE that closes the connection. // banResponseClose is the SWW_WAF_BAN_RESPONSE that closes the connection.
banResponseClose = "close" banResponseClose = "close"
) )
@@ -40,7 +40,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
// client for an hour, the default. // client for an hour, the default.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited) line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit || if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
line.BanExpires != expires { line.BanExpires != expires {
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+ t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
@@ -51,7 +51,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
// Every request while the ban lasts is refused. // Every request while the ban lasts is refused.
clk.advance(time.Hour - time.Second) clk.advance(time.Hour - time.Second)
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned) line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" { if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" {
t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+ t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+
"want %s and neither of the others", line.BanExpires, line.Offence, "want %s and neither of the others", line.BanExpires, line.Offence,
@@ -78,7 +78,9 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
breakLimit := func() string { breakLimit := func() string {
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
return s.get(client, http.StatusForbidden, requestlog.ActionRateLimited).BanExpires line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
return line.BanExpires
} }
wantExpires := func(got string, length time.Duration) { wantExpires := func(got string, length time.Duration) {
t.Helper() t.Helper()
@@ -89,19 +91,19 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
} }
} }
// A first ban lasts SWW_LIMIT_BAN_DURATION; one within // A first ban lasts SWW_WAF_LIMIT_BAN_DURATION; one within
// SWW_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long. // SWW_WAF_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
wantExpires(breakLimit(), 10*time.Minute) wantExpires(breakLimit(), 10*time.Minute)
clk.advance(10*time.Minute + time.Hour) clk.advance(10*time.Minute + time.Hour)
wantExpires(breakLimit(), 30*time.Minute) wantExpires(breakLimit(), 30*time.Minute)
// Later than that, SWW_LIMIT_BAN_DURATION again. // Later than that, SWW_WAF_LIMIT_BAN_DURATION again.
clk.advance(30*time.Minute + time.Hour + time.Second) clk.advance(30*time.Minute + time.Hour + time.Second)
wantExpires(breakLimit(), 10*time.Minute) wantExpires(breakLimit(), 10*time.Minute)
clk.advance(10 * time.Minute) clk.advance(10 * time.Minute)
wantExpires(breakLimit(), 30*time.Minute) wantExpires(breakLimit(), 30*time.Minute)
// 90 minutes would be longer than SWW_MAX_BAN_DURATION: the ban is // 90 minutes would be longer than SWW_WAF_MAX_BAN_DURATION: the ban is
// permanent. // permanent.
clk.advance(30 * time.Minute) clk.advance(30 * time.Minute)
@@ -111,7 +113,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
} }
clk.advance(365 * 24 * time.Hour) clk.advance(365 * 24 * time.Hour)
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
} }
func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) { func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
@@ -123,10 +125,10 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
// client for an hour. // client for an hour.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited) s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
for range 3 { for range 3 {
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
// Later the same day the client has its whole allowance again: the // Later the same day the client has its whole allowance again: the
@@ -135,7 +137,7 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
clk.advance(time.Hour) clk.advance(time.Hour)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited) s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32")) banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
if len(banned) != 2 || banned[0].Notes.Refused != 3 { if len(banned) != 2 || banned[0].Notes.Refused != 3 {
@@ -148,8 +150,8 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
// In the IPv4 cases, client breaks the limit; these two are next to it. // In the IPv4 cases, client breaks the limit; these two are next to it.
const ( const (
allowed = "203.0.113.60" // in SWW_ALLOW_NETS allowed = "203.0.113.60" // in SWW_WAF_ALLOW_NETS
exempt = "203.0.113.50" // in SWW_RATE_LIMIT_EXEMPT_NETS exempt = "203.0.113.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
) )
for _, tc := range []struct { for _, tc := range []struct {
@@ -164,7 +166,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
nil, []string{otherClient, exempt}, nil, []string{otherClient, exempt},
}, },
{ {
"the IPv4 netblock SWW_BAN_SCOPE_V4_PREFIX sets", "the IPv4 netblock SWW_WAF_BAN_SCOPE_V4_PREFIX sets",
map[string]string{banScopeV4Prefix: "24"}, client, map[string]string{banScopeV4Prefix: "24"}, client,
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed}, []string{otherClient, exempt}, []string{"203.0.112.9", allowed},
}, },
@@ -173,7 +175,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"}, []string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
}, },
{ {
"the IPv6 netblock SWW_IPV6_GROUP_PREFIX sets", "the IPv6 netblock SWW_WAF_IPV6_GROUP_PREFIX sets",
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1", map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"}, []string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
}, },
@@ -190,10 +192,10 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
s, _, _ := startWithClock(t, "", env) s, _, _ := startWithClock(t, "", env)
s.get(tc.breaker, http.StatusOK, requestlog.ActionForward) s.get(tc.breaker, http.StatusOK, requestlog.ActionForward)
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionRateLimited) s.get(tc.breaker, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
for _, sent := range tc.refused { for _, sent := range tc.refused {
s.get(sent, http.StatusForbidden, requestlog.ActionBanned) s.get(sent, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
for _, sent := range tc.let { for _, sent := range tc.let {
@@ -216,9 +218,9 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
// fromDE's ban covers otherClient, which is refused unasked about. // fromDE's ban covers otherClient, which is refused unasked about.
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned) line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
if line.Country != "" { if line.Country != "" {
t.Errorf("log line has country %q, want none", line.Country) t.Errorf("log line has country %q, want none", line.Country)
} }
@@ -237,9 +239,9 @@ func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
expires := requestlog.FormatTime(clk.Now().Add(time.Hour)) expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited) s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned) line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantHeuristic(t, line, "active_ban", wantHeuristic(t, line, "active_ban",
`{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`) `{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`)
} }
@@ -247,10 +249,10 @@ func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) { func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
t.Parallel() t.Parallel()
const denied = "192.0.2.50" // in SWW_DENY_NETS const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
for _, tc := range []struct { for _, tc := range []struct {
setting string // "" leaves SWW_BAN_RESPONSE at its default setting string // "" leaves SWW_WAF_BAN_RESPONSE at its default
status int // 0 is the connection closed without an answer status int // 0 is the connection closed without an answer
}{ }{
{"", http.StatusForbidden}, {"", http.StatusForbidden},
@@ -275,11 +277,11 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
s, _, _ := startWithClock(t, geojsURL, env) s, _, _ := startWithClock(t, geojsURL, env)
s.get(denied, tc.status, requestlog.ActionDenied) s.get(denied, tc.status, requestlog.ActionClientDenied)
s.get(fromKP, tc.status, requestlog.ActionDenied) s.get(fromKP, tc.status, requestlog.ActionClientDenied)
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, tc.status, requestlog.ActionRateLimited) s.get(fromDE, tc.status, requestlog.ActionClientIsRateLimited)
s.get(fromDE, tc.status, requestlog.ActionBanned) s.get(fromDE, tc.status, requestlog.ActionTemporarilyBanned)
}) })
} }
} }
@@ -297,9 +299,9 @@ func TestBanNotes(t *testing.T) {
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.request(fromDE, "/repo/commits?page=2", s.request(fromDE, "/repo/commits?page=2",
http.StatusForbidden, requestlog.ActionRateLimited) http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned) s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned) s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
netblock := netip.MustParsePrefix(fromDE + "/32") netblock := netip.MustParsePrefix(fromDE + "/32")
want := bans.Ban{ want := bans.Ban{
@@ -342,7 +344,7 @@ func TestBanNotes(t *testing.T) {
// The next ban counts this one among the earlier. // The next ban counts this one among the earlier.
clk.advance(time.Hour) clk.advance(time.Hour)
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
got = ledger.Bans(netblock) got = ledger.Bans(netblock)
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
@@ -360,12 +362,12 @@ func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) {
// One ban is held, so otherClient's ban drops client's. // One ban is held, so otherClient's ban drops client's.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited) s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(otherClient, http.StatusOK, requestlog.ActionForward) s.get(otherClient, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited) s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned) s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
// clock is the time a test sets, by which sww counts requests and // clock is the time a test sets, by which sww counts requests and
@@ -413,7 +415,7 @@ func startWithClock(
// sender sends requests to rproxy one after another, each on a connection of its // sender sends requests to rproxy one after another, each on a connection of its
// own, and checks each one's answer and log line. discriminatorAddr is where // own, and checks each one's answer and log line. discriminatorAddr is where
// discriminator's own endpoints are, which adminRequest asks. Every request // discriminator's own endpoints are, which ownEndpointRequest asks. Every request
// discriminator gets must come through the sender, since the log lines are // discriminator gets must come through the sender, since the log lines are
// matched to them in order. // matched to them in order.
type sender struct { type sender struct {
+18 -17
View File
@@ -17,9 +17,9 @@ type percentage struct {
} }
// biasedThresholdsSet reports whether a biased threshold can lower a // biasedThresholdsSet reports whether a biased threshold can lower a
// client's limits by its lookup: SWW_ASN_BYTES_PERCENT or // client's limits by its lookup: SWW_WAF_ASN_BYTES_PERCENT or
// SWW_COUNTRY_BYTES_PERCENT is not empty, SWW_UNKNOWN_LIMIT_PERCENT is // SWW_WAF_COUNTRY_BYTES_PERCENT is not empty, SWW_WAF_UNKNOWN_LIMIT_PERCENT is
// below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is // below 100, or SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
// then needed before its request goes on. // then needed before its request goes on.
func biasedThresholdsSet(cfg *config.Config) bool { func biasedThresholdsSet(cfg *config.Config) bool {
return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 || return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
@@ -31,15 +31,15 @@ func biasedThresholdsSet(cfg *config.Config) bool {
// as looked up, each "" when unknown, and the blocklists, DNSBL zones and // as looked up, each "" when unknown, and the blocklists, DNSBL zones and
// AbuseIPDB that list it. Each is the lowest of those the settings give // AbuseIPDB that list it. Each is the lowest of those the settings give
// it, the first of them in the order below when several are lowest: the // it, the first of them in the order below when several are lowest: the
// percentage SWW_TAG_LIMIT_PERCENT gives its asn= tag, the one the file // percentage SWW_WAF_TAG_LIMIT_PERCENT gives its asn= tag, the one the file
// SWW_ASN_LIMIT_PERCENT_URL names gives its AS number, the one // SWW_WAF_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
// SWW_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its // SWW_WAF_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
// other tags, for a client without a country, unless its request skipped // other tags, for a client without a country, unless its request skipped
// the wait for GeoJS, SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist // the wait for GeoJS, SWW_WAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
// lists, the percentage of SWW_BLOCKLIST_ACTION while it is limit, and // lists, the percentage of SWW_WAF_BLOCKLIST_ACTION while it is limit, and
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is // for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
// a hit, the percentage of SWW_REPUTATION_ACTION while it is limit. For // a hit, the percentage of SWW_WAF_REPUTATION_ACTION while it is limit. For
// the byte limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT // the byte limits, SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT
// take the place of the first three for an AS number or a country they // take the place of the first three for an AS number or a country they
// list. // list.
func (rq *request) limitPercentages() (percentage, percentage) { func (rq *request) limitPercentages() (percentage, percentage) {
@@ -48,25 +48,25 @@ func (rq *request) limitPercentages() (percentage, percentage) {
unknown := percentage{percent: whole} unknown := percentage{percent: whole}
if country == "" && !rq.skippedLookupWait { if country == "" && !rq.skippedLookupWait {
unknown = percentage{cfg.UnknownLimitPercent, "SWW_UNKNOWN_LIMIT_PERCENT"} unknown = percentage{cfg.UnknownLimitPercent, "SWW_WAF_UNKNOWN_LIMIT_PERCENT"}
} }
fetched := percentage{percent: whole} fetched := percentage{percent: whole}
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed { if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
fetched = percentage{percent, "SWW_ASN_LIMIT_PERCENT_URL"} fetched = percentage{percent, "SWW_WAF_ASN_LIMIT_PERCENT_URL"}
} }
blocklisted := percentage{percent: whole} blocklisted := percentage{percent: whole}
if rq.blocklisted && cfg.BlocklistAction == "limit" { if rq.blocklisted && cfg.BlocklistAction == "limit" {
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_BLOCKLIST_ACTION"} blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_WAF_BLOCKLIST_ACTION"}
} }
reputationListed := percentage{percent: whole} reputationListed := percentage{percent: whole}
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" { if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"} reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_WAF_REPUTATION_ACTION"}
} }
const tagSetting = "SWW_TAG_LIMIT_PERCENT" const tagSetting = "SWW_WAF_TAG_LIMIT_PERCENT"
asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched) asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched)
countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting) countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting)
@@ -81,11 +81,12 @@ func (rq *request) limitPercentages() (percentage, percentage) {
asnBytes, countryBytes := asnRequests, countryRequests asnBytes, countryBytes := asnRequests, countryRequests
if _, listed := cfg.ASNBytesPercent[asn]; listed { if _, listed := cfg.ASNBytesPercent[asn]; listed {
asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_ASN_BYTES_PERCENT") asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_WAF_ASN_BYTES_PERCENT")
} }
if _, listed := cfg.CountryBytesPercent[country]; listed { if _, listed := cfg.CountryBytesPercent[country]; listed {
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT") countryBytes = given(cfg.CountryBytesPercent, country,
"SWW_WAF_COUNTRY_BYTES_PERCENT")
} }
return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted, return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted,
+42 -34
View File
@@ -22,10 +22,10 @@ import (
// The biased thresholds. // The biased thresholds.
const ( const (
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT" tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
asnBytesPercent = "SWW_ASN_BYTES_PERCENT" asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT" countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT" unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT"
) )
const ( const (
@@ -34,7 +34,7 @@ const (
asnDEHalf = asnDE + ":50" asnDEHalf = asnDE + ":50"
asnDEQuarter = asnDE + ":25" asnDEQuarter = asnDE + ":25"
countryDEHalf = "de:50" countryDEHalf = "de:50"
// The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags. // The same, as SWW_WAF_TAG_LIMIT_PERCENT names fromDE's tags.
asnTagDEHalf = "asn=64496:50" asnTagDEHalf = "asn=64496:50"
asnTagDEQuarter = "asn=64496:25" asnTagDEQuarter = "asn=64496:25"
countryTagDEHalf = "country=DE:50" countryTagDEHalf = "country=DE:50"
@@ -76,17 +76,19 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
}{ }{
{http.StatusOK, requestlog.ActionForward}, {http.StatusOK, requestlog.ActionForward},
{http.StatusOK, requestlog.ActionForward}, {http.StatusOK, requestlog.ActionForward},
{http.StatusForbidden, requestlog.ActionRateLimited}, {http.StatusForbidden, requestlog.ActionClientIsRateLimited},
} { } {
line := s.get(tc.from, sent.status, sent.action) line := s.get(tc.from, sent.status, sent.action)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
"50 from "+tc.setting) "50 from "+tc.setting)
} }
// fromKP, which no setting lists, has the whole limit. // fromKP, which no setting lists, has the whole limit.
for range 3 { for range 3 {
line := s.get(fromKP, http.StatusOK, requestlog.ActionForward) line := s.get(fromKP, http.StatusOK, requestlog.ActionForward)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
none) none)
} }
}) })
@@ -119,7 +121,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes) t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
} }
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
"50 from "+tc.setting) "50 from "+tc.setting)
// fromKP, which no setting lists, has the whole limit. // fromKP, which no setting lists, has the whole limit.
@@ -128,7 +131,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit) t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
} }
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, none) wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting, none)
}) })
} }
} }
@@ -139,9 +143,9 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
for _, tc := range []struct { for _, tc := range []struct {
name string name string
env map[string]string env map[string]string
// limitPercent and bytesPercent are the log line's, as percentText // rateLimitPercent and bytesLimitPercent are the log line's, as
// gives them, and limitHit is its limit_hit. // percentText gives them, and limitHit is its limit_hit.
limitPercent, bytesPercent, limitHit string rateLimitPercent, bytesLimitPercent, limitHit string
}{ }{
{ {
"lowering the byte limits alone", "lowering the byte limits alone",
@@ -177,10 +181,12 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit) t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit)
} }
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
tc.limitPercent) line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, tc.rateLimitPercent)
tc.bytesPercent) wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
tc.bytesLimitPercent)
}) })
} }
} }
@@ -192,15 +198,15 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
// The first request breaks the limit, and bans the client; the log line // The first request breaks the limit, and bans the client; the log line
// gives the 0. // gives the 0.
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionRateLimited) line := s.get(unplaced, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
if line.fields["limit_percent"] != float64(0) || if line.fields["rate_limit_percent"] != float64(0) ||
line.fields["limit_percent_setting"] != unknownLimitPercent { line.fields["rate_limit_percent_setting"] != unknownLimitPercent {
t.Errorf("log line has limit_percent %v from %v, want 0 from %s", t.Errorf("log line has rate_limit_percent %v from %v, want 0 from %s",
line.fields["limit_percent"], line.fields["limit_percent_setting"], line.fields["rate_limit_percent"], line.fields["rate_limit_percent_setting"],
unknownLimitPercent) unknownLimitPercent)
} }
s.get(unplaced, http.StatusForbidden, requestlog.ActionBanned) s.get(unplaced, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
func TestLowestPercentageApplies(t *testing.T) { func TestLowestPercentageApplies(t *testing.T) {
@@ -210,7 +216,7 @@ func TestLowestPercentageApplies(t *testing.T) {
name string name string
env map[string]string env map[string]string
from string from string
// want is the log line's limit_percent, as percentText gives it. // want is the log line's rate_limit_percent, as percentText gives it.
want string want string
}{ }{
{ {
@@ -234,7 +240,7 @@ func TestLowestPercentageApplies(t *testing.T) {
noCountry, "25 from " + unknownLimitPercent, noCountry, "25 from " + unknownLimitPercent,
}, },
{ {
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100. // SWW_WAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
"the AS number's, for a client without a country", "the AS number's, for a client without a country",
map[string]string{tagLimitPercent: "asn=64500:25"}, map[string]string{tagLimitPercent: "asn=64500:25"},
noCountry, "25 from " + tagLimitPercent, noCountry, "25 from " + tagLimitPercent,
@@ -250,8 +256,9 @@ func TestLowestPercentageApplies(t *testing.T) {
// A quarter of 4 requests a minute: the second breaks the limit. // A quarter of 4 requests a minute: the second breaks the limit.
s.get(tc.from, http.StatusOK, requestlog.ActionForward) s.get(tc.from, http.StatusOK, requestlog.ActionForward)
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionRateLimited) line := s.get(tc.from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.want) tc.want)
}) })
} }
@@ -269,7 +276,7 @@ func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(t *test
for _, from := range []string{unplaced, "10.0.0.8"} { for _, from := range []string{unplaced, "10.0.0.8"} {
s.get(from, http.StatusOK, requestlog.ActionForward) s.get(from, http.StatusOK, requestlog.ActionForward)
s.get(from, http.StatusOK, requestlog.ActionForward) s.get(from, http.StatusOK, requestlog.ActionForward)
s.get(from, http.StatusForbidden, requestlog.ActionRateLimited) s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
} }
// One in a country has the whole limit. // One in a country has the whole limit.
@@ -284,7 +291,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers. // says, with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
map[string]string{unknownLimitPercent: "0"}) map[string]string{unknownLimitPercent: "0"})
// Once the time the request waits for its answer is up, the client // Once the time the request waits for its answer is up, the client
@@ -293,8 +300,9 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
serveFromDE(t, server, http.MethodGet, http.NoBody) serveFromDE(t, server, http.MethodGet, http.NoBody)
line := out.requestLine(t) line := out.requestLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited) wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
"0 from "+unknownLimitPercent) "0 from "+unknownLimitPercent)
}) })
} }
@@ -327,7 +335,7 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers. // says, with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
map[string]string{ map[string]string{
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value, lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
}) })
@@ -374,7 +382,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
// A quarter of 12 requests a minute is 3: the fourth breaks it. // A quarter of 12 requests a minute is 3: the fourth breaks it.
"a rate limit", "a rate limit",
map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter}, map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
3, http.StatusForbidden, requestlog.ActionRateLimited, 3, http.StatusForbidden, requestlog.ActionClientIsRateLimited,
"requests per minute over the limit of 3", "25 from " + tagLimitPercent, "requests per minute over the limit of 3", "25 from " + tagLimitPercent,
}, },
{ {
@@ -497,7 +505,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
} }
// percentText gives a limit percentage and the setting that gave it as // percentText gives a limit percentage and the setting that gave it as
// text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are // text, such as "50 from SWW_WAF_TAG_LIMIT_PERCENT", or none when both are
// left out. // left out.
func percentText(percent *int64, setting string) string { func percentText(percent *int64, setting string) string {
switch { switch {
+4 -4
View File
@@ -1,6 +1,6 @@
package discriminator package discriminator
// The heuristic blocklists. It reads SWW_BLOCKLIST_ACTION, and the blocklists // The heuristic blocklists. It reads SWW_WAF_BLOCKLIST_ACTION, and the blocklists
// through the reputation lists. // through the reputation lists.
import ( import (
@@ -11,7 +11,7 @@ import (
) )
// blocklists notes the blocklists that list the client, as noteListed does, // blocklists notes the blocklists that list the client, as noteListed does,
// and refuses the request when SWW_BLOCKLIST_ACTION is deny. Being limit, it // and refuses the request when SWW_WAF_BLOCKLIST_ACTION is deny. Being limit, it
// lowers the client's limits instead (see limitPercentages), and being log, it // lowers the client's limits instead (see limitPercentages), and being log, it
// does nothing more. Its figure is the blocklists that list the client. // does nothing more. Its figure is the blocklists that list the client.
type blocklists struct{} type blocklists struct{}
@@ -28,14 +28,14 @@ func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
} }
return &verdict{ return &verdict{
action: requestlog.ActionDenied, action: requestlog.ActionClientDenied,
figures: map[string]any{"lists": listedBy}, figures: map[string]any{"lists": listedBy},
} }
} }
// refusedByBlocklists reports whether the heuristic blocklists refuses a // refusedByBlocklists reports whether the heuristic blocklists refuses a
// client, given listedBy, the blocklists that list it: it does when one lists // client, given listedBy, the blocklists that list it: it does when one lists
// it and SWW_BLOCKLIST_ACTION is deny. // it and SWW_WAF_BLOCKLIST_ACTION is deny.
func (rq *request) refusedByBlocklists(listedBy []string) bool { func (rq *request) refusedByBlocklists(listedBy []string) bool {
return len(listedBy) > 0 && rq.h.config.BlocklistAction == deny return len(listedBy) > 0 && rq.h.config.BlocklistAction == deny
} }
+3 -5
View File
@@ -1,13 +1,11 @@
package discriminator package discriminator
// The heuristic byte_limits. It reads SWW_BYTES_COUNT, and the byte limits // The heuristic byte_limits. It reads SWW_WAF_BYTES_COUNT, and the byte limits
// and biased thresholds through the limiter and limitPercentages. // and biased thresholds through the limiter and limitPercentages.
import ( import (
"context" "context"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// byteLimits counts the request's bytes, as countedBytes gives them, for the // byteLimits counts the request's bytes, as countedBytes gives them, for the
@@ -28,7 +26,7 @@ func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict
} }
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now, counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
rq.countedBytes(), rq.bytesPercent.percent) rq.countedBytes(), rq.bytesLimitPercent.percent)
rq.line.Counts.MinuteBytes = counts.MinuteBytes rq.line.Counts.MinuteBytes = counts.MinuteBytes
rq.line.Counts.HourBytes = counts.HourBytes rq.line.Counts.HourBytes = counts.HourBytes
rq.line.Counts.DayBytes = counts.DayBytes rq.line.Counts.DayBytes = counts.DayBytes
@@ -39,5 +37,5 @@ func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict
rq.banForLimit(now, hit, rq.out.status) rq.banForLimit(now, hit, rq.out.status)
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)} return &verdict{figures: limitFigures(hit)}
} }
+14 -14
View File
@@ -19,13 +19,13 @@ import (
// The byte limit settings. // The byte limit settings.
const ( const (
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE" bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR" bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY" bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY"
bytesCount = "SWW_BYTES_COUNT" bytesCount = "SWW_WAF_BYTES_COUNT"
) )
// The values of SWW_BYTES_COUNT. // The values of SWW_WAF_BYTES_COUNT.
const ( const (
countResponse = "response" countResponse = "response"
countRequest = "request" countRequest = "request"
@@ -40,7 +40,7 @@ const (
// byteLimit is the byte limit these tests set, as a setting: a request // byteLimit is the byte limit these tests set, as a setting: a request
// with a body and its answer, 100 bytes, go over it. // with a body and its answer, 100 bytes, go over it.
byteLimit = "99" byteLimit = "99"
// minuteBytes is limit_hit for SWW_BYTES_LIMIT_PER_MINUTE. // minuteBytes is limit_hit for SWW_WAF_BYTES_LIMIT_PER_MINUTE.
minuteBytes = "minute_bytes" minuteBytes = "minute_bytes"
) )
@@ -90,7 +90,7 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
line.BanExpires, tc.window, expires) line.BanExpires, tc.window, expires)
} }
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+ wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1) `instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
@@ -125,7 +125,7 @@ func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes) len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
} }
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) { func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
@@ -147,7 +147,7 @@ func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
answerBytes, minuteBytes, expires) answerBytes, minuteBytes, expires)
} }
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) { func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
@@ -176,7 +176,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted) line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
} }
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}) })
} }
} }
@@ -218,7 +218,7 @@ func TestBytesCountSaysWhichBytesCount(t *testing.T) {
} }
} }
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}) })
} }
} }
@@ -227,8 +227,8 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
allowed = "192.0.2.7" // in SWW_ALLOW_NETS allowed = "192.0.2.7" // in SWW_WAF_ALLOW_NETS
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS exempt = "192.0.2.10" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
) )
s, _ := startWithAnswers(t, map[string]string{ s, _ := startWithAnswers(t, map[string]string{
@@ -386,7 +386,7 @@ func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T)
// counted, and the byte limit is not broken. Its line gives the bytes // counted, and the byte limit is not broken. Its line gives the bytes
// counted before it. // counted before it.
line := s.upload() line := s.upload()
wantWouldAction(t, line, requestlog.ActionRateLimited) wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes { if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes {
t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d", t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d",
+1 -1
View File
@@ -43,7 +43,7 @@ func scheme(r *http.Request, isQuestion bool) string {
// clientGroup is the client a request is counted toward: its IPv4 // clientGroup is the client a request is counted toward: its IPv4
// address, or its IPv6 group, the netblock its IPv6 address is in of the // address, or its IPv6 group, the netblock its IPv6 address is in of the
// length SWW_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser // length SWW_WAF_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4. // usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix { func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
addr = addr.Unmap() addr = addr.Unmap()
+27 -26
View File
@@ -1,7 +1,7 @@
package discriminator package discriminator
// The heuristic core_rule_set. It reads SWW_WAF_MODE, SWW_WAF_EXEMPT_PATHS // The heuristic core_rule_set. It reads SWW_WAF_CORE_RULE_SET_MODE,
// and SWW_WAF_ANOMALY_THRESHOLD. // SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS and SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
import ( import (
"context" "context"
@@ -9,18 +9,18 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
) )
// coreRuleSet inspects the request with the Core Rule Set, unless // coreRuleSet inspects the request with the Core Rule Set, unless
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as // SWW_WAF_CORE_RULE_SET_MODE is off or SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
// pathExempt decides, and notes the rules it matched and its score in the // exempts its path, as pathExempt decides, and notes the rules it matched and
// log line, and the rules in the metrics. A score at or over // its score in the log line, and the rules in the metrics. A score at or over
// SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature, // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD is a match: it adds the tag
// raises the waf_block alert, and in block mode refuses the request with 403, // exploitsignature, raises the core_rule_set_match alert, and in block mode
// which is an offence its client's history counts. Its figures are the score // refuses the request with 403, which is an offence its client's history
// and the threshold. // counts. Its figures are the score and the threshold.
type coreRuleSet struct{} type coreRuleSet struct{}
func (coreRuleSet) name() string { return "core_rule_set" } func (coreRuleSet) name() string { return "core_rule_set" }
@@ -38,12 +38,12 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
result, read, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.in.Body) result, read, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.in.Body)
rq.requestBytes = int64(len(read)) rq.requestBytes = int64(len(read))
rq.line.DurationWAF = new(requestlog.Milliseconds(time.Since(start))) rq.line.DurationCoreRuleSetMS = new(requestlog.Milliseconds(time.Since(start)))
rq.line.WAFRuleIDs = result.RuleIDs rq.line.CoreRuleSetRuleIDs = result.RuleIDs
rq.line.WAFScore = &result.Score rq.line.CoreRuleSetScore = &result.Score
for _, id := range result.RuleIDs { for _, id := range result.RuleIDs {
rq.h.metrics.WAFMatched(cfg.WAFMode, id) rq.h.metrics.CoreRuleSetMatched(cfg.WAFMode, id)
} }
threshold := cfg.WAFAnomalyThreshold threshold := cfg.WAFAnomalyThreshold
@@ -52,26 +52,26 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
} }
rq.addTag(config.ExploitSignatureTag) rq.addTag(config.ExploitSignatureTag)
rq.alertWAFBlock(result) rq.alertCoreRuleSetMatch(result)
if cfg.WAFMode == config.WAFModeDetect { if cfg.WAFMode == config.WAFModeDetect {
return nil return nil
} }
rq.wafBlocked = true rq.blockedByCoreRuleSet = true
return &verdict{ return &verdict{
action: requestlog.ActionWAFBlocked, action: requestlog.ActionBlockedByCoreRuleSet,
figures: map[string]any{figureScore: result.Score, "threshold": threshold}, figures: map[string]any{figureScore: result.Score, "threshold": threshold},
} }
} }
// alertWAFBlock raises the waf_block alert for the request, which the Core // alertCoreRuleSetMatch raises the core_rule_set_match alert for the request,
// Rule Set scored at result, at or over SWW_WAF_ANOMALY_THRESHOLD. Its // which the Core Rule Set scored at result, at or over
// detail gives the rule ids, the score, the method and the path with the // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD. Its detail gives the rule ids, the
// query, and, for a request that is not refused for it, the mode: detect, // score, the method and the path with the query, and, for a request that is
// or observe in observe mode. // not refused for it, the mode: detect, or observe in observe mode.
func (rq *request) alertWAFBlock(result waf.Result) { func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
detail := map[string]any{ detail := map[string]any{
"rule_ids": result.RuleIDs, "rule_ids": result.RuleIDs,
"score": result.Score, "score": result.Score,
@@ -87,14 +87,15 @@ func (rq *request) alertWAFBlock(result waf.Result) {
} }
rq.h.alerts.Raise(alerts.Alert{ rq.h.alerts.Raise(alerts.Alert{
Event: alerts.EventWAFBlock, Event: alerts.EventCoreRuleSetMatch,
Host: rq.site, Host: rq.site,
Client: rq.client, Client: rq.client,
Netblock: rq.h.clientGroup(rq.client), Netblock: rq.h.clientGroup(rq.client),
ASN: rq.line.ASN, ASN: rq.line.ASN,
ASName: rq.line.ASName, ASName: rq.line.ASName,
Country: rq.line.Country, Country: rq.line.Country,
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD", Reason: "scored by the Core Rule Set at or over " +
Detail: detail, "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
Detail: detail,
}) })
} }
+74 -87
View File
@@ -10,19 +10,17 @@ import (
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and // The Core Rule Set's settings the tests set, besides SWW_WAF_CORE_RULE_SET_MODE, and
// its two modes that inspect requests. // its two modes that inspect requests.
const ( const (
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD" wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD"
wafDisabledRules = "SWW_WAF_DISABLED_RULES" wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS" wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_BODY_LIMIT" wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT"
apps = "SWW_APPS"
block = "block" block = "block"
detect = "detect" detect = "detect"
) )
@@ -30,7 +28,7 @@ const (
// formData is the type of a form's body. // formData is the type of a form's body.
const formData = "application/x-www-form-urlencoded" const formData = "application/x-www-form-urlencoded"
// sizeLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies, 1K as a // sizeLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies, 1K as a
// setting. // setting.
const ( const (
sizeLimit = 1 << 10 sizeLimit = 1 << 10
@@ -39,27 +37,33 @@ const (
// sqlInjection asks for / with an SQL injection in its query, which only // sqlInjection asks for / with an SQL injection in its query, which only
// the Core Rule Set's rule 942100 matches, with a score of 5, the default // the Core Rule Set's rule 942100 matches, with a score of 5, the default
// SWW_WAF_ANOMALY_THRESHOLD. // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
const sqlInjection = "/?id=1'%20OR%20'1'='1" const sqlInjection = "/?id=1'%20OR%20'1'='1"
// wantWAF checks the request log line's waf_rule_ids and waf_score, and // wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
// that it has duration_waf, or with no score, that it has none of the // core_rule_set_score, and that it has duration_core_rule_set_ms, or with no
// three: the Core Rule Set did not inspect the request. // score, that it has none of the three: the Core Rule Set did not inspect the
func wantWAF(t *testing.T, line logLine, score *int, ruleIDs ...int) { // request.
func wantCoreRuleSet(t *testing.T, line logLine, score *int, ruleIDs ...int) {
t.Helper() t.Helper()
if !slices.Equal(line.WAFRuleIDs, ruleIDs) { if !slices.Equal(line.CoreRuleSetRuleIDs, ruleIDs) {
t.Errorf("log line has waf_rule_ids %v, want %v", line.WAFRuleIDs, ruleIDs) t.Errorf("log line has core_rule_set_rule_ids %v, want %v",
line.CoreRuleSetRuleIDs, ruleIDs)
} }
switch { switch {
case score == nil && (line.WAFScore != nil || line.DurationWAF != nil): case score == nil &&
t.Errorf("log line has waf_score %v and duration_waf %v, want neither", (line.CoreRuleSetScore != nil || line.DurationCoreRuleSetMS != nil):
line.fields["waf_score"], line.fields["duration_waf"]) t.Errorf("log line has core_rule_set_score %v and "+
case score != nil && (line.WAFScore == nil || *line.WAFScore != *score): "duration_core_rule_set_ms %v, want neither",
t.Errorf("log line has waf_score %v, want %d", line.fields["waf_score"], *score) line.fields["core_rule_set_score"], line.fields["duration_core_rule_set_ms"])
case score != nil && line.DurationWAF == nil: case score != nil &&
t.Error("log line has no duration_waf") (line.CoreRuleSetScore == nil || *line.CoreRuleSetScore != *score):
t.Errorf("log line has core_rule_set_score %v, want %d",
line.fields["core_rule_set_score"], *score)
case score != nil && line.DurationCoreRuleSetMS == nil:
t.Error("log line has no duration_core_rule_set_ms")
} }
} }
@@ -72,7 +76,7 @@ func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
status int status int
action string action string
}{ }{
{block, block, http.StatusForbidden, requestlog.ActionWAFBlocked}, {block, block, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet},
{detect, detect, http.StatusOK, requestlog.ActionForward}, {detect, detect, http.StatusOK, requestlog.ActionForward},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -94,7 +98,8 @@ func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
wafMode: detect, tagDeny: "exploitsignature", wafMode: detect, tagDeny: "exploitsignature",
}) })
line := s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionDenied) line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionClientDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`) wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
} }
@@ -134,14 +139,14 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
mode, action string mode, action string
status int status int
}{ }{
{block, requestlog.ActionWAFBlocked, http.StatusForbidden}, {block, requestlog.ActionBlockedByCoreRuleSet, http.StatusForbidden},
{detect, requestlog.ActionForward, http.StatusOK}, {detect, requestlog.ActionForward, http.StatusOK},
} { } {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode}) s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
line := s.requestWithHeader(client, attack.path, attack.header, line := s.requestWithHeader(client, attack.path, attack.header,
tc.status, tc.action) tc.status, tc.action)
wantWAF(t, line, &attack.score, attack.ruleIDs...) wantCoreRuleSet(t, line, &attack.score, attack.ruleIDs...)
} }
}) })
} }
@@ -153,7 +158,7 @@ func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block}) s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line := s.request(client, sqlInjection, http.StatusForbidden, line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionWAFBlocked) requestlog.ActionBlockedByCoreRuleSet)
wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`) wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`)
} }
@@ -164,7 +169,7 @@ func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source", line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source",
http.StatusOK, requestlog.ActionForward) http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, new(0)) wantCoreRuleSet(t, line, new(0))
} }
func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal( func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
@@ -172,7 +177,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
) { ) {
t.Parallel() t.Parallel()
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS const allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
s, _, _ := startWithClock(t, "", map[string]string{ s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block, wafMode: block,
@@ -181,29 +186,29 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
rulesDir: writeRules(t, testRules), rulesDir: writeRules(t, testRules),
}) })
// A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS // A client in SWW_WAF_ALLOW_NETS, and a path SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
// exempts, are not inspected. // exempts, are not inspected.
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward) line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, nil) wantCoreRuleSet(t, line, nil)
line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK, line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK,
requestlog.ActionForward) requestlog.ActionForward)
wantWAF(t, line, nil) wantCoreRuleSet(t, line, nil)
// The prefix is matched as rate limit exempt paths are: a path that // The prefix is matched as rate limit exempt paths are: a path that
// goes up and out of it is inspected. // goes up and out of it is inspected.
line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden, line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden,
requestlog.ActionWAFBlocked) requestlog.ActionBlockedByCoreRuleSet)
wantWAF(t, line, new(25), 930100, 930110, 942100) wantCoreRuleSet(t, line, new(25), 930100, 930110, 942100)
// A request a rule file refuses is not inspected. // A request a rule file refuses is not inspected.
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden, line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
requestlog.ActionRuleBlocked) requestlog.ActionBlockedByBlockRule)
wantWAF(t, line, nil) wantCoreRuleSet(t, line, nil)
// With SWW_WAF_MODE off, no request is. // With SWW_WAF_CORE_RULE_SET_MODE off, no request is.
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off}) s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, nil) wantCoreRuleSet(t, line, nil)
} }
func TestAnomalyThreshold(t *testing.T) { func TestAnomalyThreshold(t *testing.T) {
@@ -217,13 +222,14 @@ func TestAnomalyThreshold(t *testing.T) {
}) })
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, new(5), 942100) wantCoreRuleSet(t, line, new(5), 942100)
} }
s, _, _ := startWithClock(t, "", map[string]string{ s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block, wafAnomalyThreshold: "5", wafMode: block, wafAnomalyThreshold: "5",
}) })
s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked) s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
} }
func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) { func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
@@ -266,7 +272,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block}) s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line, _ := s.requestWithBody(request.method, client, request.path, line, _ := s.requestWithBody(request.method, client, request.path,
request.header, body, http.StatusOK, requestlog.ActionForward) request.header, body, http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, new(0)) wantCoreRuleSet(t, line, new(0))
// A list given replaces the default. // A list given replaces the default.
s, _, _ = startWithClock(t, "", map[string]string{ s, _, _ = startWithClock(t, "", map[string]string{
@@ -274,37 +280,16 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
}) })
score := 5 * len(request.ruleIDs) score := 5 * len(request.ruleIDs)
line, _ = s.requestWithBody(request.method, client, request.path, line, _ = s.requestWithBody(request.method, client, request.path,
request.header, body, http.StatusForbidden, requestlog.ActionWAFBlocked) request.header, body, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
wantWAF(t, line, &score, request.ruleIDs...) wantCoreRuleSet(t, line, &score, request.ruleIDs...)
// And switches off the rules it lists. // And switches off the rules it lists.
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, new(0)) wantCoreRuleSet(t, line, new(0))
}) })
} }
} }
func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
t.Parallel()
// Only gitea's fixes let a file name through in path, a parameter
// gitea sends file names in.
const fileName = "/?path=.gitignore"
s := startDiscriminator(t, "", map[string]string{
wafMode: block, apps: siteA + ":gitea",
})
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
http.StatusOK, discriminator.DispositionAllow)
wantDisposition(t, do(t, s.question(siteB, client, fileName)),
http.StatusForbidden, discriminator.DispositionDeny)
lines := s.out.requestLines(t, 2)
wantLine(t, lines[0], http.StatusOK, requestlog.ActionForward)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionWAFBlocked)
}
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) { func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
t.Parallel() t.Parallel()
@@ -316,14 +301,14 @@ func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block}) s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body, line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
http.StatusOK, requestlog.ActionForward) http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, new(0)) wantCoreRuleSet(t, line, new(0))
s, _, _ = startWithClock(t, "", map[string]string{ s, _, _ = startWithClock(t, "", map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting, wafMode: block, wafBodyLimit: sizeLimitSetting,
}) })
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body, line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
http.StatusForbidden, requestlog.ActionWAFBlocked) http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
wantWAF(t, line, new(5), 942100) wantCoreRuleSet(t, line, new(5), 942100)
} }
func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) { func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
@@ -413,32 +398,33 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
}) })
for range 2 { for range 2 {
s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked) s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
} }
// The third refusal in a minute breaks the error burst, and bans the // The third refusal in a minute breaks the error burst, and bans the
// client. // client.
line := s.request(client, sqlInjection, http.StatusForbidden, line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionWAFBlocked) requestlog.ActionBlockedByCoreRuleSet)
if line.LimitHit != requestlog.LimitHitErrorBurst || if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
line.Offence != requestlog.OffenceLimit { line.Offence != requestlog.OffenceLimit {
t.Errorf("log line has limit_hit %q and offence %q, want error_burst and limit", t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals and limit",
line.LimitHit, line.Offence) line.LimitHit, line.Offence)
} }
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
want := ratelimit.Offences{Limit: 1, WAFBlocked: 3} want := ratelimit.Offences{Limit: 1, BlockedByCoreRuleSet: 3}
if offences := historyOf(t, server, client).Offences; offences != want { if offences := historyOf(t, server, client).Offences; offences != want {
t.Errorf("history counts the offences %+v, want %+v", offences, want) t.Errorf("history counts the offences %+v, want %+v", offences, want)
} }
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`sww_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3) `sww_core_rule_set_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
wantMetric(t, metrics, wantMetric(t, metrics,
`sww_offences_total{instance="app",kind="waf_blocked"}`, 3) `sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`, 3)
wantMetric(t, metrics, `sww_requests_total{action="waf_blocked",`+ wantMetric(t, metrics, `sww_requests_total{action="blocked_by_core_rule_set",`+
`instance="app",status_class="4xx"}`, 3) `instance="app",status_class="4xx"}`, 3)
} }
@@ -462,9 +448,9 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`sww_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3) `sww_core_rule_set_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
wantNoSeries(t, metrics, wantNoSeries(t, metrics,
`sww_offences_total{instance="app",kind="waf_blocked"}`) `sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`)
} }
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) { func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
@@ -473,17 +459,17 @@ func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe}) s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe})
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionWAFBlocked) wantWouldAction(t, line, requestlog.ActionBlockedByCoreRuleSet)
wantWAF(t, line, new(5), 942100) wantCoreRuleSet(t, line, new(5), 942100)
// It is an offence as in enforce mode. // It is an offence as in enforce mode.
want := ratelimit.Offences{WAFBlocked: 1} want := ratelimit.Offences{BlockedByCoreRuleSet: 1}
if offences := historyOf(t, server, client).Offences; offences != want { if offences := historyOf(t, server, client).Offences; offences != want {
t.Errorf("history counts the offences %+v, want %+v", offences, want) t.Errorf("history counts the offences %+v, want %+v", offences, want)
} }
} }
func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) { func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
t.Parallel() t.Parallel()
for _, tc := range []struct { for _, tc := range []struct {
@@ -497,7 +483,7 @@ func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
}{ }{
{ {
"block", map[string]string{wafMode: block}, "block", map[string]string{wafMode: block},
http.StatusForbidden, requestlog.ActionWAFBlocked, "", http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet, "",
}, },
{ {
"detect", map[string]string{wafMode: detect}, "detect", map[string]string{wafMode: detect},
@@ -532,12 +518,13 @@ func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
wantAlerts(t, queue, alerts.Alert{ wantAlerts(t, queue, alerts.Alert{
Instance: alertInstance, Instance: alertInstance,
Time: clk.Now(), Time: clk.Now(),
Event: alerts.EventWAFBlock, Event: alerts.EventCoreRuleSetMatch,
Host: appHost, Host: appHost,
Client: netip.MustParseAddr(client), Client: netip.MustParseAddr(client),
Netblock: netip.MustParsePrefix(client + "/32"), Netblock: netip.MustParsePrefix(client + "/32"),
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD", Reason: "scored by the Core Rule Set at or over " +
Detail: detail, "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
Detail: detail,
}) })
if queue.Suppressed() != 1 { if queue.Suppressed() != 1 {
+3 -3
View File
@@ -1,6 +1,6 @@
package discriminator package discriminator
// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES. // The heuristic countries. It reads SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
import ( import (
"context" "context"
@@ -10,7 +10,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// countries refuses a request when SWW_EXCLUSIVELY_ALLOWED_COUNTRIES is set and // countries refuses a request when SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES is set and
// does not hold the client's country as it was looked up. A client without a // does not hold the client's country as it was looked up. A client without a
// country, or whose country cannot be found, is refused. Its figures are the // country, or whose country cannot be found, is refused. Its figures are the
// country and the setting that refused it. // country and the setting that refused it.
@@ -29,7 +29,7 @@ func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
return &verdict{ return &verdict{
action: requestlog.ActionCountryDenied, action: requestlog.ActionCountryDenied,
figures: map[string]any{ figures: map[string]any{
"country": country, "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", "country": country, "setting": "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
}, },
} }
} }
+6 -6
View File
@@ -38,7 +38,7 @@ func TestCountryLists(t *testing.T) {
[]string{fromKP, unplaced}, []string{fromKP, unplaced},
}, },
{ {
"with a country in SWW_TAG_DENY", "with a country in SWW_WAF_TAG_DENY",
map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"}, map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"},
[]string{fromKP, unplaced}, []string{fromKP, unplaced},
}, },
@@ -100,11 +100,11 @@ func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied) line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
wantHeuristic(t, line, "countries", wantHeuristic(t, line, "countries",
`{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`) `{"country": "KP", "setting": "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied) line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
wantHeuristic(t, line, "countries", wantHeuristic(t, line, "countries",
`{"country": "", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`) `{"country": "", "setting": "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
} }
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) { func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
@@ -262,11 +262,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
action string action string
}{ }{
{ {
"not in SWW_ALLOW_NETS", "", "not in SWW_WAF_ALLOW_NETS", "",
http.StatusForbidden, requestlog.ActionCountryDenied, http.StatusForbidden, requestlog.ActionCountryDenied,
}, },
{ {
"in SWW_ALLOW_NETS", "10.0.0.7,fd00::/8", "in SWW_WAF_ALLOW_NETS", "10.0.0.7,fd00::/8",
http.StatusOK, requestlog.ActionForward, http.StatusOK, requestlog.ActionForward,
}, },
} { } {
@@ -296,7 +296,7 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP, // startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
// each in an AS of its own, and no other address. It returns its URL, and // each in an AS of its own, and no other address. It returns its URL, and
// what returns the addresses it has been asked about. A test that needs // what returns the addresses it has been asked about. A test that needs
// the stand-in to be asked or to answer sets SWW_LOOKUP_TIMEOUT to an // the stand-in to be asked or to answer sets SWW_WAF_LOOKUP_TIMEOUT to an
// hour, whether or not a request waits for the answer: on the default, a // hour, whether or not a request waits for the answer: on the default, a
// hold-up of the test process can abandon the request to the stand-in, and // hold-up of the test process can abandon the request to the stand-in, and
// leave the client unknown. // leave the client unknown.
+2 -3
View File
@@ -1,6 +1,6 @@
package discriminator package discriminator
// The heuristic crowdsec. It reads SWW_CROWDSEC_LAPI_URL, and the decisions // The heuristic crowdsec. It reads SWW_WAF_CROWDSEC_LAPI_URL, and the decisions
// through the reputation lists. // through the reputation lists.
import ( import (
@@ -30,10 +30,9 @@ func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
rq.addTag(config.CrowdSecTag) rq.addTag(config.CrowdSecTag)
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL}, rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
"listed by the CrowdSec decision list") "listed by the CrowdSec decision list")
rq.banForCrowdSec(now, decision)
return &verdict{ return &verdict{
action: requestlog.ActionBanned, action: rq.banForCrowdSec(now, decision),
figures: map[string]any{ figures: map[string]any{
"scenario": decision.Scenario, "scenario": decision.Scenario,
"expires": requestlog.FormatTime(decision.Expires), "expires": requestlog.FormatTime(decision.Expires),
+9 -9
View File
@@ -18,8 +18,8 @@ import (
// test puts in the copy of its decision list, at decisionsURL, that it // test puts in the copy of its decision list, at decisionsURL, that it
// needs, as reputation.json would at start. // needs, as reputation.json would at start.
const ( const (
crowdSecURL = "SWW_CROWDSEC_LAPI_URL" crowdSecURL = "SWW_WAF_CROWDSEC_LAPI_URL"
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY" crowdSecKey = "SWW_WAF_CROWDSEC_LAPI_KEY"
lapi = "http://crowdsec.example:8080" lapi = "http://crowdsec.example:8080"
decisionsURL = lapi + "/v1/decisions" decisionsURL = lapi + "/v1/decisions"
bouncerKey = "crowdsec-key-0123456789abcdef" bouncerKey = "crowdsec-key-0123456789abcdef"
@@ -39,7 +39,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
expires := requestlog.FormatTime(fetched.Add(4 * time.Hour)) expires := requestlog.FormatTime(fetched.Add(4 * time.Hour))
// Its first request is refused, and bans it until the decision ends. // Its first request is refused, and bans it until the decision ends.
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned) line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantReputation(t, line, decisionsURL) wantReputation(t, line, decisionsURL)
if line.BanExpires != expires { if line.BanExpires != expires {
@@ -72,7 +72,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
// ban, and once it has ended the client is let through. // ban, and once it has ended the client is let through.
clk.advance(4*time.Hour - time.Second) clk.advance(4*time.Hour - time.Second)
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned) line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantReputation(t, line) wantReputation(t, line)
if line.BanExpires != expires { if line.BanExpires != expires {
@@ -105,7 +105,7 @@ func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+ `"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
`"value": "`+client+`"}]`) `"value": "`+client+`"}]`)
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned) line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+ wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`) `"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
wantTags(t, line, "crowdsec") wantTags(t, line, "crowdsec")
@@ -129,9 +129,9 @@ func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T
// Just before its end, the decision bans a client in the netblock, and // Just before its end, the decision bans a client in the netblock, and
// one on an IPv6 address bans the address's group, the /64. // one on an IPv6 address bans the address's group, the /64.
clk.advance(time.Minute - time.Nanosecond) clk.advance(time.Minute - time.Nanosecond)
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionBanned) s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionBanned) s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionBanned) s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
// Once it has ended, it bans no other client, and the bans it made end // Once it has ended, it bans no other client, and the bans it made end
// with it. // with it.
@@ -164,7 +164,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
`"value": "`+client+`"}]`) `"value": "`+client+`"}]`)
line := s.get(client, http.StatusOK, requestlog.ActionForward) line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned) wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
wantReputation(t, line, decisionsURL) wantReputation(t, line, decisionsURL)
if held := server.Ledger.Snapshot(); len(held) != 0 { if held := server.Ledger.Snapshot(); len(held) != 0 {
+3 -3
View File
@@ -1,6 +1,6 @@
package discriminator package discriminator
// The heuristic deny_nets. It reads SWW_DENY_NETS. // The heuristic deny_nets. It reads SWW_WAF_DENY_NETS.
import ( import (
"context" "context"
@@ -9,7 +9,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// denyNets refuses a client in SWW_DENY_NETS. Its figure is the netblock of // denyNets refuses a client in SWW_WAF_DENY_NETS. Its figure is the netblock of
// the setting the client is in. // the setting the client is in.
type denyNets struct{} type denyNets struct{}
@@ -19,7 +19,7 @@ func (denyNets) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, netblock := range rq.h.config.DenyNets { for _, netblock := range rq.h.config.DenyNets {
if netblock.Contains(rq.client) { if netblock.Contains(rq.client) {
return &verdict{ return &verdict{
action: requestlog.ActionDenied, action: requestlog.ActionClientDenied,
figures: map[string]any{figureNetblock: netblock.String()}, figures: map[string]any{figureNetblock: netblock.String()},
} }
} }
+22 -21
View File
@@ -40,7 +40,7 @@ const (
// headers and body. // headers and body.
DispositionDeny = "deny" DispositionDeny = "deny"
// DispositionClose tells rproxy to close the visitor's connection // DispositionClose tells rproxy to close the visitor's connection
// without an answer, which SWW_BAN_RESPONSE asks for when it is close. // without an answer, which SWW_WAF_BAN_RESPONSE asks for when it is close.
// discriminator closing the question's connection instead would read as // discriminator closing the question's connection instead would read as
// discriminator being down, and rproxy would then let the request through. // discriminator being down, and rproxy would then let the request through.
DispositionClose = "close" DispositionClose = "close"
@@ -89,12 +89,12 @@ var (
// //
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case //nolint:tagliatelle // docs/request-log.md names the fields in snake_case
type Report struct { type Report struct {
RequestID string `json:"request_id"` RequestID string `json:"request_id"`
Status int `json:"status"` Status int `json:"status"`
RequestBytes int64 `json:"request_bytes"` RequestBytes int64 `json:"request_bytes"`
ResponseBytes int64 `json:"response_bytes"` ResponseBytes int64 `json:"response_bytes"`
DurationTotal float64 `json:"duration_total"` DurationTotalMS float64 `json:"duration_total_ms"`
DurationUpstreamTotal float64 `json:"duration_upstream_total"` DurationUpstreamTotalMS float64 `json:"duration_upstream_total_ms"`
} }
// New returns the server `sww discriminator` runs. It answers // New returns the server `sww discriminator` runs. It answers
@@ -123,6 +123,7 @@ func New(params Params) *Server {
Lists: h.lists, Lists: h.lists,
DNSBL: h.dnsbl, DNSBL: h.dnsbl,
AbuseIPDB: h.abuseIPDB, AbuseIPDB: h.abuseIPDB,
RouteWatch: h.routeWatch,
Metrics: h.metrics, Metrics: h.metrics,
} }
} }
@@ -182,7 +183,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
rq.line.DiscriminatorLine = requestlog.DiscriminatorLineDecision rq.line.DiscriminatorLine = requestlog.DiscriminatorLineDecision
refused := rq.check(r.Context()) refused := rq.check(r.Context())
rq.checked = time.Now() rq.checked = time.Now()
underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix) underOwnPrefix := strings.HasPrefix(r.URL.Path, ownEndpointPrefix)
if refused == nil && !underOwnPrefix { if refused == nil && !underOwnPrefix {
// In observe mode, what enforce mode would have refused counts here. // In observe mode, what enforce mode would have refused counts here.
@@ -207,7 +208,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
// The app never sees this prefix. discriminator's own endpoints are at its // The app never sees this prefix. discriminator's own endpoints are at its
// own address, so this answers as a path with no endpoint does. // own address, so this answers as a path with no endpoint does.
rq.line.Action = requestlog.ActionAdmin rq.line.Action = requestlog.ActionOwnEndpoint
rq.out.Header().Set(DispositionHeader, DispositionDeny) rq.out.Header().Set(DispositionHeader, DispositionDeny)
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound) http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
} }
@@ -301,7 +302,7 @@ func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
} }
// allow answers a question with DispositionAllow and, while // allow answers a question with DispositionAllow and, while
// SWW_ADD_LOOKUP_HEADERS is set, the client's AS number and country. // SWW_WAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country.
func (rq *request) allow() { func (rq *request) allow() {
rq.allowed = true rq.allowed = true
@@ -327,7 +328,7 @@ func (rq *request) deny(r refusal) {
rq.refused = &r rq.refused = &r
if r.status == 0 { if r.status == 0 {
// SWW_BAN_RESPONSE is close. Written past rq.out, since the log line's // SWW_WAF_BAN_RESPONSE is close. Written past rq.out, since the log line's
// status stays 0: the visitor is sent nothing. // status stays 0: the visitor is sent nothing.
rq.out.Header().Set(DispositionHeader, DispositionClose) rq.out.Header().Set(DispositionHeader, DispositionClose)
rq.out.ResponseWriter.WriteHeader(http.StatusOK) rq.out.ResponseWriter.WriteHeader(http.StatusOK)
@@ -404,7 +405,7 @@ func readReport(w http.ResponseWriter, r *http.Request) (Report, error) {
for _, name := range []string{ for _, name := range []string{
"request_id", "status", "request_bytes", "response_bytes", "request_id", "status", "request_bytes", "response_bytes",
"duration_total", "duration_upstream_total", "duration_total_ms", "duration_upstream_total_ms",
} { } {
value, there := fields[name] value, there := fields[name]
if !there || string(value) == "null" { if !there || string(value) == "null" {
@@ -430,11 +431,11 @@ func (report *Report) checkRanges() error {
return fmt.Errorf("%w: request_bytes", errReportValue) return fmt.Errorf("%w: request_bytes", errReportValue)
case report.ResponseBytes < 0 || report.ResponseBytes > maxReportedBytes: case report.ResponseBytes < 0 || report.ResponseBytes > maxReportedBytes:
return fmt.Errorf("%w: response_bytes", errReportValue) return fmt.Errorf("%w: response_bytes", errReportValue)
case report.DurationTotal < 0 || report.DurationTotal > maxReportedMilliseconds: case report.DurationTotalMS < 0 || report.DurationTotalMS > maxReportedMilliseconds:
return fmt.Errorf("%w: duration_total", errReportValue) return fmt.Errorf("%w: duration_total_ms", errReportValue)
case report.DurationUpstreamTotal < 0 || case report.DurationUpstreamTotalMS < 0 ||
report.DurationUpstreamTotal > maxReportedMilliseconds: report.DurationUpstreamTotalMS > maxReportedMilliseconds:
return fmt.Errorf("%w: duration_upstream_total", errReportValue) return fmt.Errorf("%w: duration_upstream_total_ms", errReportValue)
} }
return nil return nil
@@ -458,18 +459,18 @@ func (h *handler) finishWith(ctx context.Context, held *decision, report Report)
rq.line.Status = report.Status rq.line.Status = report.Status
rq.line.RequestBytes = report.RequestBytes rq.line.RequestBytes = report.RequestBytes
rq.line.ResponseBytes = report.ResponseBytes rq.line.ResponseBytes = report.ResponseBytes
rq.line.DurationTotal = report.DurationTotal rq.line.DurationTotalMS = report.DurationTotalMS
if rq.allowed { if rq.allowed {
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal rq.line.DurationUpstreamTotalMS = &report.DurationUpstreamTotalMS
rq.runBanningHeuristics(ctx, onReport()) rq.runBanningHeuristics(ctx, onReport())
rq.countAnomalies() rq.countAnomalies()
rq.addToHistory() rq.addToHistory()
rq.h.metrics.RequestReported(&rq.line, rq.h.metrics.RequestReported(&rq.line,
time.Duration(report.DurationTotal*float64(time.Millisecond)), time.Duration(report.DurationTotalMS*float64(time.Millisecond)),
time.Duration(report.DurationUpstreamTotal*float64(time.Millisecond))) time.Duration(report.DurationUpstreamTotalMS*float64(time.Millisecond)))
} }
rq.writeLine() rq.writeLine()
+36 -34
View File
@@ -39,7 +39,7 @@ const (
// reportBodyLimit is the longest body SPEC.md lets a report have. // reportBodyLimit is the longest body SPEC.md lets a report have.
reportBodyLimit = 1 << 20 reportBodyLimit = 1 << 20
// anomalyClientPerMinute is the anomaly threshold these tests set. // anomalyClientPerMinute is the anomaly threshold these tests set.
anomalyClientPerMinute = "SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE" anomalyClientPerMinute = "SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE"
) )
// The fields of a report, as "discriminator" in SPEC.md names them. // The fields of a report, as "discriminator" in SPEC.md names them.
@@ -48,8 +48,8 @@ const (
reportStatus = "status" reportStatus = "status"
reportRequestBytes = "request_bytes" reportRequestBytes = "request_bytes"
reportResponseBytes = "response_bytes" reportResponseBytes = "response_bytes"
reportDuration = "duration_total" reportDuration = "duration_total_ms"
reportUpstreamDuration = "duration_upstream_total" reportUpstreamDuration = "duration_upstream_total_ms"
) )
// Discriminator is a discriminator running for one test, on a clock set to midnight. // Discriminator is a discriminator running for one test, on a clock set to midnight.
@@ -181,7 +181,7 @@ func (s *Discriminator) history() ratelimit.History {
} }
// metrics returns what the metrics endpoint answers, for a server started with // metrics returns what the metrics endpoint answers, for a server started with
// the token as SWW_METRICS_TOKEN. // the token as SWW_WAF_METRICS_TOKEN.
func (s *Discriminator) metrics() string { func (s *Discriminator) metrics() string {
s.t.Helper() s.t.Helper()
@@ -639,7 +639,7 @@ func TestQuestionDeniedGetsTheBanResponse(t *testing.T) {
got := s.ask(client, "/") got := s.ask(client, "/")
wantDisposition(t, got, status, discriminator.DispositionDeny) wantDisposition(t, got, status, discriminator.DispositionDeny)
wantBody(t, got, http.StatusText(status)+"\n") wantBody(t, got, http.StatusText(status)+"\n")
wantLine(t, s.out.requestLine(t), status, requestlog.ActionDenied) wantLine(t, s.out.requestLine(t), status, requestlog.ActionClientDenied)
}) })
} }
} }
@@ -655,8 +655,8 @@ func TestBanResponseCloseIsAnAnswerOfItsOwn(t *testing.T) {
wantDisposition(t, got, http.StatusOK, discriminator.DispositionClose) wantDisposition(t, got, http.StatusOK, discriminator.DispositionClose)
wantBody(t, got, "") wantBody(t, got, "")
// The visitor is sent nothing. // The visitor is sent nothing.
wantLine(t, s.out.requestLine(t), 0, requestlog.ActionDenied) wantLine(t, s.out.requestLine(t), 0, requestlog.ActionClientDenied)
wantMetric(t, s.metrics(), `sww_requests_total{action="denied",instance="`+ wantMetric(t, s.metrics(), `sww_requests_total{action="client_denied",instance="`+
alertInstance+`",status_class="none"}`, 1) alertInstance+`",status_class="none"}`, 1)
} }
@@ -671,12 +671,12 @@ func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(t *testing.T) {
got := s.ask(client, "/blocked") got := s.ask(client, "/blocked")
wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden, wantLine(t, s.out.requestLine(t), http.StatusForbidden,
requestlog.ActionRuleBlocked) requestlog.ActionBlockedByBlockRule)
got = s.ask(client, "/.env") got = s.ask(client, "/.env")
wantDisposition(t, got, http.StatusTooManyRequests, discriminator.DispositionDeny) wantDisposition(t, got, http.StatusTooManyRequests, discriminator.DispositionDeny)
wantLine(t, s.out.requestLines(t, 2)[1], http.StatusTooManyRequests, wantLine(t, s.out.requestLines(t, 2)[1], http.StatusTooManyRequests,
requestlog.ActionBanned) requestlog.ActionTemporarilyBanned)
} }
func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) { func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) {
@@ -693,7 +693,7 @@ func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) {
wantDisposition(t, got, http.StatusNotFound, discriminator.DispositionDeny) wantDisposition(t, got, http.StatusNotFound, discriminator.DispositionDeny)
} }
wantLine(t, s.out.requestLine(t), http.StatusNotFound, requestlog.ActionAdmin) wantLine(t, s.out.requestLine(t), http.StatusNotFound, requestlog.ActionOwnEndpoint)
got := s.ask(otherClient, discriminator.MetricsPath) got := s.ask(otherClient, discriminator.MetricsPath)
wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny)
@@ -709,7 +709,7 @@ func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) {
line := s.out.requestLine(t) line := s.out.requestLine(t)
wantLine(t, line, http.StatusOK, requestlog.ActionForward) wantLine(t, line, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionDenied) wantWouldAction(t, line, requestlog.ActionClientDenied)
} }
func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) { func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) {
@@ -725,9 +725,9 @@ func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) {
} }
lines := s.out.requestLines(t, 2) lines := s.out.requestLines(t, 2)
if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitErrorBurst { if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitMinuteRefusals {
t.Errorf("log lines have limit_hit %q and %q, want none and %q", t.Errorf("log lines have limit_hit %q and %q, want none and %q",
lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitErrorBurst) lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitMinuteRefusals)
} }
} }
@@ -744,9 +744,9 @@ func TestDeniedQuestionCountsForTheErrorBurst(t *testing.T) {
} }
lines := s.out.requestLines(t, 2) lines := s.out.requestLines(t, 2)
if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitErrorBurst { if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitMinuteRefusals {
t.Errorf("log lines have limit_hit %q and %q, want none and %q", t.Errorf("log lines have limit_hit %q and %q, want none and %q",
lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitErrorBurst) lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitMinuteRefusals)
} }
} }
@@ -894,14 +894,14 @@ func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) {
t.Errorf("history is %+v, want the refused request alone", history) t.Errorf("history is %+v, want the refused request alone", history)
} }
const series = `sww_requests_total{action="rate_limited",instance="` + const series = `sww_requests_total{action="client_is_rate_limited",instance="` +
alertInstance + `",status_class="4xx"}` alertInstance + `",status_class="4xx"}`
wantMetric(t, s.metrics(), series, 1) wantMetric(t, s.metrics(), series, 1)
got := s.report(discriminator.Report{ got := s.report(discriminator.Report{
RequestID: denied.Header.Get(requestIDHeader), Status: http.StatusForbidden, RequestID: denied.Header.Get(requestIDHeader), Status: http.StatusForbidden,
ResponseBytes: 1000, DurationTotal: 2, ResponseBytes: 1000, DurationTotalMS: 2,
}) })
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
@@ -935,7 +935,7 @@ func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) {
got := s.report(discriminator.Report{ got := s.report(discriminator.Report{
RequestID: id, Status: http.StatusBadGateway, RequestID: id, Status: http.StatusBadGateway,
RequestBytes: bodyBytes, ResponseBytes: answerBytes, RequestBytes: bodyBytes, ResponseBytes: answerBytes,
DurationTotal: 5, DurationUpstreamTotal: 4, DurationTotalMS: 5, DurationUpstreamTotalMS: 4,
}) })
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
@@ -951,7 +951,8 @@ func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) {
unscraped := startDiscriminator(t, "", map[string]string{metricsToken: token}) unscraped := startDiscriminator(t, "", map[string]string{metricsToken: token})
got = unscraped.report(discriminator.Report{ got = unscraped.report(discriminator.Report{
RequestID: unscraped.allowed(client), Status: http.StatusBadGateway, DurationTotal: 5, RequestID: unscraped.allowed(client), Status: http.StatusBadGateway,
DurationTotalMS: 5,
}) })
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
wantMetric(t, unscraped.metrics(), wantMetric(t, unscraped.metrics(),
@@ -1189,8 +1190,8 @@ func wantReportsFields(t *testing.T, line logLine) {
if line.DiscriminatorLine != requestlog.DiscriminatorLineReport || if line.DiscriminatorLine != requestlog.DiscriminatorLineReport ||
line.Status != http.StatusCreated || line.Status != http.StatusCreated ||
line.RequestBytes != bodyBytes || line.ResponseBytes != answerBytes || line.RequestBytes != bodyBytes || line.ResponseBytes != answerBytes ||
line.DurationTotal != 12.5 || line.DurationUpstreamTotal == nil || line.DurationTotalMS != 12.5 || line.DurationUpstreamTotalMS == nil ||
*line.DurationUpstreamTotal != 10.25 { *line.DurationUpstreamTotalMS != 10.25 {
t.Errorf("the report's line lacks the report's fields: %+v", line.Line) t.Errorf("the report's line lacks the report's fields: %+v", line.Line)
} }
} }
@@ -1205,8 +1206,8 @@ func wantSameDecision(t *testing.T, reported, decided logLine) {
same.Status = decided.Status same.Status = decided.Status
same.RequestBytes = decided.RequestBytes same.RequestBytes = decided.RequestBytes
same.ResponseBytes = decided.ResponseBytes same.ResponseBytes = decided.ResponseBytes
same.DurationTotal = decided.DurationTotal same.DurationTotalMS = decided.DurationTotalMS
same.DurationUpstreamTotal = decided.DurationUpstreamTotal same.DurationUpstreamTotalMS = decided.DurationUpstreamTotalMS
same.Counts.MinuteBytes = decided.Counts.MinuteBytes same.Counts.MinuteBytes = decided.Counts.MinuteBytes
same.Counts.HourBytes = decided.Counts.HourBytes same.Counts.HourBytes = decided.Counts.HourBytes
same.Counts.DayBytes = decided.Counts.DayBytes same.Counts.DayBytes = decided.Counts.DayBytes
@@ -1224,7 +1225,7 @@ func wantDecisionsFields(t *testing.T, line logLine, id, query, accept string) {
if line.DiscriminatorLine != requestlog.DiscriminatorLineDecision || if line.DiscriminatorLine != requestlog.DiscriminatorLineDecision ||
line.Status != http.StatusOK || line.ResponseBytes != 0 || line.Status != http.StatusOK || line.ResponseBytes != 0 ||
line.DurationUpstreamTotal != nil || line.DurationChecks == nil || line.DurationUpstreamTotalMS != nil || line.DurationChecksMS == nil ||
line.RequestID != id || line.Query != query || line.RequestID != id || line.Query != query ||
!line.HasAuthorization || !line.HasCookie || !line.HasAuthorization || !line.HasCookie ||
line.RequestHeaders["accept"] != accept || len(line.RuleIDs) != 1 || line.RequestHeaders["accept"] != accept || len(line.RuleIDs) != 1 ||
@@ -1260,7 +1261,7 @@ func TestReportWritesTheLastLineWithTheDecisionsAndTheReportsFields(t *testing.T
got := s.report(discriminator.Report{ got := s.report(discriminator.Report{
RequestID: id, Status: http.StatusCreated, RequestID: id, Status: http.StatusCreated,
RequestBytes: bodyBytes, ResponseBytes: answerBytes, RequestBytes: bodyBytes, ResponseBytes: answerBytes,
DurationTotal: 12.5, DurationUpstreamTotal: 10.25, DurationTotalMS: 12.5, DurationUpstreamTotalMS: 10.25,
}) })
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
@@ -1298,14 +1299,14 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
}{ }{
{ {
"deny", map[string]string{denyNets: client}, "/", http.StatusForbidden, "deny", map[string]string{denyNets: client}, "/", http.StatusForbidden,
requestlog.ActionDenied, requestlog.ActionClientDenied,
}, },
{ {
"close", map[string]string{denyNets: client, banResponse: banResponseClose}, "/", "close", map[string]string{denyNets: client, banResponse: banResponseClose}, "/",
0, requestlog.ActionDenied, 0, requestlog.ActionClientDenied,
}, },
{"own prefix", nil, discriminator.HealthPath, http.StatusNotFound, {"own prefix", nil, discriminator.HealthPath, http.StatusNotFound,
requestlog.ActionAdmin}, requestlog.ActionOwnEndpoint},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1317,7 +1318,7 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
do(t, req) do(t, req)
got := s.report(discriminator.Report{ got := s.report(discriminator.Report{
RequestID: id, Status: tc.status, ResponseBytes: 8, DurationTotal: 3, RequestID: id, Status: tc.status, ResponseBytes: 8, DurationTotalMS: 3,
}) })
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
@@ -1325,8 +1326,8 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
wantLine(t, line, tc.status, tc.action) wantLine(t, line, tc.status, tc.action)
if line.DiscriminatorLine != requestlog.DiscriminatorLineReport || if line.DiscriminatorLine != requestlog.DiscriminatorLineReport ||
line.RequestID != id || line.ResponseBytes != 8 || line.DurationTotal != 3 || line.RequestID != id || line.ResponseBytes != 8 || line.DurationTotalMS != 3 ||
line.DurationUpstreamTotal != nil { line.DurationUpstreamTotalMS != nil {
t.Errorf("the report's line is %+v", line.Line) t.Errorf("the report's line is %+v", line.Line)
} }
}) })
@@ -1485,7 +1486,7 @@ func TestALargeBodyAnnouncedAndLongHeadersAreNoReasonToRefuse(t *testing.T) {
func TestLogLevelHoldsBackNoRequestLine(t *testing.T) { func TestLogLevelHoldsBackNoRequestLine(t *testing.T) {
t.Parallel() t.Parallel()
s := startDiscriminator(t, "", map[string]string{"SWW_LOG_LEVEL": "error"}) s := startDiscriminator(t, "", map[string]string{"SWW_WAF_LOG_LEVEL": "error"})
// A refused question would write a process warning, held back at error. // A refused question would write a process warning, held back at error.
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
@@ -1583,9 +1584,10 @@ func TestCoreRuleSetAlertNamesTheSite(t *testing.T) {
wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny)
waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventWAFBlock || if len(waiting) != 1 || waiting[0].Event != alerts.EventCoreRuleSetMatch ||
waiting[0].Host != siteA { waiting[0].Host != siteA {
t.Errorf("alerts waiting are %+v, want a waf_block naming %s", waiting, siteA) t.Errorf("alerts waiting are %+v, want a core_rule_set_match naming %s",
waiting, siteA)
} }
} }
+3 -3
View File
@@ -1,6 +1,6 @@
package discriminator package discriminator
// The heuristic dnsbl. It reads SWW_REPUTATION_ACTION, and the DNSBL zones' // The heuristic dnsbl. It reads SWW_WAF_REPUTATION_ACTION, and the DNSBL zones'
// verdicts through the DNSBL checker. // verdicts through the DNSBL checker.
import ( import (
@@ -14,7 +14,7 @@ import (
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed // dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses // does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
// the request when SWW_REPUTATION_ACTION is deny. Being // the request when SWW_WAF_REPUTATION_ACTION is deny. Being
// limit, it lowers the client's limits instead (see limitPercentages), and // limit, it lowers the client's limits instead (see limitPercentages), and
// being log, it does nothing more. A zone without a verdict on the client is // being log, it does nothing more. A zone without a verdict on the client is
// asked about it in the background, and the request does not wait for the // asked about it in the background, and the request does not wait for the
@@ -37,7 +37,7 @@ func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
} }
return &verdict{ return &verdict{
action: requestlog.ActionDenied, action: requestlog.ActionClientDenied,
figures: map[string]any{"zones": listedBy}, figures: map[string]any{"zones": listedBy},
} }
} }
+1 -1
View File
@@ -30,7 +30,7 @@ func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict
} }
return &verdict{ return &verdict{
action: requestlog.ActionTooLarge, action: requestlog.ActionRequestHeaderTooLarge,
figures: map[string]any{ figures: map[string]any{
"fields": fields, "fields_limit": maxHeaderFields, "fields": fields, "fields_limit": maxHeaderFields,
"cookies": cookies, "cookies_limit": maxCookies, "cookies": cookies, "cookies_limit": maxCookies,
+2 -1
View File
@@ -21,7 +21,8 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
) )
for range healthChecks { for range healthChecks {
got := s.adminRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK) got := s.ownEndpointRequest("", http.MethodGet, discriminator.HealthPath, "",
http.StatusOK)
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType { if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+ t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
+2 -1
View File
@@ -37,13 +37,14 @@ const (
) )
// The lists of heuristics, each in the order its heuristics run. A client in // The lists of heuristics, each in the order its heuristics run. A client in
// SWW_ALLOW_NETS skips them all. // SWW_WAF_ALLOW_NETS skips them all.
// beforeLookup are the heuristics that run before the client's AS number and // beforeLookup are the heuristics that run before the client's AS number and
// country are looked up, so that a client they refuse is not looked up. // country are looked up, so that a client they refuse is not looked up.
func beforeLookup() []heuristic { func beforeLookup() []heuristic {
return []heuristic{ return []heuristic{
tagLists{}, tagLists{},
routeWatchTags{},
denyNets{}, denyNets{},
activeBan{}, activeBan{},
} }
+4 -4
View File
@@ -31,9 +31,9 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
clk.advance(time.Second) clk.advance(time.Second)
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
clk.advance(time.Second) clk.advance(time.Second)
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned) s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
want := ratelimit.History{ want := ratelimit.History{
FirstSeen: start, FirstSeen: start,
@@ -100,8 +100,8 @@ func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token}) s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
s.adminRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK) s.ownEndpointRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK)
s.adminRequest("", http.MethodGet, discriminator.MetricsPath, "", s.ownEndpointRequest("", http.MethodGet, discriminator.MetricsPath, "",
http.StatusUnauthorized) http.StatusUnauthorized)
s.scrape() s.scrape()
+2 -2
View File
@@ -12,7 +12,7 @@ import (
) )
// The headers in which an answer of allow gives rproxy the client's AS number // The headers in which an answer of allow gives rproxy the client's AS number
// and country, for the app, while SWW_ADD_LOOKUP_HEADERS is set. Go writes // and country, for the app, while SWW_WAF_ADD_LOOKUP_HEADERS is set. Go writes
// every header name in this form, as it sends it and as it receives it, so // every header name in this form, as it sends it and as it receives it, so
// X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive. // X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive.
const ( const (
@@ -23,7 +23,7 @@ const (
// lookUp looks up the client's AS number and country, in the lookup // lookUp looks up the client's AS number and country, in the lookup
// database or through GeoJS, and notes them for the log line and as the // database or through GeoJS, and notes them for the log line and as the
// request's asn= and country= tags, unless // request's asn= and country= tags, unless
// SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or // SWW_WAF_LOOKUP_SOURCE is off or the client is on a private, loopback or
// link-local address, which no lookup can place. The lookup database // link-local address, which no lookup can place. The lookup database
// answers at once. With GeoJS, while a setting needs the answer, such as a // answers at once. With GeoJS, while a setting needs the answer, such as a
// country list or a biased threshold, a new client's request waits for it, // country list or a biased threshold, a new client's request waits for it,
+11 -11
View File
@@ -20,7 +20,7 @@ import (
// and country. // and country.
type asnAndCountry struct{ asn, asName, country string } type asnAndCountry struct{ asn, asName, country string }
// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the // fileSource is the SWW_WAF_LOOKUP_SOURCE that looks clients up in the
// lookup database. // lookup database.
const fileSource = "file" const fileSource = "file"
@@ -40,7 +40,7 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T)
// comes too. None waits for GeoJS. // comes too. None waits for GeoJS.
for _, line := range []logLine{ for _, line := range []logLine{
s.get(fromDE, http.StatusOK, requestlog.ActionForward), s.get(fromDE, http.StatusOK, requestlog.ActionForward),
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited), s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited),
s.get(fromKP, http.StatusOK, requestlog.ActionForward), s.get(fromKP, http.StatusOK, requestlog.ActionForward),
} { } {
got := asnAndCountry{line.ASN, line.ASName, line.Country} got := asnAndCountry{line.ASN, line.ASName, line.Country}
@@ -179,7 +179,7 @@ func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.
for _, line := range []logLine{ for _, line := range []logLine{
s.get(fromDE, http.StatusOK, requestlog.ActionForward), s.get(fromDE, http.StatusOK, requestlog.ActionForward),
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited), s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited),
} { } {
if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want { if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want {
t.Errorf("log line has %+v, want %+v", got, want) t.Errorf("log line has %+v, want %+v", got, want)
@@ -264,7 +264,7 @@ func TestClientsAreLookedUpInTheLookupDatabaseAndGeoJSIsNotAsked(t *testing.T) {
want asnAndCountry want asnAndCountry
}{ }{
{s.get(fromDE, http.StatusOK, requestlog.ActionForward), de}, {s.get(fromDE, http.StatusOK, requestlog.ActionForward), de},
{s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited), de}, {s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited), de},
{ {
s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied), s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
asnAndCountry{asnKP, asNameKP, "KP"}, asnAndCountry{asnKP, asNameKP, "KP"},
@@ -385,7 +385,7 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
// is never reached. // is never reached.
const timeout = 3 * time.Second const timeout = 3 * time.Second
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
map[string]string{ map[string]string{
lookupTimeout: timeout.String(), lookupTimeout: timeout.String(),
allowedCountries: "DE", allowedCountries: "DE",
@@ -405,25 +405,25 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
}) })
} }
// unansweredGeoJSURL is where a GeoJS that never answers is asked: a // silentGeoJSURL is where a GeoJS that never answers is asked: a
// request to it waits, without the network, until it is abandoned. // request to it waits, without the network, until it is abandoned.
// TestMain registers it with Go's default transport, through which GeoJS // TestMain registers it with Go's default transport, through which GeoJS
// is asked. // is asked.
const unansweredGeoJSURL = "unanswered://geojs/v1/ip/geo.json" const silentGeoJSURL = "silent://geojs/v1/ip/geo.json"
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
transport, _ := http.DefaultTransport.(*http.Transport) transport, _ := http.DefaultTransport.(*http.Transport)
transport.RegisterProtocol("unanswered", unansweredGeoJS{}) transport.RegisterProtocol("silent", silentGeoJS{})
transport.RegisterProtocol("abuseipdb", abuseIPDBStandIn{}) transport.RegisterProtocol("abuseipdb", abuseIPDBStandIn{})
m.Run() m.Run()
} }
// unansweredGeoJS is the GeoJS at unansweredGeoJSURL. // silentGeoJS is the GeoJS at silentGeoJSURL.
type unansweredGeoJS struct{} type silentGeoJS struct{}
// RoundTrip waits until req is abandoned. // RoundTrip waits until req is abandoned.
func (unansweredGeoJS) RoundTrip(req *http.Request) (*http.Response, error) { func (silentGeoJS) RoundTrip(req *http.Request) (*http.Response, error) {
<-req.Context().Done() <-req.Context().Done()
return nil, req.Context().Err() return nil, req.Context().Err()
+18 -18
View File
@@ -17,9 +17,9 @@ import (
) )
const ( const (
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
metricsTopN = "SWW_METRICS_TOP_N" metricsTopN = "SWW_WAF_METRICS_TOP_N"
// token is the SWW_METRICS_TOKEN the tests set, and bearer how a // token is the SWW_WAF_METRICS_TOKEN the tests set, and bearer how a
// request carries it. // request carries it.
token = "0123456789abcdef0123456789abcdef" token = "0123456789abcdef0123456789abcdef"
bearer = "Bearer " + token bearer = "Bearer " + token
@@ -32,7 +32,7 @@ func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) {
// An empty token does not match the unset one either. // An empty token does not match the unset one either.
for _, authorization := range []string{bearer, "Bearer ", ""} { for _, authorization := range []string{bearer, "Bearer ", ""} {
s.adminRequest(authorization, http.MethodGet, discriminator.MetricsPath, "", s.ownEndpointRequest(authorization, http.MethodGet, discriminator.MetricsPath, "",
http.StatusNotFound) http.StatusNotFound)
} }
} }
@@ -60,7 +60,7 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
{http.MethodGet, "/_sww/bans", bearer, http.StatusNotFound}, {http.MethodGet, "/_sww/bans", bearer, http.StatusNotFound},
{http.MethodPost, discriminator.HealthPath, "", http.StatusNotFound}, {http.MethodPost, discriminator.HealthPath, "", http.StatusNotFound},
} { } {
got := s.adminRequest(tc.authorization, tc.method, tc.path, "", tc.status) got := s.ownEndpointRequest(tc.authorization, tc.method, tc.path, "", tc.status)
if tc.status == http.StatusUnauthorized && if tc.status == http.StatusUnauthorized &&
got.header.Get("WWW-Authenticate") != "Bearer" { got.header.Get("WWW-Authenticate") != "Bearer" {
@@ -91,7 +91,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
out.endedLines(t, 2) out.endedLines(t, 2)
forward := `{action="forward",instance="app",status_class="2xx"}` forward := `{action="forward",instance="app",status_class="2xx"}`
notFound := `{action="admin",instance="app",status_class="4xx"}` notFound := `{action="own_endpoint",instance="app",status_class="4xx"}`
// The request for the metrics is itself under way. // The request for the metrics is itself under way.
metrics := scrape(t, server.Addr) metrics := scrape(t, server.Addr)
@@ -113,7 +113,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
func TestMetricsCountLimitsAndBans(t *testing.T) { func TestMetricsCountLimitsAndBans(t *testing.T) {
t.Parallel() t.Parallel()
const denied = "192.0.2.50" // in SWW_DENY_NETS const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
s, clk, _ := startWithClock(t, "", map[string]string{ s, clk, _ := startWithClock(t, "", map[string]string{
metricsToken: token, metricsToken: token,
@@ -124,16 +124,16 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
maxBanDuration: "2h", maxBanDuration: "2h",
}) })
// SWW_BAN_RESPONSE=close sends no status at all. // SWW_WAF_BAN_RESPONSE=close sends no status at all.
s.get(denied, 0, requestlog.ActionDenied) s.get(denied, 0, requestlog.ActionClientDenied)
// A first broken limit bans for an hour. // A first broken limit bans for an hour.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, 0, requestlog.ActionRateLimited) s.get(client, 0, requestlog.ActionClientIsRateLimited)
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`sww_requests_total{action="denied",instance="app",status_class="none"}`, 1) `sww_requests_total{action="client_denied",instance="app",status_class="none"}`, 1)
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+ wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
`kind="requests",window="minute"}`, 1) `kind="requests",window="minute"}`, 1)
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1) wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
@@ -145,9 +145,9 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0) wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0)
// A limit broken again right after would ban for three hours, longer // A limit broken again right after would ban for three hours, longer
// than SWW_MAX_BAN_DURATION, so the ban is permanent. // than SWW_WAF_MAX_BAN_DURATION, so the ban is permanent.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, 0, requestlog.ActionRateLimited) s.get(client, 0, requestlog.ActionClientIsRateLimited)
metrics = s.scrape() metrics = s.scrape()
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+ wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
@@ -327,13 +327,13 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
tagDeny: denyKP, tagDeny: denyKP,
}) })
// GeoJS fails, so the client has no country= tag, and SWW_TAG_DENY does // GeoJS fails, so the client has no country= tag, and SWW_WAF_TAG_DENY does
// not refuse it. // not refuse it.
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(forwardedFor, fromDE) req.Header.Set(forwardedFor, fromDE)
wantStatus(t, do(t, req), http.StatusOK) wantStatus(t, do(t, req), http.StatusOK)
// The client stops waiting for GeoJS once SWW_LOOKUP_TIMEOUT is up, so // The client stops waiting for GeoJS once SWW_WAF_LOOKUP_TIMEOUT is up, so
// GeoJS's failure can come after its request has ended. // GeoJS's failure can come after its request has ended.
deadline := time.Now().Add(waitLimit) deadline := time.Now().Add(waitLimit)
metrics := scrape(t, server.Addr) metrics := scrape(t, server.Addr)
@@ -347,7 +347,7 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
wantMetric(t, metrics, `sww_geojs_requests_total{instance="app"}`, 1) wantMetric(t, metrics, `sww_geojs_requests_total{instance="app"}`, 1)
wantMetric(t, metrics, `sww_geojs_failures_total{instance="app"}`, 1) wantMetric(t, metrics, `sww_geojs_failures_total{instance="app"}`, 1)
wantMetric(t, metrics, `sww_geojs_unanswered_total{instance="app"}`, 1) wantMetric(t, metrics, `sww_requests_without_geojs_answer_total{instance="app"}`, 1)
} }
// keptAnswer returns GeoJS's answer that the client at addr is in // keptAnswer returns GeoJS's answer that the client at addr is in
@@ -381,8 +381,8 @@ func scrape(t *testing.T, addr string) string {
func (s *sender) scrape() string { func (s *sender) scrape() string {
s.t.Helper() s.t.Helper()
return string(s.adminRequest(bearer, http.MethodGet, discriminator.MetricsPath, "", return string(s.ownEndpointRequest(bearer, http.MethodGet, discriminator.MetricsPath,
http.StatusOK).body) "", http.StatusOK).body)
} }
// metric returns the value of series in metrics, which are in the // metric returns the value of series in metrics, which are in the
+98 -10
View File
@@ -7,13 +7,101 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// observe is the value of SWW_MODE for observe mode. // observe is the value of SWW_WAF_MODE for observe mode.
const observe = "observe" const observe = "observe"
func TestBanActionSaysWhetherTheBanRunsOut(t *testing.T) {
t.Parallel()
const trapPath = "/wp-login.php"
for _, tc := range []struct {
name string
cause string
// endsIn is how long after the request the ban ends: negative for a
// ban that has ended, and zero for a permanent ban.
endsIn time.Duration
path string
want string
}{
{
"temporary ban", bans.CauseAdmin, time.Hour, "/",
requestlog.ActionTemporarilyBanned,
},
{"permanent ban", bans.CauseAdmin, 0, "/", requestlog.ActionPermanentlyBanned},
{
"request that makes a ban permanent", bans.CauseAttack, time.Hour, "/",
requestlog.ActionPermanentlyBanned,
},
{
"trap path after an attack ban ended", bans.CauseAttack, -time.Hour,
trapPath, requestlog.ActionPermanentlyBanned,
},
{
"first trap path after another ban ended", bans.CauseAdmin, -time.Hour,
trapPath, requestlog.ActionTemporarilyBanned,
},
} {
for _, modeValue := range []string{"enforce", observe} {
t.Run(tc.name+" in "+modeValue, func(t *testing.T) {
t.Parallel()
s, clk, server := startWithClock(t, "", map[string]string{
trapPaths: trapPathList, mode: modeValue,
})
ban := bans.Ban{
Netblock: netip.MustParsePrefix(client + "/32"),
Start: clk.Now().Add(-2 * time.Hour),
Cause: tc.cause,
}
if tc.endsIn != 0 {
ban.Expires = clk.Now().Add(tc.endsIn)
}
server.Ledger.Load([]bans.Ban{ban})
if modeValue == observe {
line := s.request(client, tc.path, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, tc.want)
return
}
s.request(client, tc.path, http.StatusForbidden, tc.want)
})
}
}
}
func TestObserveModeNamesAPermanentBanWhoseAlertWouldBeSent(t *testing.T) {
t.Parallel()
s, clk, server, queue := startWithAlerts(t, map[string]string{
trapPaths: trapPathList, mode: observe,
})
server.Ledger.Load([]bans.Ban{{
Netblock: netip.MustParsePrefix(client + "/32"),
Start: clk.Now().Add(-2 * time.Hour),
Expires: clk.Now().Add(-time.Hour),
Cause: bans.CauseAttack,
}})
line := s.request(client, "/wp-login.php", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionPermanentlyBanned)
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventPermanentBan {
t.Errorf("alerts waiting are %+v, want one %s", waiting, alerts.EventPermanentBan)
}
}
func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) { func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
t.Parallel() t.Parallel()
@@ -22,7 +110,7 @@ func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
}) })
line := s.get(client, http.StatusOK, requestlog.ActionForward) line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionDenied) wantWouldAction(t, line, requestlog.ActionClientDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`) wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`)
} }
@@ -30,12 +118,12 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
denied = "192.0.2.50" // in SWW_DENY_NETS denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
banned = otherClient // under a ban read from bans.json banned = otherClient // under a ban read from bans.json
) )
for _, tc := range []struct { for _, tc := range []struct {
setting string // "" leaves SWW_MODE at its default setting string // "" leaves SWW_WAF_MODE at its default
observe bool observe bool
}{ }{
{"", false}, {"", false},
@@ -69,10 +157,10 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
for _, sent := range []struct{ from, refusal string }{ for _, sent := range []struct{ from, refusal string }{
{denied, requestlog.ActionDenied}, {denied, requestlog.ActionClientDenied},
{banned, requestlog.ActionBanned}, {banned, requestlog.ActionTemporarilyBanned},
{fromKP, requestlog.ActionDenied}, {fromKP, requestlog.ActionClientDenied},
{fromDE, requestlog.ActionRateLimited}, {fromDE, requestlog.ActionClientIsRateLimited},
} { } {
if !tc.observe { if !tc.observe {
line := s.get(sent.from, http.StatusForbidden, sent.refusal) line := s.get(sent.from, http.StatusForbidden, sent.refusal)
@@ -110,7 +198,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
for range 2 { for range 2 {
line := s.get(client, http.StatusOK, requestlog.ActionForward) line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionRateLimited) wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit || if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
line.BanExpires != "" { line.BanExpires != "" {
@@ -123,7 +211,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
// The ban read from bans.json refuses nothing, and so counts no // The ban read from bans.json refuses nothing, and so counts no
// refusal in its notes, but is kept. // refusal in its notes, but is kept.
line := s.get(otherClient, http.StatusOK, requestlog.ActionForward) line := s.get(otherClient, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned) wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
if line.BanExpires != requestlog.FormatTime(kept.Expires) { if line.BanExpires != requestlog.FormatTime(kept.Expires) {
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires, t.Errorf("log line has ban_expires %q, want %s", line.BanExpires,
+37 -29
View File
@@ -16,32 +16,33 @@ import (
"sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/metrics" "sneak.berlin/go/smallwebwaf/internal/metrics"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/routewatch"
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/waf"
) )
// adminPrefix starts the path of each of discriminator's own endpoints. The app never // ownEndpointPrefix starts the path of each of discriminator's own endpoints. The app
// sees a path under it. // never sees a path under it.
const adminPrefix = "/_sww/" const ownEndpointPrefix = "/_sww/"
// HealthPath is the health endpoint of discriminator and of rproxy, which the // HealthPath is the health endpoint of discriminator and of rproxy, which the
// container's health check asks. // container's health check asks.
const HealthPath = "/_sww/healthz" const HealthPath = "/_sww/healthz"
// MetricsPath is where the metrics are, for a request that carries // MetricsPath is where the metrics are, for a request that carries
// SWW_METRICS_TOKEN. // SWW_WAF_METRICS_TOKEN.
const MetricsPath = "/_sww/metrics" const MetricsPath = "/_sww/metrics"
// BansPath is where an admin lists and adds bans, and, followed by / and // BansPath is where an admin lists and adds bans, and, followed by / and
// a client's address, lifts them, with SWW_ADMIN_TOKEN. // a client's address, lifts them, with SWW_WAF_ADMIN_TOKEN.
const BansPath = "/_sww/bans" const BansPath = "/_sww/bans"
// ClientsPath is where an admin asks what discriminator knows of a client, by // ClientsPath is where an admin asks what discriminator knows of a client, by
// the client's address after it, with SWW_ADMIN_TOKEN. // the client's address after it, with SWW_WAF_ADMIN_TOKEN.
const ClientsPath = "/_sww/clients/" const ClientsPath = "/_sww/clients/"
// Params are what New needs. // Params are what New needs.
@@ -54,17 +55,17 @@ type Params struct {
// ProcessLog receives the process's own messages. // ProcessLog receives the process's own messages.
ProcessLog *slog.Logger ProcessLog *slog.Logger
// GeoJSURL is where clients' AS numbers and countries are looked up // GeoJSURL is where clients' AS numbers and countries are looked up
// while SWW_LOOKUP_SOURCE is geojs, normally lookup.URL. // while SWW_WAF_LOOKUP_SOURCE is geojs, normally lookup.URL.
GeoJSURL string GeoJSURL string
// AbuseIPDBURL is where clients are checked with AbuseIPDB while // AbuseIPDBURL is where clients are checked with AbuseIPDB while
// SWW_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL. // SWW_WAF_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL.
AbuseIPDBURL string AbuseIPDBURL string
// LookupFile is the lookup database they are looked up in while // LookupFile is the lookup database they are looked up in while
// SWW_LOOKUP_SOURCE is file, and nil otherwise. // SWW_WAF_LOOKUP_SOURCE is file, and nil otherwise.
LookupFile *lookup.File LookupFile *lookup.File
// CoreRuleSet is what NewCoreRuleSet returns: what inspects requests // CoreRuleSet is what NewCoreRuleSet returns: what inspects requests
// while SWW_WAF_MODE is not off, and nil while it is. // while SWW_WAF_CORE_RULE_SET_MODE is not off, and nil while it is.
CoreRuleSet *waf.CoreRuleSet CoreRuleSet *coreruleset.CoreRuleSet
// Now tells the time by which requests are counted for the rate // Now tells the time by which requests are counted for the rate
// limits, bans are made and run out, and GeoJS's answers are kept, // limits, bans are made and run out, and GeoJS's answers are kept,
// normally time.Now in UTC, the time the state files give. // normally time.Now in UTC, the time the state files give.
@@ -76,16 +77,17 @@ type Params struct {
// permanent, for each count over an anomaly threshold, for each request // permanent, for each count over an anomaly threshold, for each request
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or // whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
// AbuseIPDB lists, for each request the Core Rule Set scores at or over // AbuseIPDB lists, for each request the Core Rule Set scores at or over
// SWW_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing, // failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
// or the day's AbuseIPDB checks used up. // or the day's AbuseIPDB checks used up.
Alerts *alerts.Queue Alerts *alerts.Queue
} }
// Server is the server discriminator runs, with the parts whose state the state // Server is the server discriminator runs, with the parts whose state the state
// files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the // files keep, the lookup database, nil unless SWW_WAF_LOOKUP_SOURCE is file, the
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts, // lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
// AbuseIPDB's scores and checks spent, and the metrics. // AbuseIPDB's scores and checks spent, and the metrics. RouteWatch, nil while
// SWW_WAF_ROUTEWATCH_URL is unset, keeps its answers in memory only.
type Server struct { type Server struct {
*http.Server *http.Server
@@ -97,6 +99,7 @@ type Server struct {
Lists *reputation.Lists Lists *reputation.Lists
DNSBL *reputation.DNSBL DNSBL *reputation.DNSBL
AbuseIPDB *reputation.AbuseIPDB AbuseIPDB *reputation.AbuseIPDB
RouteWatch *routewatch.Client
Metrics *metrics.Metrics Metrics *metrics.Metrics
} }
@@ -126,7 +129,7 @@ func newHandler(params Params) *handler {
BytesPerHour: params.Config.BytesLimitPerHour, BytesPerHour: params.Config.BytesLimitPerHour,
BytesPerDay: params.Config.BytesLimitPerDay, BytesPerDay: params.Config.BytesLimitPerDay,
}, params.Config.MaxTrackedClients), }, params.Config.MaxTrackedClients),
ledger: bans.New(bans.Rules{ ledger: bans.New(bans.Params{
LimitBanDuration: params.Config.LimitBanDuration, LimitBanDuration: params.Config.LimitBanDuration,
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow, LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
MaxBanDuration: params.Config.MaxBanDuration, MaxBanDuration: params.Config.MaxBanDuration,
@@ -152,6 +155,12 @@ func newHandler(params Params) *handler {
coreRuleSet: params.CoreRuleSet, coreRuleSet: params.CoreRuleSet,
alerts: params.Alerts, alerts: params.Alerts,
} }
if params.Config.RouteWatchURL != "" {
h.routeWatch = routewatch.New(routewatch.Params{
URL: params.Config.RouteWatchURL, Now: params.Now, ProcessLog: params.ProcessLog,
})
}
h.geojs = lookup.New(lookup.Params{ h.geojs = lookup.New(lookup.Params{
URL: params.GeoJSURL, URL: params.GeoJSURL,
Timeout: params.Config.LookupTimeout, Timeout: params.Config.LookupTimeout,
@@ -176,7 +185,7 @@ func newHandler(params Params) *handler {
// newReputation returns the lists fetched from URLs, the DNSBL zones' // newReputation returns the lists fetched from URLs, the DNSBL zones'
// verdicts and AbuseIPDB's scores, as the settings in params name them, // verdicts and AbuseIPDB's scores, as the settings in params name them,
// with none fetched, asked for or checked yet, and adds their metrics to // with none fetched, asked for or checked yet, and adds their metrics to
// m, AbuseIPDB's while SWW_ABUSEIPDB_KEY is set. // m, AbuseIPDB's while SWW_WAF_ABUSEIPDB_KEY is set.
func newReputation( func newReputation(
params Params, m *metrics.Metrics, params Params, m *metrics.Metrics,
) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) { ) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) {
@@ -209,29 +218,27 @@ func newReputation(
return lists, dnsbl, abuseIPDB return lists, dnsbl, abuseIPDB
} }
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL, // NewCoreRuleSet returns the Core Rule Set at SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL,
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies // without the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off, reading bodies
// up to SWW_WAF_BODY_LIMIT, with the exclusions in // up to SWW_WAF_CORE_RULE_SET_BODY_LIMIT, with the exclusions in
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_APPS, // SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_CORE_RULE_SET_MODE is
// or nil while SWW_WAF_MODE is off. It // off. It reads at most QuestionBodyMaxBytes less one byte: a question's body of
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a // QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
// JSON or XML body over its limit. An exclusions file that waf.New refuses is // JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
// an error in every mode. While the mode is off, a set file is loaded with the // an error in every mode. While the mode is off, a set file is loaded with the
// Core Rule Set only to check it. // Core Rule Set only to check it.
func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) { func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" { if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
} }
coreRuleSet, err := waf.New(waf.Params{ coreRuleSet, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules, ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1), BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile, ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
Apps: cfg.Apps,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err) return nil, fmt.Errorf("SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
} }
if cfg.WAFMode == config.WAFModeOff { if cfg.WAFMode == config.WAFModeOff {
@@ -257,8 +264,9 @@ type handler struct {
lists *reputation.Lists lists *reputation.Lists
dnsbl *reputation.DNSBL dnsbl *reputation.DNSBL
abuseIPDB *reputation.AbuseIPDB abuseIPDB *reputation.AbuseIPDB
routeWatch *routewatch.Client
rules *rules.Files rules *rules.Files
coreRuleSet *waf.CoreRuleSet coreRuleSet *coreruleset.CoreRuleSet
alerts *alerts.Queue alerts *alerts.Queue
// awaitingReport holds the decision on each answered question by its id // awaitingReport holds the decision on each answered question by its id
// until its report comes, and nil for a question still being handled. // until its report comes, and nil for a question still being handled.
+43 -50
View File
@@ -17,6 +17,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
"sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
@@ -55,35 +56,35 @@ const (
// The settings the tests set. // The settings the tests set.
const ( const (
mode = "SWW_MODE" mode = "SWW_WAF_MODE"
trustedProxies = "SWRP_TRUSTED_PROXIES" trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX" ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS" maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
allowNets = "SWW_ALLOW_NETS" allowNets = "SWW_WAF_ALLOW_NETS"
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS" rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWW_DENY_NETS" denyNets = "SWW_WAF_DENY_NETS"
rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE" rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY" rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS" rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
lookupSource = "SWW_LOOKUP_SOURCE" lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_LOOKUP_DB_PATH" lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
lookupTimeout = "SWW_LOOKUP_TIMEOUT" lookupTimeout = "SWW_WAF_LOOKUP_TIMEOUT"
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS" addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
tagDeny = "SWW_TAG_DENY" tagDeny = "SWW_WAF_TAG_DENY"
// denyKP is the value of SWW_TAG_DENY that refuses fromKP's country. // denyKP is the value of SWW_WAF_TAG_DENY that refuses fromKP's country.
denyKP = "country=KP" denyKP = "country=KP"
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES" allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
banResponse = "SWW_BAN_RESPONSE" banResponse = "SWW_WAF_BAN_RESPONSE"
limitBanDuration = "SWW_LIMIT_BAN_DURATION" limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW" limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
maxBanDuration = "SWW_MAX_BAN_DURATION" maxBanDuration = "SWW_WAF_MAX_BAN_DURATION"
maxBans = "SWW_MAX_BANS" maxBans = "SWW_WAF_MAX_BANS"
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX" banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
instanceName = "SWW_INSTANCE_NAME" instanceName = "SWW_WAF_INSTANCE_NAME"
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS" logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
attackBanDuration = "SWW_ATTACK_BAN_DURATION" attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
rulesDir = "SWW_RULES_DIR" rulesDir = "SWW_WAF_RULES_DIR"
wafMode = "SWW_WAF_MODE" wafMode = "SWW_WAF_CORE_RULE_SET_MODE"
) )
// off is the value that switches a setting off. // off is the value that switches a setting off.
@@ -296,8 +297,8 @@ func startProxyWithGeoJS(
// startProxyWithClock is startProxyWithGeoJS with requests counted and // startProxyWithClock is startProxyWithGeoJS with requests counted and
// bans made by the time now tells, and returns discriminator's server as well, whose // bans made by the time now tells, and returns discriminator's server as well, whose
// Addr is where discriminator listens. Unless env sets SWW_RULES_DIR, it is an empty // Addr is where discriminator listens. Unless env sets SWW_WAF_RULES_DIR, it is an
// directory, of no rules, and unless it sets SWW_INSTANCE_NAME, that is app, // empty directory, of no rules, and unless it sets SWW_WAF_INSTANCE_NAME, that is app,
// the label instance of every metric. // the label instance of every metric.
func startProxyWithClock( func startProxyWithClock(
t *testing.T, appURL, geojsURL string, now func() time.Time, t *testing.T, appURL, geojsURL string, now func() time.Time,
@@ -347,25 +348,21 @@ func serve(t *testing.T, server *discriminator.Server) {
// startRProxy starts rproxy in front of the app at appURL, asking the discriminator // startRProxy starts rproxy in front of the app at appURL, asking the discriminator
// at discriminatorAddr, with the settings in env on top of the defaults, and returns // at discriminatorAddr, with the settings in env on top of the defaults, and returns
// where it listens. What it writes is dropped. SWRP_TIMEOUT_MS is a minute unless // where it listens. What it writes is dropped. SWW_RPROXY_TIMEOUT_MS is a minute unless
// env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide. // env sets it, so that on a busy host SWW_RPROXY_DEFAULT_DISPOSITION does not decide.
func startRProxy( func startRProxy(
t *testing.T, discriminatorAddr, appURL string, env map[string]string, t *testing.T, discriminatorAddr, appURL string, env map[string]string,
) string { ) string {
t.Helper() t.Helper()
settings := map[string]string{ settings := map[string]string{
"SWRP_WAF_BACKEND": "http://" + discriminatorAddr, "SWW_RPROXY_WAF_BACKEND": "http://" + discriminatorAddr,
"SWRP_UPSTREAM_URL": appURL, "SWW_RPROXY_UPSTREAM_URL": appURL,
"SWRP_TIMEOUT_MS": "60000", "SWW_RPROXY_TIMEOUT_MS": "60000",
} }
maps.Copy(settings, env) maps.Copy(settings, env)
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) { cfg, err := config.RProxyFromEnvironment(configtest.Viper(settings))
value, ok := settings[name]
return value, ok
})
if err != nil { if err != nil {
t.Fatalf("settings %v: %v", settings, err) t.Fatalf("settings %v: %v", settings, err)
} }
@@ -397,11 +394,11 @@ func startRProxy(
// settings in env make it. No alert is sent from the queue: they wait in it, // settings in env make it. No alert is sent from the queue: they wait in it,
// for the test to look at. // for the test to look at.
// With no geojsURL, there is no stand-in for GeoJS to look clients up at, and // With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
// SWW_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup // SWW_WAF_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup
// database SWW_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB // database SWW_WAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
// at abuseIPDBURL while env sets SWW_ABUSEIPDB_KEY. SWW_WAF_MODE is off unless // at abuseIPDBURL while env sets SWW_WAF_ABUSEIPDB_KEY.
// env sets it, so that only the tests of the Core Rule Set have their requests // SWW_WAF_CORE_RULE_SET_MODE is off unless env sets it, so that only the tests of the
// inspected by it. // Core Rule Set have their requests inspected by it.
func newDiscriminator( func newDiscriminator(
t *testing.T, geojsURL string, now func() time.Time, env map[string]string, t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
) (*discriminator.Server, *output, *alerts.Queue) { ) (*discriminator.Server, *output, *alerts.Queue) {
@@ -426,11 +423,7 @@ func newDiscriminatorWith(
maps.Copy(settings, env) maps.Copy(settings, env)
cfg, err := config.FromEnvironment(func(name string) (string, bool) { cfg, err := config.FromEnvironment(configtest.Viper(settings))
value, ok := settings[name]
return value, ok
})
if err != nil { if err != nil {
t.Fatalf("settings %v: %v", settings, err) t.Fatalf("settings %v: %v", settings, err)
} }
+13 -9
View File
@@ -1,7 +1,7 @@
package discriminator package discriminator
// The heuristic rate_limits. It reads SWW_RATE_LIMIT_EXEMPT_NETS, // The heuristic rate_limits. It reads SWW_WAF_RATE_LIMIT_EXEMPT_NETS,
// SWW_RATE_LIMIT_EXEMPT_PATHS and SWW_BAN_RESPONSE, and the rate limits and // SWW_WAF_RATE_LIMIT_EXEMPT_PATHS and SWW_WAF_BAN_RESPONSE, and the rate limits and
// biased thresholds through the limiter and limitPercentages. // biased thresholds through the limiter and limitPercentages.
import ( import (
@@ -28,12 +28,14 @@ func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict
return nil return nil
} }
rq.limitPercent, rq.bytesPercent = rq.limitPercentages() rq.rateLimitPercent, rq.bytesLimitPercent = rq.limitPercentages()
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged() rq.line.RateLimitPercent, rq.line.RateLimitPercentSetting =
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged() rq.rateLimitPercent.logged()
rq.line.BytesLimitPercent, rq.line.BytesLimitPercentSetting =
rq.bytesLimitPercent.logged()
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now, counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
rq.limitPercent.percent) rq.rateLimitPercent.percent)
rq.line.Counts = counts rq.line.Counts = counts
if !over { if !over {
@@ -42,7 +44,9 @@ func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict
rq.banForLimit(now, hit, rq.h.config.BanResponse) rq.banForLimit(now, hit, rq.h.config.BanResponse)
return &verdict{action: requestlog.ActionRateLimited, figures: limitFigures(hit)} return &verdict{
action: requestlog.ActionClientIsRateLimited, figures: limitFigures(hit),
}
} }
// overAWholeRateLimit reports whether the heuristic rate_limits will refuse the // overAWholeRateLimit reports whether the heuristic rate_limits will refuse the
@@ -55,8 +59,8 @@ func (rq *request) overAWholeRateLimit(now time.Time) bool {
} }
// countedByRateLimits reports whether the rate limits count the request: its // countedByRateLimits reports whether the rate limits count the request: its
// client is not in SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt // client is not in SWW_WAF_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
// under SWW_RATE_LIMIT_EXEMPT_PATHS. // under SWW_WAF_RATE_LIMIT_EXEMPT_PATHS.
func (rq *request) countedByRateLimits() bool { func (rq *request) countedByRateLimits() bool {
cfg := rq.h.config cfg := rq.h.config
+15 -12
View File
@@ -10,7 +10,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// minute is the window of SWW_RATE_LIMIT_PER_MINUTE, as a log line's // minute is the window of SWW_WAF_RATE_LIMIT_PER_MINUTE, as a log line's
// limit_hit names it. // limit_hit names it.
const minute = "minute" const minute = "minute"
@@ -59,7 +59,7 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
if sent.want == http.StatusOK { if sent.want == http.StatusOK {
wantLine(t, line, http.StatusOK, requestlog.ActionForward) wantLine(t, line, http.StatusOK, requestlog.ActionForward)
} else { } else {
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited) wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
if line.LimitHit != minute { if line.LimitHit != minute {
t.Errorf("log line has limit_hit %q, want minute", line.LimitHit) t.Errorf("log line has limit_hit %q, want minute", line.LimitHit)
@@ -79,14 +79,14 @@ func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited) line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`) wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`)
} }
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) { func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
t.Parallel() t.Parallel()
// With SWW_IPV6_GROUP_PREFIX at 48, the first two addresses, in two // With SWW_WAF_IPV6_GROUP_PREFIX at 48, the first two addresses, in two
// /64s of one /48, are one client, and the second's request breaks the // /64s of one /48, are one client, and the second's request breaks the
// limit; the third, in the next /48, is another client. // limit; the third, in the next /48, is another client.
const ( const (
@@ -102,7 +102,7 @@ func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
status int status int
action string action string
}{ }{
{rateLimitPerMinute, "1", http.StatusForbidden, requestlog.ActionRateLimited}, {rateLimitPerMinute, "1", http.StatusForbidden, requestlog.ActionClientIsRateLimited},
{bytesLimitPerMinute, byteLimit, http.StatusOK, requestlog.ActionForward}, {bytesLimitPerMinute, byteLimit, http.StatusOK, requestlog.ActionForward},
} { } {
t.Run(tc.setting, func(t *testing.T) { t.Run(tc.setting, func(t *testing.T) {
@@ -129,7 +129,7 @@ func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) { func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
t.Parallel() t.Parallel()
const denied = "192.0.2.50" // in SWW_DENY_NETS const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
geojsURL, _ := startGeoJS(t) geojsURL, _ := startGeoJS(t)
s, _, server := startWithClock(t, geojsURL, map[string]string{ s, _, server := startWithClock(t, geojsURL, map[string]string{
@@ -160,14 +160,17 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
// A path outside every prefix is counted: /assets is not under // A path outside every prefix is counted: /assets is not under
// /assets/, and breaks the limit. // /assets/, and breaks the limit.
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited) s.request(client, "/assets", http.StatusForbidden,
requestlog.ActionClientIsRateLimited)
// A ban, SWW_DENY_NETS and SWW_TAG_DENY still refuse a path under a // A ban, SWW_WAF_DENY_NETS and SWW_WAF_TAG_DENY still refuse a path under a
// prefix. // prefix.
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/assets/app.js", http.StatusForbidden,
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied) requestlog.ActionTemporarilyBanned)
s.request(denied, "/assets/app.js", http.StatusForbidden,
requestlog.ActionClientDenied)
s.request(fromKP, "/assets/app.js", s.request(fromKP, "/assets/app.js",
http.StatusForbidden, requestlog.ActionDenied) http.StatusForbidden, requestlog.ActionClientDenied)
} }
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) { func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
@@ -211,7 +214,7 @@ func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
// Counted, the second request breaks the limit of one request // Counted, the second request breaks the limit of one request
// a minute. // a minute.
s.request(client, sent, http.StatusOK, requestlog.ActionForward) s.request(client, sent, http.StatusOK, requestlog.ActionForward)
s.request(client, sent, http.StatusForbidden, requestlog.ActionRateLimited) s.request(client, sent, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
}) })
} }
} }
+8 -8
View File
@@ -1,7 +1,7 @@
package discriminator package discriminator
// The heuristic refusal_limit. It reads SWW_ERROR_BURST_THRESHOLD and // The heuristic refusal_limit. It reads SWW_WAF_ERROR_BURST_THRESHOLD and
// SWW_BAN_RESPONSE. // SWW_WAF_BAN_RESPONSE.
import ( import (
"context" "context"
@@ -14,8 +14,8 @@ import (
// refusalLimit counts the request once it has been answered, if discriminator // refusalLimit counts the request once it has been answered, if discriminator
// refused it after a rule file match, a trap path or a Core Rule Set match, and // refused it after a rule file match, a trap path or a Core Rule Set match, and
// in observe mode if enforce mode would have: more than // in observe mode if enforce mode would have: more than
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute break // SWW_WAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
// a limit, which bans the client. A client in SWW_ALLOW_NETS, which the // a limit, which bans the client. A client in SWW_WAF_ALLOW_NETS, which the
// heuristics skip, is not counted, and nothing is while the threshold is off. // heuristics skip, is not counted, and nothing is while the threshold is off.
// Its figures are the window, the count and the limit. // Its figures are the window, the count and the limit.
type refusalLimit struct{} type refusalLimit struct{}
@@ -28,7 +28,7 @@ func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdic
return nil return nil
} }
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked { if !rq.attack && !rq.blockedByBlockRule && !rq.blockedByCoreRuleSet {
return nil return nil
} }
@@ -42,13 +42,13 @@ func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdic
status := rq.out.status status := rq.out.status
switch rq.line.WouldAction { switch rq.line.WouldAction {
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked: case requestlog.ActionBlockedByBlockRule, requestlog.ActionBlockedByCoreRuleSet:
status = http.StatusForbidden status = http.StatusForbidden
case requestlog.ActionBanned: case requestlog.ActionTemporarilyBanned, requestlog.ActionPermanentlyBanned:
status = cfg.BanResponse status = cfg.BanResponse
} }
rq.banForLimit(now, hit, status) rq.banForLimit(now, hit, status)
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)} return &verdict{figures: limitFigures(hit)}
} }
+35 -22
View File
@@ -15,7 +15,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
const errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD" const errorBurstThreshold = "SWW_WAF_ERROR_BURST_THRESHOLD"
// refused is a request the tests here send, which discriminator refuses after a rule // refused is a request the tests here send, which discriminator refuses after a rule
// file match. // file match.
@@ -37,9 +37,10 @@ func (r refused) send(s *sender, from string) logLine {
switch r { switch r {
case blockRule: case blockRule:
return s.request(from, blockedPath, http.StatusForbidden, return s.request(from, blockedPath, http.StatusForbidden,
requestlog.ActionRuleBlocked) requestlog.ActionBlockedByBlockRule)
case banRule: case banRule:
return s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned) return s.request(from, probePath, http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
} }
s.t.Fatalf("no request for the refusal %d", r) s.t.Fatalf("no request for the refusal %d", r)
@@ -48,7 +49,7 @@ func (r refused) send(s *sender, from string) logLine {
} }
// startForErrorBurst is startWithClock with testRules, the metrics token and // startForErrorBurst is startWithClock with testRules, the metrics token and
// SWW_ERROR_BURST_THRESHOLD at threshold, and the settings in env. // SWW_WAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
func startForErrorBurst( func startForErrorBurst(
t *testing.T, threshold string, env map[string]string, t *testing.T, threshold string, env map[string]string,
) (*sender, *clock, *discriminator.Server) { ) (*sender, *clock, *discriminator.Server) {
@@ -71,9 +72,20 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
name string name string
// refusals are four, one over the threshold of three. // refusals are four, one over the threshold of three.
refusals []refused refusals []refused
// nextAction is the action of the client's next request: the next
// request after a ban for a ban rule makes it permanent.
nextAction string
}{ }{
{"block rule", []refused{blockRule, blockRule, blockRule, blockRule}}, {
{"a ban rule last", []refused{blockRule, blockRule, blockRule, banRule}}, "block rule",
[]refused{blockRule, blockRule, blockRule, blockRule},
requestlog.ActionTemporarilyBanned,
},
{
"a ban rule last",
[]refused{blockRule, blockRule, blockRule, banRule},
requestlog.ActionPermanentlyBanned,
},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -95,13 +107,13 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
// The fourth is answered as the others were, breaks the error // The fourth is answered as the others were, breaks the error
// burst, and bans the client. // burst, and bans the client.
line := tc.refusals[3].send(s, client) line := tc.refusals[3].send(s, client)
if line.LimitHit != requestlog.LimitHitErrorBurst || if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
line.Offence != requestlog.OffenceLimit { line.Offence != requestlog.OffenceLimit {
t.Errorf("log line has limit_hit %q and offence %q, want error_burst "+ t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals "+
"and limit", line.LimitHit, line.Offence) "and limit", line.LimitHit, line.Offence)
} }
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, tc.nextAction)
}) })
} }
} }
@@ -169,7 +181,7 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
t.Fatalf("bans\n%+v\nwant\n%+v", got, want) t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
} }
wantOffences := ratelimit.Offences{Limit: 1, RuleBlocked: 3} wantOffences := ratelimit.Offences{Limit: 1, BlockedByBlockRule: 3}
if offences := historyOf(t, server, client).Offences; offences != wantOffences { if offences := historyOf(t, server, client).Offences; offences != wantOffences {
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences) t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
} }
@@ -179,7 +191,7 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
`kind="refusals",window="minute"}`, 1) `kind="refusals",window="minute"}`, 1)
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1) wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
wantMetric(t, metrics, wantMetric(t, metrics,
`sww_offences_total{instance="app",kind="rule_blocked"}`, 3) `sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 3)
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1) wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
} }
@@ -265,7 +277,8 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
line = blockRule.send(s, client) line = blockRule.send(s, client)
} }
if broken := line.LimitHit == requestlog.LimitHitErrorBurst; broken != tc.broken { broken := line.LimitHit == requestlog.LimitHitMinuteRefusals
if broken != tc.broken {
t.Errorf("the 31st refusal broke the error burst: %t, want %t", t.Errorf("the 31st refusal broke the error burst: %t, want %t",
broken, tc.broken) broken, tc.broken)
} }
@@ -277,15 +290,15 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
allowed = "192.0.2.60" // in SWW_ALLOW_NETS allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS exempt = "192.0.2.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
) )
s, _, _ := startForErrorBurst(t, "1", map[string]string{ s, _, _ := startForErrorBurst(t, "1", map[string]string{
allowNets: allowed, rateLimitExemptNets: exempt, allowNets: allowed, rateLimitExemptNets: exempt,
}) })
// A client in SWW_ALLOW_NETS is not refused, and so not counted. // A client in SWW_WAF_ALLOW_NETS is not refused, and so not counted.
for range 3 { for range 3 {
s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward) s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
} }
@@ -294,11 +307,11 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
blockRule.send(s, exempt) blockRule.send(s, exempt)
line := blockRule.send(s, exempt) line := blockRule.send(s, exempt)
if line.LimitHit != requestlog.LimitHitErrorBurst { if line.LimitHit != requestlog.LimitHitMinuteRefusals {
t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit) t.Errorf("log line has limit_hit %q, want minute_refusals", line.LimitHit)
} }
s.get(exempt, http.StatusForbidden, requestlog.ActionBanned) s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) { func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
@@ -341,7 +354,7 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
for range 2 { for range 2 {
line := s.request(otherClient, blockedPath, http.StatusOK, line := s.request(otherClient, blockedPath, http.StatusOK,
requestlog.ActionForward) requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned) wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
if line.LimitHit != "" { if line.LimitHit != "" {
t.Errorf("log line has limit_hit %q, want none", line.LimitHit) t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
@@ -353,10 +366,10 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward) s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward) line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionRuleBlocked) wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
if line.LimitHit != requestlog.LimitHitErrorBurst || line.BanExpires != "" { if line.LimitHit != requestlog.LimitHitMinuteRefusals || line.BanExpires != "" {
t.Errorf("log line has limit_hit %q and ban_expires %q, want error_burst "+ t.Errorf("log line has limit_hit %q and ban_expires %q, want minute_refusals "+
"and none", line.LimitHit, line.BanExpires) "and none", line.LimitHit, line.BanExpires)
} }
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
) )
// deny is the SWW_BLOCKLIST_ACTION and the SWW_REPUTATION_ACTION that // deny is the SWW_WAF_BLOCKLIST_ACTION and the SWW_WAF_REPUTATION_ACTION that
// refuses the requests of a client a source lists. // refuses the requests of a client a source lists.
const deny = "deny" const deny = "deny"
+72 -62
View File
@@ -23,12 +23,12 @@ import (
// The reputation settings. // The reputation settings.
const ( const (
blocklistURLs = "SWW_BLOCKLIST_URLS" blocklistURLs = "SWW_WAF_BLOCKLIST_URLS"
blocklistAction = "SWW_BLOCKLIST_ACTION" blocklistAction = "SWW_WAF_BLOCKLIST_ACTION"
asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL" asnLimitPercentURL = "SWW_WAF_ASN_LIMIT_PERCENT_URL"
) )
// The actions of SWW_BLOCKLIST_ACTION and SWW_REPUTATION_ACTION: // The actions of SWW_WAF_BLOCKLIST_ACTION and SWW_WAF_REPUTATION_ACTION:
// limitHalf gives a listed client half of every limit, and limitQuarter a // limitHalf gives a listed client half of every limit, and limitQuarter a
// quarter. // quarter.
const ( const (
@@ -49,12 +49,12 @@ const (
func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) { func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
t.Parallel() t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionDenied forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
for _, tc := range []struct { for _, tc := range []struct {
action string action string
// statuses and actions are those of a listed client's three // statuses and actions are those of a listed client's three
// requests, and percent their limit_percent, as percentText gives it. // requests, and percent their rate_limit_percent, as percentText gives it.
statuses []int statuses []int
actions []string actions []string
percent string percent string
@@ -66,7 +66,7 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
{ {
// Half of 4 requests a minute: the third breaks the limit. // Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden}, limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionRateLimited}, []string{forward, forward, requestlog.ActionClientIsRateLimited},
"50 from " + blocklistAction, "50 from " + blocklistAction,
}, },
{ {
@@ -90,8 +90,8 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
for i := range 3 { for i := range 3 {
line := s.get(from, tc.statuses[i], tc.actions[i]) line := s.get(from, tc.statuses[i], tc.actions[i])
wantReputation(t, line, dropURL) wantReputation(t, line, dropURL)
wantPercent(t, "limit_percent", line.LimitPercent, wantPercent(t, "rate_limit_percent", line.RateLimitPercent,
line.LimitPercentSetting, tc.percent) line.RateLimitPercentSetting, tc.percent)
// A request refused for the list is not counted. // A request refused for the list is not counted.
counted := line.fields["counts"] != nil counted := line.fields["counts"] != nil
@@ -106,7 +106,8 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
for range 3 { for range 3 {
line := s.get(unplaced, http.StatusOK, forward) line := s.get(unplaced, http.StatusOK, forward)
wantReputation(t, line) wantReputation(t, line)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
none) none)
} }
@@ -162,7 +163,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T)
s, server, _ := startWithLookups(t, tc.env) s, server, _ := startWithLookups(t, tc.env)
tc.load(t, server) tc.load(t, server)
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied) line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, tc.heuristic, tc.figures) wantHeuristic(t, line, tc.heuristic, tc.figures)
}) })
} }
@@ -177,7 +178,7 @@ func TestBlocklistsComeAfterAllowedCountriesAndSkipAllowNets(t *testing.T) {
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}}) loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
// fromKP's country refuses it before the list is looked at, and fromDE, // fromKP's country refuses it before the list is looked at, and fromDE,
// in SWW_ALLOW_NETS, is not checked at all: neither is noted, nor // in SWW_WAF_ALLOW_NETS, is not checked at all: neither is noted, nor
// alerted. // alerted.
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)) wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied))
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward)) wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
@@ -193,7 +194,7 @@ func TestObserveModeForwardsAClientABlocklistDeniesAndAlertsIt(t *testing.T) {
loadLists(t, server, map[string][]string{dropURL: {fromDE}}) loadLists(t, server, map[string][]string{dropURL: {fromDE}})
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward) line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionDenied) wantWouldAction(t, line, requestlog.ActionClientDenied)
wantReputation(t, line, dropURL) wantReputation(t, line, dropURL)
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
@@ -207,7 +208,7 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
for _, tc := range []struct { for _, tc := range []struct {
action, asnPercent string action, asnPercent string
// want is the upload's limit_percent and bytes_percent, as // want is the upload's rate_limit_percent and bytes_limit_percent, as
// percentText gives them, and limitHit its limit_hit. // percentText gives them, and limitHit its limit_hit.
want, limitHit string want, limitHit string
}{ }{
@@ -229,9 +230,11 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
// The upload's 100 bytes are over 49, a quarter of 199, and 99, // The upload's 100 bytes are over 49, a quarter of 199, and 99,
// half of it, and within 199. // half of it, and within 199.
line := s.uploadFrom(fromDE) line := s.uploadFrom(fromDE)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.want) tc.want)
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
tc.want) tc.want)
if line.LimitHit != tc.limitHit { if line.LimitHit != tc.limitHit {
@@ -253,9 +256,9 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
name string name string
env map[string]string env map[string]string
file string file string
// limitPercent and bytesPercent are the upload's, as percentText // rateLimitPercent and bytesLimitPercent are the upload's, as
// gives them. // percentText gives them.
limitPercent, bytesPercent string rateLimitPercent, bytesLimitPercent string
}{ }{
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL}, {"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
{ {
@@ -274,7 +277,7 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
}, },
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none}, {"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
{ {
"SWW_ASN_BYTES_PERCENT's in place of the file's for the byte limits", "SWW_WAF_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none, map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
}, },
} { } {
@@ -288,10 +291,12 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
loadLists(t, server, map[string][]string{asnURL: {"# by AS number", tc.file}}) loadLists(t, server, map[string][]string{asnURL: {"# by AS number", tc.file}})
line := s.uploadFrom(fromDE) line := s.uploadFrom(fromDE)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
tc.limitPercent) line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, tc.rateLimitPercent)
tc.bytesPercent) wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
tc.bytesLimitPercent)
}) })
} }
} }
@@ -362,9 +367,9 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
// The DNSBL settings. // The DNSBL settings.
const ( const (
dnsblZones = "SWW_DNSBL_ZONES" dnsblZones = "SWW_WAF_DNSBL_ZONES"
dnsblResolver = "SWW_DNSBL_RESOLVER" dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
reputationAction = "SWW_REPUTATION_ACTION" reputationAction = "SWW_WAF_REPUTATION_ACTION"
) )
// The DNSBL zones these tests name, which are never asked about the // The DNSBL zones these tests name, which are never asked about the
@@ -380,12 +385,12 @@ const (
func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) { func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
t.Parallel() t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionDenied forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
for _, tc := range []struct { for _, tc := range []struct {
action string action string
// statuses and actions are those of a listed client's three // statuses and actions are those of a listed client's three
// requests, and percent their limit_percent, as percentText gives it. // requests, and percent their rate_limit_percent, as percentText gives it.
statuses []int statuses []int
actions []string actions []string
percent string percent string
@@ -397,7 +402,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
{ {
// Half of 4 requests a minute: the third breaks the limit. // Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden}, limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionRateLimited}, []string{forward, forward, requestlog.ActionClientIsRateLimited},
"50 from " + reputationAction, "50 from " + reputationAction,
}, },
{ {
@@ -420,10 +425,10 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
for _, from := range []string{fromDE, fromKP} { for _, from := range []string{fromDE, fromKP} {
for i := range 3 { for i := range 3 {
line := s.get(from, tc.statuses[i], tc.actions[i]) line := s.get(from, tc.statuses[i], tc.actions[i])
// In the order SWW_DNSBL_ZONES names them. // In the order SWW_WAF_DNSBL_ZONES names them.
wantReputation(t, line, listedBy[from]...) wantReputation(t, line, listedBy[from]...)
wantPercent(t, "limit_percent", line.LimitPercent, wantPercent(t, "rate_limit_percent", line.RateLimitPercent,
line.LimitPercentSetting, tc.percent) line.RateLimitPercentSetting, tc.percent)
// A request refused for the verdict is not counted. // A request refused for the verdict is not counted.
counted := line.fields["counts"] != nil counted := line.fields["counts"] != nil
@@ -438,7 +443,8 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
for range 3 { for range 3 {
line := s.get(unplaced, http.StatusOK, forward) line := s.get(unplaced, http.StatusOK, forward)
wantReputation(t, line) wantReputation(t, line)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
none) none)
} }
@@ -466,9 +472,9 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}}) loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}})
// The blocklist refuses fromKP before its verdict is looked at, and // The blocklist refuses fromKP before its verdict is looked at, and
// fromDE, in SWW_ALLOW_NETS, is not checked at all: neither is noted // fromDE, in SWW_WAF_ALLOW_NETS, is not checked at all: neither is noted
// for the zone, nor alerted, nor asked about. // for the zone, nor alerted, nor asked about.
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied), wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
dropURL) dropURL)
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward)) wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
@@ -532,7 +538,7 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}}) loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward) line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionDenied) wantWouldAction(t, line, requestlog.ActionClientDenied)
wantReputation(t, line, dnsblZone) wantReputation(t, line, dnsblZone)
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
@@ -546,7 +552,7 @@ func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
for _, tc := range []struct { for _, tc := range []struct {
blocklistAction, reputationAction string blocklistAction, reputationAction string
// want is the request's limit_percent and bytes_percent, as // want is the request's rate_limit_percent and bytes_limit_percent, as
// percentText gives them. // percentText gives them.
want string want string
}{ }{
@@ -571,9 +577,11 @@ func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
// Named by the blocklist, then by the zone. // Named by the blocklist, then by the zone.
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward) line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantReputation(t, line, dropURL, dnsblZone) wantReputation(t, line, dropURL, dnsblZone)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.want) tc.want)
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
tc.want) tc.want)
}) })
} }
@@ -699,7 +707,7 @@ func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) {
// The AbuseIPDB settings, and accountKey, the key the tests set. // The AbuseIPDB settings, and accountKey, the key the tests set.
const ( const (
abuseIPDBKey = "SWW_ABUSEIPDB_KEY" abuseIPDBKey = "SWW_WAF_ABUSEIPDB_KEY"
accountKey = "abuseipdb-key-0123456789abcdef" accountKey = "abuseipdb-key-0123456789abcdef"
) )
@@ -741,8 +749,8 @@ func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(t *testing.T
s.get(fromDE, http.StatusOK, forward) s.get(fromDE, http.StatusOK, forward)
s.get(fromDE, http.StatusOK, forward) s.get(fromDE, http.StatusOK, forward)
s.get(fromKP, http.StatusOK, forward) s.get(fromKP, http.StatusOK, forward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned) s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantAbuseIPDBChecks(t, server, 0) wantAbuseIPDBChecks(t, server, 0)
// Once the ban has ended, fromDE's first request has it checked in the // Once the ban has ended, fromDE's first request has it checked in the
@@ -781,7 +789,7 @@ func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing
s.get(addresses[0], http.StatusOK, forward) s.get(addresses[0], http.StatusOK, forward)
} }
s.get(addresses[0], http.StatusForbidden, requestlog.ActionRateLimited) s.get(addresses[0], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
clk.advance(time.Hour) clk.advance(time.Hour)
// Once the ban has ended, which set its counters back to zero, a // Once the ban has ended, which set its counters back to zero, a
@@ -814,20 +822,20 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
want ratelimit.Offences want ratelimit.Offences
}{ }{
{ {
"a block rule", blockedPath, http.StatusForbidden, requestlog.ActionRuleBlocked, "a block rule", blockedPath, http.StatusForbidden,
ratelimit.Offences{RuleBlocked: 1}, requestlog.ActionBlockedByBlockRule, ratelimit.Offences{BlockedByBlockRule: 1},
}, },
{ {
"the Core Rule Set", sqlInjection, http.StatusForbidden, "the Core Rule Set", sqlInjection, http.StatusForbidden,
requestlog.ActionWAFBlocked, ratelimit.Offences{WAFBlocked: 1}, requestlog.ActionBlockedByCoreRuleSet, ratelimit.Offences{BlockedByCoreRuleSet: 1},
}, },
{ {
"a ban rule", probePath, http.StatusForbidden, requestlog.ActionBanned, "a ban rule", probePath, http.StatusForbidden, requestlog.ActionTemporarilyBanned,
ratelimit.Offences{Attack: 1}, ratelimit.Offences{Attack: 1},
}, },
{ {
"a trap path", "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned, "a trap path", "/xmlrpc.php", http.StatusForbidden,
ratelimit.Offences{Attack: 1}, requestlog.ActionTemporarilyBanned, ratelimit.Offences{Attack: 1},
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -858,12 +866,12 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) { func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) {
t.Parallel() t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionDenied forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
for _, tc := range []struct { for _, tc := range []struct {
action string action string
// statuses and actions are those of fromDE's three requests, and // statuses and actions are those of fromDE's three requests, and
// percent their limit_percent, as percentText gives it. // percent their rate_limit_percent, as percentText gives it.
statuses []int statuses []int
actions []string actions []string
percent string percent string
@@ -875,7 +883,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
{ {
// Half of 4 requests a minute: the third breaks the limit. // Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden}, limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionRateLimited}, []string{forward, forward, requestlog.ActionClientIsRateLimited},
"50 from " + reputationAction, "50 from " + reputationAction,
}, },
{ {
@@ -890,13 +898,14 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey, rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
reputationAction: tc.action, reputationAction: tc.action,
}) })
// At SWW_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it. // At SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74}) loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
for i := range 3 { for i := range 3 {
line := s.get(fromDE, tc.statuses[i], tc.actions[i]) line := s.get(fromDE, tc.statuses[i], tc.actions[i])
wantReputation(t, line, abuseipdb) wantReputation(t, line, abuseipdb)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.percent) tc.percent)
// A request refused for the score is not counted. // A request refused for the score is not counted.
@@ -910,7 +919,8 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
for range 3 { for range 3 {
line := s.get(fromKP, http.StatusOK, forward) line := s.get(fromKP, http.StatusOK, forward)
wantReputation(t, line) wantReputation(t, line)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
none) none)
} }
@@ -941,7 +951,7 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
// The alert is made as a DNSBL zone's is, with the score besides. // The alert is made as a DNSBL zone's is, with the score besides.
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit || if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
waiting[0].Reason != "scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE" || waiting[0].Reason != "scored by AbuseIPDB at or over SWW_WAF_ABUSEIPDB_MIN_SCORE" ||
waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) || waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) ||
queue.Suppressed() != 1 { queue.Suppressed() != 1 {
t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+ t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
@@ -972,7 +982,7 @@ func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
// fromDE's score is not used, and once it has committed an offence it // fromDE's score is not used, and once it has committed an offence it
// is not checked either. // is not checked either.
wantReputation(t, s.get(fromDE, http.StatusOK, forward)) wantReputation(t, s.get(fromDE, http.StatusOK, forward))
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
clk.advance(time.Hour) clk.advance(time.Hour)
wantReputation(t, s.get(fromDE, http.StatusOK, forward)) wantReputation(t, s.get(fromDE, http.StatusOK, forward))
wantAbuseIPDBChecks(t, server, 0) wantAbuseIPDBChecks(t, server, 0)
@@ -996,10 +1006,10 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
}{ }{
{"for a broken rate limit", func(s *sender, from string) { {"for a broken rate limit", func(s *sender, from string) {
s.get(from, http.StatusOK, requestlog.ActionForward) s.get(from, http.StatusOK, requestlog.ActionForward)
s.get(from, http.StatusForbidden, requestlog.ActionRateLimited) s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
}}, }},
{"for a clear sign of attack", func(s *sender, from string) { {"for a clear sign of attack", func(s *sender, from string) {
s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned) s.request(from, probePath, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}}, }},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -1012,7 +1022,7 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
abuseIPDBKey: accountKey, reputationAction: actionLog, abuseIPDBKey: accountKey, reputationAction: actionLog,
}) })
// Every source lists client, and none otherClient, whose score is // Every source lists client, and none otherClient, whose score is
// under SWW_ABUSEIPDB_MIN_SCORE, 75 by default. // under SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
loadLists(t, server, map[string][]string{dropURL: {client}}) loadLists(t, server, map[string][]string{dropURL: {client}})
loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil}) loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil})
loadScores(server, map[string]int64{client: score, otherClient: 74}) loadScores(server, map[string]int64{client: score, otherClient: 74})
@@ -1061,7 +1071,7 @@ func verdictsFetched() time.Time {
} }
// loadVerdicts puts into server's DNSBL, for each client listedBy names, // loadVerdicts puts into server's DNSBL, for each client listedBy names,
// a verdict of each zone SWW_DNSBL_ZONES names, fetched at // a verdict of each zone SWW_WAF_DNSBL_ZONES names, fetched at
// verdictsFetched, as reputation.json would at start: one that lists the // verdictsFetched, as reputation.json would at start: one that lists the
// client from each zone listedBy gives for it, and one that does not from // client from each zone listedBy gives for it, and one that does not from
// each other zone. // each other zone.
+29 -28
View File
@@ -44,20 +44,20 @@ type decision struct {
lookedUp bool lookedUp bool
lookupAnswer lookup.Answer lookupAnswer lookup.Answer
// skippedLookupWait is true for a request that did not wait for GeoJS and // skippedLookupWait is true for a request that did not wait for GeoJS and
// found no answer kept. SWW_UNKNOWN_LIMIT_PERCENT does not apply to it, // found no answer kept. SWW_WAF_UNKNOWN_LIMIT_PERCENT does not apply to it,
// so that not waiting never lowers its client's limits. // so that not waiting never lowers its client's limits.
skippedLookupWait bool skippedLookupWait bool
// counted is true for a request the rate limits counted, whose bytes // counted is true for a request the rate limits counted, whose bytes
// the byte limits count once it has ended. limitPercent and // the byte limits count once it has ended. rateLimitPercent and
// bytesPercent are then its client's limit percentages for the rate // bytesLimitPercent are then its client's limit percentages for the rate
// limits and for the byte limits. // limits and for the byte limits.
counted bool counted bool
limitPercent, bytesPercent percentage rateLimitPercent, bytesLimitPercent percentage
// attack is true for a request that matched a ban rule or asked for a // attack is true for a request that matched a ban rule or asked for a
// trap path, ruleBlocked for one a block rule refused, and wafBlocked for // trap path, blockedByBlockRule for one a block rule refused, and
// one the Core Rule Set refused, each an offence its client's history // blockedByCoreRuleSet for one the Core Rule Set refused, each an offence
// counts. // its client's history counts.
attack, ruleBlocked, wafBlocked bool attack, blockedByBlockRule, blockedByCoreRuleSet bool
// reputation is the reputation sources that list the client, for the // reputation is the reputation sources that list the client, for the
// notes of a ban the request makes. // notes of a ban the request makes.
reputation []bans.ReputationHit reputation []bans.ReputationHit
@@ -170,7 +170,7 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
// check is the one place where a request can be refused. It returns nil to let // check is the one place where a request can be refused. It returns nil to let
// the request through. The heuristics of checkClient decide: a request they // the request through. The heuristics of checkClient decide: a request they
// refuse is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core // refuse is answered with SWW_WAF_BAN_RESPONSE, 403 for a block rule or the Core
// Rule Set, or 431 for the header limits. In observe mode nothing is refused, // Rule Set, or 431 for the header limits. In observe mode nothing is refused,
// and the log line names what enforce mode would have done. ctx is the // and the log line names what enforce mode would have done. ctx is the
// request's own context. // request's own context.
@@ -187,9 +187,10 @@ func (rq *request) check(ctx context.Context) *refusal {
rq.line.WouldAction = action rq.line.WouldAction = action
return nil return nil
case action == requestlog.ActionRuleBlocked || action == requestlog.ActionWAFBlocked: case action == requestlog.ActionBlockedByBlockRule ||
action == requestlog.ActionBlockedByCoreRuleSet:
return &refusal{status: http.StatusForbidden, action: action} return &refusal{status: http.StatusForbidden, action: action}
case action == requestlog.ActionTooLarge: case action == requestlog.ActionRequestHeaderTooLarge:
return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action} return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action}
default: default:
return rq.banResponse(action) return rq.banResponse(action)
@@ -199,7 +200,7 @@ func (rq *request) check(ctx context.Context) *refusal {
// checkClient runs the heuristics of beforeLookup on the request, then looks // checkClient runs the heuristics of beforeLookup on the request, then looks
// up the client's AS number and country, then runs those of afterLookup. It // up the client's AS number and country, then runs those of afterLookup. It
// returns the first verdict, or nil when none gives one. A client in // returns the first verdict, or nil when none gives one. A client in
// SWW_ALLOW_NETS skips them all, and is not looked up. ctx is the request's // SWW_WAF_ALLOW_NETS skips them all, and is not looked up. ctx is the request's
// own context. // own context.
func (rq *request) checkClient(ctx context.Context) *verdict { func (rq *request) checkClient(ctx context.Context) *verdict {
if isInside(rq.client, rq.h.config.AllowNets) { if isInside(rq.client, rq.h.config.AllowNets) {
@@ -223,7 +224,7 @@ func (rq *request) checkClient(ctx context.Context) *verdict {
} }
// pathExempt reports whether r is exempt under prefixes, // pathExempt reports whether r is exempt under prefixes,
// SWW_RATE_LIMIT_EXEMPT_PATHS or SWW_WAF_EXEMPT_PATHS: whether its // SWW_WAF_RATE_LIMIT_EXEMPT_PATHS or SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS: whether its
// path as sent, the path the app receives, not percent-decoded, starts // path as sent, the path the app receives, not percent-decoded, starts
// with one of prefixes, so that /%61ssets/x is not under /assets/ for an // with one of prefixes, so that /%61ssets/x is not under /assets/ for an
// app whose router matches the path as received. A request whose decoded // app whose router matches the path as received. A request whose decoded
@@ -261,8 +262,8 @@ func (rq *request) finish() {
} }
duration := time.Since(rq.start) duration := time.Since(rq.start)
line.DurationTotal = requestlog.Milliseconds(duration) line.DurationTotalMS = requestlog.Milliseconds(duration)
line.DurationChecks = timing(rq.start, rq.checked) line.DurationChecksMS = timing(rq.start, rq.checked)
// Counted before the log line is written, so that the metrics count // Counted before the log line is written, so that the metrics count
// every request whose line is out. An allowed request is the exception: // every request whose line is out. An allowed request is the exception:
@@ -303,15 +304,15 @@ func timing(start, end time.Time) *float64 {
// when it comes. // when it comes.
func (rq *request) addToHistory() { func (rq *request) addToHistory() {
request := ratelimit.Request{ request := ratelimit.Request{
Forwarded: rq.allowed, Forwarded: rq.allowed,
Refused: rq.refused != nil, Refused: rq.refused != nil,
Status: rq.out.status, Status: rq.out.status,
RequestBytes: rq.requestBytes, RequestBytes: rq.requestBytes,
ResponseBytes: rq.out.bytes, ResponseBytes: rq.out.bytes,
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit, BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
Attack: rq.attack, Attack: rq.attack,
RuleBlocked: rq.ruleBlocked, BlockedByBlockRule: rq.blockedByBlockRule,
WAFBlocked: rq.wafBlocked, BlockedByCoreRuleSet: rq.blockedByCoreRuleSet,
} }
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request) rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request)
@@ -325,9 +326,9 @@ func (rq *request) addToHistory() {
// countAnomalies counts the request, which has ended, and its bytes, as // countAnomalies counts the request, which has ended, and its bytes, as
// countedBytes gives them, for the anomaly thresholds, whatever was done // countedBytes gives them, for the anomaly thresholds, whatever was done
// with it: a request refused, one from a client in SWW_ALLOW_NETS or // with it: a request refused, one from a client in SWW_WAF_ALLOW_NETS or
// SWW_RATE_LIMIT_EXEMPT_NETS, and one for a path in // SWW_WAF_RATE_LIMIT_EXEMPT_NETS, and one for a path in
// SWW_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its // SWW_WAF_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its
// client's AS number when answerAtTheEnd gives one. With every anomaly // client's AS number when answerAtTheEnd gives one. With every anomaly
// threshold off, the default, it does nothing. // threshold off, the default, it does nothing.
func (rq *request) countAnomalies() { func (rq *request) countAnomalies() {
+14 -14
View File
@@ -19,7 +19,7 @@ import (
const ( const (
// requestIDHeader carries the request's id. // requestIDHeader carries the request's id.
requestIDHeader = "X-Request-ID" requestIDHeader = "X-Request-ID"
// instance is the SWW_INSTANCE_NAME a test sets. // instance is the SWW_WAF_INSTANCE_NAME a test sets.
instance = "fsn1app1/blog" instance = "fsn1app1/blog"
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate // ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
// limits count it as. // limits count it as.
@@ -156,9 +156,9 @@ func wantBareLine(t *testing.T, line logLine) {
// withTimings returns want with the timings of line. // withTimings returns want with the timings of line.
func withTimings(line logLine, want requestlog.Line) requestlog.Line { func withTimings(line logLine, want requestlog.Line) requestlog.Line {
want.DurationTotal = line.DurationTotal want.DurationTotalMS = line.DurationTotalMS
want.DurationChecks = line.DurationChecks want.DurationChecksMS = line.DurationChecksMS
want.DurationUpstreamTotal = line.DurationUpstreamTotal want.DurationUpstreamTotalMS = line.DurationUpstreamTotalMS
return want return want
} }
@@ -196,7 +196,7 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
func TestTimingsAreInOrder(t *testing.T) { func TestTimingsAreInOrder(t *testing.T) {
t.Parallel() t.Parallel()
const denied = "192.0.2.50" // in SWW_DENY_NETS const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
// The pause sets the times apart; a hold-up of the test only lengthens // The pause sets the times apart; a hold-up of the test only lengthens
@@ -226,10 +226,10 @@ func TestTimingsAreInOrder(t *testing.T) {
// A request passed to the app has every timing; one refused, none of // A request passed to the app has every timing; one refused, none of
// the app's; the health check, which runs no check, only the total. // the app's; the health check, which runs no check, only the total.
wantTimings(t, forwarded, "duration_total", "duration_checks", wantTimings(t, forwarded, "duration_total_ms", "duration_checks_ms",
"duration_upstream_total") "duration_upstream_total_ms")
wantTimings(t, refused, "duration_total", "duration_checks") wantTimings(t, refused, "duration_total_ms", "duration_checks_ms")
wantTimings(t, health, "duration_total") wantTimings(t, health, "duration_total_ms")
if t.Failed() { if t.Failed() {
return return
@@ -237,9 +237,9 @@ func TestTimingsAreInOrder(t *testing.T) {
// In whole microseconds, as they are logged, so that the sum below is // In whole microseconds, as they are logged, so that the sum below is
// exact. // exact.
total := microseconds(forwarded.DurationTotal) total := microseconds(forwarded.DurationTotalMS)
checks := microseconds(*forwarded.DurationChecks) checks := microseconds(*forwarded.DurationChecksMS)
upstream := microseconds(*forwarded.DurationUpstreamTotal) upstream := microseconds(*forwarded.DurationUpstreamTotalMS)
// The checks end before the request is handed to the app. // The checks end before the request is handed to the app.
if checks+upstream > total { if checks+upstream > total {
@@ -247,9 +247,9 @@ func TestTimingsAreInOrder(t *testing.T) {
total, checks, upstream) total, checks, upstream)
} }
if *refused.DurationChecks > refused.DurationTotal { if *refused.DurationChecksMS > refused.DurationTotalMS {
t.Errorf("refused request's checks took %v of %v milliseconds", t.Errorf("refused request's checks took %v of %v milliseconds",
*refused.DurationChecks, refused.DurationTotal) *refused.DurationChecksMS, refused.DurationTotalMS)
} }
} }
+43
View File
@@ -0,0 +1,43 @@
package discriminator
// The heuristic routewatch_tags. It reads SWW_WAF_ROUTEWATCH_URL.
import (
"context"
"strconv"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
)
// routeWatchTags adds to the request the tags asn=<number> and prefix=<netblock>
// from what routewatch answered about the client. It refuses nothing, and the
// request does not wait for routewatch. A request that gets no tags from it has
// the reason in the log line's routewatch_tags_missing. While
// SWW_WAF_ROUTEWATCH_URL is unset it does nothing. Answers are kept by client and
// asked for by the request's address, and a route without an origin AS gives only
// the prefix= tag.
type routeWatchTags struct{}
func (routeWatchTags) name() string { return "routewatch_tags" }
func (routeWatchTags) check(ctx context.Context, rq *request, _ time.Time) *verdict {
if rq.h.routeWatch == nil {
return nil
}
route, missing := rq.h.routeWatch.Route(ctx, rq.h.clientGroup(rq.client), rq.client)
if missing != "" {
rq.line.RouteWatchTagsMissing = string(missing)
return nil
}
rq.addTag(config.PrefixTagPrefix + route.Netblock.String())
if route.ASN != 0 {
rq.addTag(config.ASNTagPrefix + strconv.FormatUint(uint64(route.ASN), 10))
}
return nil
}
@@ -0,0 +1,245 @@
package discriminator_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// routeWatchURL is SWW_WAF_ROUTEWATCH_URL, and tagsMissing the log line's field
// that says why a request got no tags from routewatch.
const (
routeWatchURL = "SWW_WAF_ROUTEWATCH_URL"
tagsMissing = "routewatch_tags_missing"
fromV6 = "2001:db8:1::5"
prefixV6 = "2001:db8::/32"
prefixDE = "203.0.113.0/24"
prefixKP = "198.51.100.0/24"
)
// standInRoute is what the stand-in for routewatch answers about an address.
type standInRoute struct {
Netblock string `json:"netblock"`
ASN int `json:"asn"`
}
// startRouteWatch starts a stand-in for routewatch's IP info API that knows the
// routes given by address, and returns its URL.
func startRouteWatch(t *testing.T, routes map[string]standInRoute) string {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
address := strings.TrimPrefix(r.URL.Path, "/api/v1/ip/")
result := map[string]any{"ip": address}
if route, known := routes[address]; known {
result["netblock"], result["asn"] = route.Netblock, route.ASN
}
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"data": map[string]any{"results": []any{result}},
})
}))
t.Cleanup(server.Close)
return server.URL
}
// waitForRouteWatch waits until routewatch's answer about from, in the client
// group of the given length, is kept, so that the next request gets its tags.
func waitForRouteWatch(
t *testing.T, server *discriminator.Server, from string, bits int,
) {
t.Helper()
addr := netip.MustParseAddr(from)
client := netip.PrefixFrom(addr, bits).Masked()
deadline := time.Now().Add(5 * time.Second)
for {
_, missing := server.RouteWatch.Route(context.Background(), client, addr)
if missing == "" {
return
}
if time.Now().After(deadline) {
t.Fatalf("routewatch's answer about %s not kept: %q", from, missing)
}
time.Sleep(time.Millisecond)
}
}
func wantTagsMissing(t *testing.T, line logLine, want string) {
t.Helper()
got, _ := line.fields[tagsMissing].(string)
if got != want {
t.Errorf("log line has %s %q, want %q", tagsMissing, got, want)
}
}
func TestRouteWatchTagsAreOnTheLogLineWithTheLookupsTags(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 64496}})
s, server, _ := startWithLookups(t, map[string]string{routeWatchURL: url})
waitForRouteWatch(t, server, fromDE, 32)
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantTags(t, line, "asn=64496", "country=DE", "prefix="+prefixDE)
wantTagsMissing(t, line, "")
}
func TestRouteWatchTagsAreOnALineDenyNetsRefused(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromKP: {prefixKP, 64511}})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url, denyNets: fromKP,
})
waitForRouteWatch(t, server, fromKP, 32)
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+fromKP+`/32"}`)
wantTags(t, line, "asn=64511", "prefix="+prefixKP)
}
func TestRouteWithoutAnOriginASGivesOnlyThePrefixTag(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 0}})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url,
})
waitForRouteWatch(t, server, fromDE, 32)
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
"prefix="+prefixDE)
}
func TestUnsetRouteWatchChangesNothing(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{lookupSource: off})
if server.RouteWatch != nil {
t.Error("RouteWatch is set while SWW_WAF_ROUTEWATCH_URL is unset")
}
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
if _, present := line.fields[tagsMissing]; present {
t.Errorf("log line has %s", tagsMissing)
}
if _, present := line.fields["tags"]; present {
t.Errorf("log line has tags %v", line.fields["tags"])
}
}
func TestUnreachableRouteWatchLetsTheRequestThroughAndSaysWhy(t *testing.T) {
t.Parallel()
// A stand-in that is closed at once, so that the port refuses connections.
dead := httptest.NewServer(http.NotFoundHandler())
url := dead.URL
dead.Close()
s, _, _ := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url,
})
wantTagsMissing(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
"no_answer_yet")
deadline := time.Now().Add(5 * time.Second)
for {
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
missing, _ := line.fields[tagsMissing].(string)
if missing == "request_failed" {
return
}
if time.Now().After(deadline) {
t.Fatalf("still %q, want request_failed", missing)
}
time.Sleep(time.Millisecond)
}
}
func TestAddressWithoutARouteHasNoTagsAndTheLineSaysSo(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: startRouteWatch(t, nil),
})
deadline := time.Now().Add(5 * time.Second)
for {
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
missing, _ := line.fields[tagsMissing].(string)
if missing == "no_route" {
return
}
if time.Now().After(deadline) {
t.Fatalf("still %q, want no_route", missing)
}
time.Sleep(time.Millisecond)
}
}
func TestTagDenyRefusesOnARouteWatchPrefix(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{
fromDE: {prefixDE, 64496}, fromKP: {prefixKP, 64511},
})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url, tagDeny: "prefix=" + prefixKP,
})
waitForRouteWatch(t, server, fromKP, 32)
waitForRouteWatch(t, server, fromDE, 32)
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "prefix=`+prefixKP+`"}`)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
func TestTagLimitPercentWorksWithAnIPv6Prefix(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromV6: {prefixV6, 64496}})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url, rateLimitPerMinute: "2",
tagLimitPercent: "prefix=" + prefixV6 + ":50",
})
waitForRouteWatch(t, server, fromV6, 64)
// Half of two is one: the second request breaks the limit.
line := s.get(fromV6, http.StatusOK, requestlog.ActionForward)
wantTags(t, line, "asn=64496", "prefix="+prefixV6)
s.get(fromV6, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
}
+11 -8
View File
@@ -1,7 +1,7 @@
package discriminator package discriminator
// The heuristic rule_files. It reads the rules of the rule files, which // The heuristic rule_files. It reads the rules of the rule files, which
// SWW_RULES_DIR and SWW_RULES_ENABLED name. // SWW_WAF_RULES_DIR and SWW_WAF_RULES_ENABLED name.
import ( import (
"context" "context"
@@ -16,9 +16,10 @@ import (
// ruleFiles checks the request against the rules of the rule files at now, // ruleFiles checks the request against the rules of the rule files at now,
// notes the ids of those it matches in the log line, adds the tag // notes the ids of those it matches in the log line, adds the tag
// exploitsignature when it matches any, and refuses the request // exploitsignature when it matches any, and refuses the request
// when the last rule it matches refuses it: with ActionRuleBlocked for a block // when the last rule it matches refuses it: with ActionBlockedByBlockRule for a
// rule, and ActionBanned for a ban rule. A ban rule bans the client's netblock // block rule, and for a ban rule with the action for the ban it makes. A ban
// for a clear sign of attack, or in observe mode raises the alert for the ban // rule bans the client's netblock for a clear sign of attack, or in observe
// mode raises the alert for the ban
// it would have made. Either rule's match is noted as an offence, for the // it would have made. Either rule's match is noted as an offence, for the
// client's history. Its figures are the rule's id and action. // client's history. Its figures are the rule's id and action.
type ruleFiles struct{} type ruleFiles struct{}
@@ -45,14 +46,16 @@ func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict {
switch last.Action { switch last.Action {
case rules.ActionBlock: case rules.ActionBlock:
rq.ruleBlocked = true rq.blockedByBlockRule = true
return &verdict{action: requestlog.ActionRuleBlocked, figures: figures} return &verdict{action: requestlog.ActionBlockedByBlockRule, figures: figures}
case rules.ActionBan: case rules.ActionBan:
rq.attack = true rq.attack = true
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
return &verdict{action: requestlog.ActionBanned, figures: figures} return &verdict{
action: rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target}),
figures: figures,
}
default: default:
return nil return nil
} }
+30 -24
View File
@@ -34,16 +34,17 @@ func TestEachRuleAction(t *testing.T) {
line := s.get(client, http.StatusOK, requestlog.ActionForward) line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantRuleIDs(t, line, "noted") wantRuleIDs(t, line, "noted")
// A block rule refuses with 403, whatever SWW_BAN_RESPONSE is, and // A block rule refuses with 403, whatever SWW_WAF_BAN_RESPONSE is, and
// bans no one. // bans no one.
line = s.request(client, "/blocked", http.StatusForbidden, line = s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionRuleBlocked) requestlog.ActionBlockedByBlockRule)
wantRuleIDs(t, line, "noted", "blocked") wantRuleIDs(t, line, "noted", "blocked")
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
// A ban rule refuses with SWW_BAN_RESPONSE, and bans the client for // A ban rule refuses with SWW_WAF_BAN_RESPONSE, and bans the client for
// seven days, the default. // seven days, the default.
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned) line = s.request(client, "/.env", http.StatusTooManyRequests,
requestlog.ActionTemporarilyBanned)
wantRuleIDs(t, line, "noted", "probe") wantRuleIDs(t, line, "noted", "probe")
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) { if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
@@ -82,7 +83,7 @@ func TestEachRuleAction(t *testing.T) {
// against the rules, and makes the ban permanent. // against the rules, and makes the ban permanent.
clk.advance(time.Hour) clk.advance(time.Hour)
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned) line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
wantRuleIDs(t, line) wantRuleIDs(t, line)
if line.BanExpires != permanent { if line.BanExpires != permanent {
@@ -90,7 +91,7 @@ func TestEachRuleAction(t *testing.T) {
} }
clk.advance(365 * 24 * time.Hour) clk.advance(365 * 24 * time.Hour)
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned) s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
} }
func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) { func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
@@ -103,7 +104,7 @@ func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward), wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward),
"exploitsignature") "exploitsignature")
wantTags(t, s.request(client, "/blocked", http.StatusForbidden, wantTags(t, s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionRuleBlocked), "exploitsignature") requestlog.ActionBlockedByBlockRule), "exploitsignature")
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward)) wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
} }
@@ -115,10 +116,11 @@ func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
}) })
line := s.request(client, "/blocked", http.StatusForbidden, line := s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionRuleBlocked) requestlog.ActionBlockedByBlockRule)
wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`) wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`)
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) line = s.request(client, "/.env", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`) wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`)
} }
@@ -130,8 +132,9 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
attackBanDuration: "1h", attackBanDuration: "1h",
}) })
// The first probe bans for SWW_ATTACK_BAN_DURATION. // The first probe bans for SWW_WAF_ATTACK_BAN_DURATION.
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) line := s.request(client, "/.env", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) { if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires) t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
} }
@@ -141,7 +144,8 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
clk.advance(time.Hour) clk.advance(time.Hour)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) line = s.request(client, "/.env", http.StatusForbidden,
requestlog.ActionPermanentlyBanned)
if line.BanExpires != permanent { if line.BanExpires != permanent {
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires) t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
} }
@@ -151,8 +155,8 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
allowed = "192.0.2.60" // in SWW_ALLOW_NETS allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS exempt = "192.0.2.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
) )
s, _, server := startWithClock(t, "", map[string]string{ s, _, server := startWithClock(t, "", map[string]string{
@@ -162,14 +166,15 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
}) })
// A client in SWW_ALLOW_NETS is not checked. // A client in SWW_WAF_ALLOW_NETS is not checked.
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward) line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
wantRuleIDs(t, line) wantRuleIDs(t, line)
// A probe over the rate limit breaks the limit before any rule sees // A probe over the rate limit breaks the limit before any rule sees
// it. // it.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionRateLimited) line = s.request(client, "/.env", http.StatusForbidden,
requestlog.ActionClientIsRateLimited)
wantRuleIDs(t, line) wantRuleIDs(t, line)
limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32")) limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
@@ -179,7 +184,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
// A client the rate limits do not apply to is still checked. // A client the rate limits do not apply to is still checked.
s.get(exempt, http.StatusOK, requestlog.ActionForward) s.get(exempt, http.StatusOK, requestlog.ActionForward)
s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionBanned) s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) { func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
@@ -191,11 +196,11 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
}) })
line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward) line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionRuleBlocked) wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
wantRuleIDs(t, line, "blocked") wantRuleIDs(t, line, "blocked")
line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward) line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned) wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
wantRuleIDs(t, line, "probe") wantRuleIDs(t, line, "probe")
if line.BanExpires != "" { if line.BanExpires != "" {
@@ -219,9 +224,10 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
metricsToken: token, metricsToken: token,
}) })
s.request(client, "/blocked", http.StatusForbidden, requestlog.ActionRuleBlocked) s.request(client, "/blocked", http.StatusForbidden,
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) requestlog.ActionBlockedByBlockRule)
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
@@ -229,10 +235,10 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
wantMetric(t, metrics, wantMetric(t, metrics,
`sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1) `sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2) wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2)
wantMetric(t, metrics, `sww_requests_total{action="rule_blocked",`+ wantMetric(t, metrics, `sww_requests_total{action="blocked_by_block_rule",`+
`instance="app",status_class="4xx"}`, 1) `instance="app",status_class="4xx"}`, 1)
wantMetric(t, metrics, wantMetric(t, metrics,
`sww_offences_total{instance="app",kind="rule_blocked"}`, 1) `sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 1)
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1) wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1)
wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1) wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1)
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0) wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0)
+11 -11
View File
@@ -26,7 +26,7 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
calls.Add(1) calls.Add(1)
}) })
geojsURL, asked := startGeoJS(t) geojsURL, asked := startGeoJS(t)
// fromKP is in SWW_ALLOW_NETS, and in SWW_DENY_NETS too, which // fromKP is in SWW_WAF_ALLOW_NETS, and in SWW_WAF_DENY_NETS too, which
// comes after it. // comes after it.
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{ addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
@@ -36,7 +36,7 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
}) })
// Neither SWW_DENY_NETS, SWW_TAG_DENY nor the limit of one request a // Neither SWW_WAF_DENY_NETS, SWW_WAF_TAG_DENY nor the limit of one request a
// minute refuses the client, and its country is not looked up. // minute refuses the client, and its country is not looked up.
wantAnswers(t, addr, out, []sentRequest{ wantAnswers(t, addr, out, []sentRequest{
{fromKP, http.StatusOK, requestlog.ActionForward}, {fromKP, http.StatusOK, requestlog.ActionForward},
@@ -68,7 +68,7 @@ func TestRequestFromAllowNetsIsNotCounted(t *testing.T) {
{listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward},
{listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusOK, requestlog.ActionForward}, {unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited}, {unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
}) })
} }
@@ -92,7 +92,7 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
wantStatus(t, do(t, req), http.StatusForbidden) wantStatus(t, do(t, req), http.StatusForbidden)
line := out.endedLine(t) line := out.endedLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionDenied) wantLine(t, line, http.StatusForbidden, requestlog.ActionClientDenied)
if len(asked()) != 0 { if len(asked()) != 0 {
t.Errorf("GeoJS was asked about %v, want nothing", asked()) t.Errorf("GeoJS was asked about %v, want nothing", asked())
@@ -108,7 +108,7 @@ func TestDenyNetsRefusalNamesItsHeuristicAndNetblock(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet}) s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet})
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied) line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`) wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`)
} }
@@ -125,10 +125,10 @@ func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
// listedAddr's refused requests are not counted, so the first request // listedAddr's refused requests are not counted, so the first request
// from unlistedAddr is within the limit of one a minute. // from unlistedAddr is within the limit of one a minute.
wantAnswers(t, addr, out, []sentRequest{ wantAnswers(t, addr, out, []sentRequest{
{listedAddr, http.StatusForbidden, requestlog.ActionDenied}, {listedAddr, http.StatusForbidden, requestlog.ActionClientDenied},
{listedAddr, http.StatusForbidden, requestlog.ActionDenied}, {listedAddr, http.StatusForbidden, requestlog.ActionClientDenied},
{unlistedAddr, http.StatusOK, requestlog.ActionForward}, {unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited}, {unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
}) })
} }
@@ -147,13 +147,13 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
// listedAddr's requests are neither refused nor counted, so the first // listedAddr's requests are neither refused nor counted, so the first
// request from unlistedAddr is within the limit of one a minute. // request from unlistedAddr is within the limit of one a minute.
// SWW_TAG_DENY still refuses an exempt client. // SWW_WAF_TAG_DENY still refuses an exempt client.
wantAnswers(t, addr, out, []sentRequest{ wantAnswers(t, addr, out, []sentRequest{
{listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward},
{listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusOK, requestlog.ActionForward}, {unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited}, {unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
{fromKP, http.StatusForbidden, requestlog.ActionDenied}, {fromKP, http.StatusForbidden, requestlog.ActionClientDenied},
}) })
} }
+3 -3
View File
@@ -1,6 +1,6 @@
package discriminator package discriminator
// The heuristic tag_deny. It reads SWW_TAG_DENY. // The heuristic tag_deny. It reads SWW_WAF_TAG_DENY.
import ( import (
"context" "context"
@@ -10,7 +10,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// tagDeny refuses a request that carries a tag in SWW_TAG_DENY. Its figure is // tagDeny refuses a request that carries a tag in SWW_WAF_TAG_DENY. Its figure is
// the first such tag, in sorted order. // the first such tag, in sorted order.
type tagDeny struct{} type tagDeny struct{}
@@ -20,7 +20,7 @@ func (tagDeny) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, tag := range rq.line.Tags { for _, tag := range rq.line.Tags {
if slices.Contains(rq.h.config.TagDeny, tag) { if slices.Contains(rq.h.config.TagDeny, tag) {
return &verdict{ return &verdict{
action: requestlog.ActionDenied, action: requestlog.ActionClientDenied,
figures: map[string]any{"tag": tag}, figures: map[string]any{"tag": tag},
} }
} }
+13 -10
View File
@@ -14,7 +14,7 @@ func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
// in capitals. // in capitals.
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"}) s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied) line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`) wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`)
wantTags(t, line, "asn=64511", "country=KP") wantTags(t, line, "asn=64511", "country=KP")
@@ -26,7 +26,7 @@ func TestTagDenyRefusesAnASNTag(t *testing.T) {
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"}) s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"})
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied), wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "asn=64511"}`) "tag_deny", `{"tag": "asn=64511"}`)
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
} }
@@ -41,7 +41,7 @@ func TestTagDenyRefusesAListTagBeforeTheRateLimitsCount(t *testing.T) {
// Each is refused for its tag: none is counted to break the limit of one. // Each is refused for its tag: none is counted to break the limit of one.
for range 3 { for range 3 {
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionDenied), wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "torexit"}`) "tag_deny", `{"tag": "torexit"}`)
} }
} }
@@ -51,14 +51,14 @@ func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
const mixedCaseZone = "DNSBL.example" const mixedCaseZone = "DNSBL.example"
// The zone's tag is in lower case, however SWW_DNSBL_ZONES writes it. // The zone's tag is in lower case, however SWW_WAF_DNSBL_ZONES writes it.
s, server, _ := startWithLookups(t, map[string]string{ s, server, _ := startWithLookups(t, map[string]string{
dnsblZones: mixedCaseZone, dnsblResolver: noResolver, dnsblZones: mixedCaseZone, dnsblResolver: noResolver,
reputationAction: actionLog, tagDeny: "dnsbl=" + dnsblZone, reputationAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
}) })
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}}) loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionDenied), wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`) "tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`)
} }
@@ -69,7 +69,7 @@ func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature", rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature",
}) })
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied) line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`) wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
wantTags(t, line, "exploitsignature") wantTags(t, line, "exploitsignature")
} }
@@ -87,8 +87,9 @@ func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
// A quarter of 4 requests a minute: the second breaks the limit. // A quarter of 4 requests a minute: the second breaks the limit.
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited) line := s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
"25 from "+tagLimitPercent) "25 from "+tagLimitPercent)
} }
@@ -108,7 +109,8 @@ func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) {
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes) t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
} }
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
"50 from "+tagLimitPercent) "50 from "+tagLimitPercent)
// fromKP, which the list does not hold, has the whole limit. // fromKP, which the list does not hold, has the whole limit.
@@ -117,5 +119,6 @@ func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) {
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit) t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
} }
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, none) wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting, none)
} }
+2 -2
View File
@@ -1,6 +1,6 @@
package discriminator package discriminator
// The heuristic tag_lists. It reads the address lists of SWW_TAG_LISTS through // The heuristic tag_lists. It reads the address lists of SWW_WAF_TAG_LISTS through
// the reputation lists. // the reputation lists.
import ( import (
@@ -8,7 +8,7 @@ import (
"time" "time"
) )
// tagLists adds to the request one tag for each list of SWW_TAG_LISTS that // tagLists adds to the request one tag for each list of SWW_WAF_TAG_LISTS that
// holds the client, the tag being the list's name. It refuses nothing, and runs // holds the client, the tag being the list's name. It refuses nothing, and runs
// first so that every refusal's log line carries these tags. // first so that every refusal's log line carries these tags.
type tagLists struct{} type tagLists struct{}
+5 -5
View File
@@ -8,9 +8,9 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// tagLists is SWW_TAG_LISTS, and vpnList and torList lists it names. // tagLists is SWW_WAF_TAG_LISTS, and vpnList and torList lists it names.
const ( const (
tagLists = "SWW_TAG_LISTS" tagLists = "SWW_WAF_TAG_LISTS"
vpnList = "https://lists.example/vpn.txt" vpnList = "https://lists.example/vpn.txt"
torList = "https://lists.example/torexit.txt" torList = "https://lists.example/torexit.txt"
) )
@@ -80,7 +80,7 @@ func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
}) })
loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}}) loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied) line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
if line.Heuristic != "deny_nets" { if line.Heuristic != "deny_nets" {
t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic) t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic)
} }
@@ -89,9 +89,9 @@ func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
// The second request breaks the limit of one a minute, which bans the client. // The second request breaks the limit of one a minute, which bans the client.
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line = s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned) line = s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
if line.Heuristic != "active_ban" { if line.Heuristic != "active_ban" {
t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic) t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic)
} }
+3 -5
View File
@@ -1,6 +1,6 @@
package discriminator package discriminator
// The heuristic trap_paths. It reads SWW_TRAP_PATHS. // The heuristic trap_paths. It reads SWW_WAF_TRAP_PATHS.
import ( import (
"context" "context"
@@ -8,11 +8,10 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
) )
// trapPaths refuses a request for a path in SWW_TRAP_PATHS: its path as a // trapPaths refuses a request for a path in SWW_WAF_TRAP_PATHS: its path as a
// path rule sees it, without the query, as sent or once percent-decoded, is one // path rule sees it, without the query, as sent or once percent-decoded, is one
// of them. Such a request is a clear sign of attack, as a ban rule's match is: // of them. Such a request is a clear sign of attack, as a ban rule's match is:
// it bans the client's netblock, or in observe mode raises the alert for the // it bans the client's netblock, or in observe mode raises the alert for the
@@ -30,10 +29,9 @@ func (trapPaths) check(_ context.Context, rq *request, now time.Time) *verdict {
} }
rq.attack = true rq.attack = true
rq.banForAttack(now, bans.Notes{TrapPath: path})
return &verdict{ return &verdict{
action: requestlog.ActionBanned, action: rq.banForAttack(now, bans.Notes{TrapPath: path}),
figures: map[string]any{"path": path}, figures: map[string]any{"path": path},
} }
} }
+13 -11
View File
@@ -14,14 +14,14 @@ import (
// trapPaths is the setting's name, and trapPathList what the tests set it // trapPaths is the setting's name, and trapPathList what the tests set it
// to. // to.
const ( const (
trapPaths = "SWW_TRAP_PATHS" trapPaths = "SWW_WAF_TRAP_PATHS"
trapPathList = "/wp-login.php,/xmlrpc.php" trapPathList = "/wp-login.php,/xmlrpc.php"
) )
func TestTrapPathBansAsABanRuleDoes(t *testing.T) { func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
t.Parallel() t.Parallel()
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS const allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
// A block rule for the same path: the trap path comes first. // A block rule for the same path: the trap path comes first.
s, clk, server := startWithClock(t, "", map[string]string{ s, clk, server := startWithClock(t, "", map[string]string{
@@ -38,12 +38,12 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
s.request(otherClient, path, http.StatusOK, requestlog.ActionForward) s.request(otherClient, path, http.StatusOK, requestlog.ActionForward)
} }
// A client in SWW_ALLOW_NETS is not checked. // A client in SWW_WAF_ALLOW_NETS is not checked.
s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward) s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
// The query is not part of the path. // The query is not part of the path.
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusTooManyRequests, line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusTooManyRequests,
requestlog.ActionBanned) requestlog.ActionTemporarilyBanned)
wantRuleIDs(t, line) wantRuleIDs(t, line)
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) { if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
@@ -77,7 +77,7 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
} }
// The next request is refused under the ban, and makes it permanent. // The next request is refused under the ban, and makes it permanent.
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned) line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
if line.BanExpires != permanent { if line.BanExpires != permanent {
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires) t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
} }
@@ -89,7 +89,7 @@ func TestTrapPathRefusalNamesItsHeuristicAndPath(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList}) s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden, line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden,
requestlog.ActionBanned) requestlog.ActionTemporarilyBanned)
wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`) wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`)
} }
@@ -100,7 +100,8 @@ func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
// %2577 decodes once to %77, not to w. // %2577 decodes once to %77, not to w.
s.request(otherClient, "/%2577p-login.php", http.StatusOK, requestlog.ActionForward) s.request(otherClient, "/%2577p-login.php", http.StatusOK, requestlog.ActionForward)
s.request(client, "/%77p-login.php", http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/%77p-login.php", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
// The ban's notes give the path as the client sent it. // The ban's notes give the path as the client sent it.
got := server.Ledger.Bans(netip.MustParsePrefix(client + "/32")) got := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
@@ -113,11 +114,12 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
t.Parallel() t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{ s, _, _ := startWithClock(t, "", map[string]string{
trapPaths: trapPathList, trapPaths: trapPathList,
"SWW_RULES_ENABLED": "false", "SWW_WAF_RULES_ENABLED": "false",
}) })
s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/xmlrpc.php", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
} }
func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) { func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
@@ -129,7 +131,7 @@ func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
}) })
line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward) line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned) wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
// No ban was made. // No ban was made.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
+3 -3
View File
@@ -24,7 +24,7 @@ const quietTime = 2 * time.Second
// FileParams are what OpenFile needs. // FileParams are what OpenFile needs.
type FileParams struct { type FileParams struct {
// Path is the lookup database, the IPinfo Lite file in its .mmdb form // Path is the lookup database, the IPinfo Lite file in its .mmdb form
// (SWW_LOOKUP_DB_PATH). // (SWW_WAF_LOOKUP_DB_PATH).
Path string Path string
// Now tells the time, normally time.Now. // Now tells the time, normally time.Now.
Now func() time.Time Now func() time.Time
@@ -231,12 +231,12 @@ func (f *File) use(reader *maxminddb.Reader) {
func read(path string) (*maxminddb.Reader, error) { func read(path string) (*maxminddb.Reader, error) {
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
if err != nil { if err != nil {
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH cannot be read: %w", err) return nil, fmt.Errorf("SWW_WAF_LOOKUP_DB_PATH cannot be read: %w", err)
} }
reader, err := maxminddb.OpenBytes(data) reader, err := maxminddb.OpenBytes(data)
if err != nil { if err != nil {
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err) return nil, fmt.Errorf("SWW_WAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
} }
return reader, nil return reader, nil
+3 -3
View File
@@ -99,9 +99,9 @@ func TestFileThatCannotBeReadIsAnError(t *testing.T) {
writeFile(t, notDatabase, "not a lookup database\n") writeFile(t, notDatabase, "not a lookup database\n")
for path, want := range map[string]string{ for path, want := range map[string]string{
missing: "SWW_LOOKUP_DB_PATH cannot be read: open " + missing + missing: "SWW_WAF_LOOKUP_DB_PATH cannot be read: open " + missing +
": no such file or directory", ": no such file or directory",
notDatabase: "SWW_LOOKUP_DB_PATH " + notDatabase + notDatabase: "SWW_WAF_LOOKUP_DB_PATH " + notDatabase +
" is not a .mmdb file: error opening database: invalid MaxMind DB file", " is not a .mmdb file: error opening database: invalid MaxMind DB file",
} { } {
_, err := OpenFile(FileParams{ _, err := OpenFile(FileParams{
@@ -216,7 +216,7 @@ func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T)
Reason: "the lookup database cannot be read, and the one read before stays in use", Reason: "the lookup database cannot be read, and the one read before stays in use",
Detail: map[string]any{ Detail: map[string]any{
"file": path, "file": path,
"error": "SWW_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " + "error": "SWW_WAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
"error opening database: invalid MaxMind DB file", "error opening database: invalid MaxMind DB file",
}, },
}) })
+5 -5
View File
@@ -1,6 +1,6 @@
// Package lookup looks up each client's AS number and country, through // Package lookup looks up each client's AS number and country, through
// the GeoJS web service or in the lookup database, the IPinfo Lite file // the GeoJS web service or in the lookup database, the IPinfo Lite file
// SWW_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at // SWW_WAF_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
// most 100,000 clients and for 7 days each, and are written to // most 100,000 clients and for 7 days each, and are written to
// lookups.json and read from it by the state package. // lookups.json and read from it by the state package.
package lookup package lookup
@@ -66,7 +66,7 @@ type Params struct {
URL string URL string
// Timeout is how long a request waits for its client's first answer, // Timeout is how long a request waits for its client's first answer,
// and how long a request to GeoJS may take before it is abandoned // and how long a request to GeoJS may take before it is abandoned
// (SWW_LOOKUP_TIMEOUT). // (SWW_WAF_LOOKUP_TIMEOUT).
Timeout time.Duration Timeout time.Duration
// Wait is true when a setting needs each request's answer before the // Wait is true when a setting needs each request's answer before the
// request goes on. Otherwise no request waits for one. // request goes on. Otherwise no request waits for one.
@@ -198,7 +198,7 @@ func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
answer, found := g.kept(client) answer, found := g.kept(client)
if !found { if !found {
g.metrics.GeoJSUnanswered.Inc() g.metrics.RequestsWithoutGeoJSAnswer.Inc()
} }
w, waiting := g.waiting[client] w, waiting := g.waiting[client]
@@ -296,7 +296,7 @@ func (g *GeoJS) answerOrWait(
} }
if w == nil { if w == nil {
g.metrics.GeoJSUnanswered.Inc() g.metrics.RequestsWithoutGeoJSAnswer.Inc()
return Answer{}, nil // too many clients wait already return Answer{}, nil // too many clients wait already
} }
@@ -307,7 +307,7 @@ func (g *GeoJS) answerOrWait(
} }
if w.late { if w.late {
g.metrics.GeoJSUnanswered.Inc() g.metrics.RequestsWithoutGeoJSAnswer.Inc()
return Answer{}, nil return Answer{}, nil
} }
+8 -8
View File
@@ -362,7 +362,7 @@ func TestWithoutWaitTheRequestGoesOnAtOnceAndTheAnswerIsGivenWhenItComes(
mu.Unlock() mu.Unlock()
wantCountry(t, g, client, germany) wantCountry(t, g, client, germany)
wantUnanswered(t, m, 0) wantRequestsWithoutGeoJSAnswer(t, m, 0)
}) })
} }
@@ -596,7 +596,7 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
// GeoJS fails, so the first client goes without an answer, and GeoJS // GeoJS fails, so the first client goes without an answer, and GeoJS
// is left alone for a second, which does not pass in this test. // is left alone for a second, which does not pass in this test.
wantCountry(t, g, clients(), "") wantCountry(t, g, clients(), "")
wantUnanswered(t, m, 1) wantRequestsWithoutGeoJSAnswer(t, m, 1)
// Meanwhile each new client goes without one at once, while there is // Meanwhile each new client goes without one at once, while there is
// room for it among the 10,000 that may wait. // room for it among the 10,000 that may wait.
@@ -604,11 +604,11 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
wantCountry(t, g, clients(), "") wantCountry(t, g, clients(), "")
} }
wantUnanswered(t, m, 10000) wantRequestsWithoutGeoJSAnswer(t, m, 10000)
// One more, for which there is no room, goes without one too. // One more, for which there is no room, goes without one too.
wantCountry(t, g, clients(), "") wantCountry(t, g, clients(), "")
wantUnanswered(t, m, 10001) wantRequestsWithoutGeoJSAnswer(t, m, 10001)
}) })
} }
@@ -841,12 +841,12 @@ func wantAsked(t *testing.T, geojs *standIn, i int, want ...string) {
} }
} }
// wantUnanswered checks how many requests m counts as having gone without // wantRequestsWithoutGeoJSAnswer checks how many requests m counts as having
// an answer from GeoJS. // gone without an answer from GeoJS.
func wantUnanswered(t *testing.T, m *metrics.Metrics, want float64) { func wantRequestsWithoutGeoJSAnswer(t *testing.T, m *metrics.Metrics, want float64) {
t.Helper() t.Helper()
got := testutil.ToFloat64(m.GeoJSUnanswered) got := testutil.ToFloat64(m.RequestsWithoutGeoJSAnswer)
if got != want { if got != want {
t.Errorf("%v requests went without an answer, want %v", got, want) t.Errorf("%v requests went without an answer, want %v", got, want)
} }
+1 -1
View File
@@ -42,7 +42,7 @@ type busiest struct {
func newCountries(topN int) *busiest { func newCountries(topN int) *busiest {
countries := newBusiest(topN, "country", "the client's country") countries := newBusiest(topN, "country", "the client's country")
countries.refused = counterVec("sww_country_list_refusals_total", countries.refused = counterVec("sww_country_list_refusals_total",
"Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.", "Requests SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
[]string{"country"}) []string{"country"})
return countries return countries
+40 -40
View File
@@ -28,15 +28,15 @@ type Metrics struct {
registry prometheus.Registerer registry prometheus.Registerer
handler http.Handler handler http.Handler
inFlight prometheus.Gauge inFlight prometheus.Gauge
requests *prometheus.CounterVec requests *prometheus.CounterVec
requestBytes *prometheus.CounterVec requestBytes *prometheus.CounterVec
responseBytes *prometheus.CounterVec responseBytes *prometheus.CounterVec
requestDuration prometheus.Histogram requestDuration prometheus.Histogram
upstreamDuration prometheus.Histogram upstreamDuration prometheus.Histogram
rateLimitHits *prometheus.CounterVec rateLimitHits *prometheus.CounterVec
offences *prometheus.CounterVec offences *prometheus.CounterVec
wafMatches *prometheus.CounterVec coreRuleSetMatches *prometheus.CounterVec
// ruleMatches are made by AddRules, and reputationHits by // ruleMatches are made by AddRules, and reputationHits by
// AddReputation. // AddReputation.
ruleMatches *prometheus.CounterVec ruleMatches *prometheus.CounterVec
@@ -45,12 +45,12 @@ type Metrics struct {
asns *busiest asns *busiest
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those // GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
// that failed. GeoJSUnanswered are the requests that needed their // that failed. RequestsWithoutGeoJSAnswer are the requests that needed
// client's answer, for a setting that acts on it, and went on without // their client's answer, for a setting that acts on it, and went on
// it because GeoJS had not given it in time. // without it because GeoJS had not given it in time.
GeoJSRequests prometheus.Counter GeoJSRequests prometheus.Counter
GeoJSFailures prometheus.Counter GeoJSFailures prometheus.Counter
GeoJSUnanswered prometheus.Counter RequestsWithoutGeoJSAnswer prometheus.Counter
stateFileWrites *prometheus.CounterVec stateFileWrites *prometheus.CounterVec
stateFileWriteFailures *prometheus.CounterVec stateFileWriteFailures *prometheus.CounterVec
@@ -62,8 +62,8 @@ type Metrics struct {
// New returns the metrics, with the Go runtime's and the process's own. // New returns the metrics, with the Go runtime's and the process's own.
// topN is how many countries and how many AS numbers get series of their // topN is how many countries and how many AS numbers get series of their
// own (SWW_METRICS_TOP_N). Every metric carries instanceName // own (SWW_WAF_METRICS_TOP_N). Every metric carries instanceName
// (SWW_INSTANCE_NAME) as its label instance. // (SWW_WAF_INSTANCE_NAME) as its label instance.
// //
//nolint:funlen // a few lines for each metric, a list that grows with them //nolint:funlen // a few lines for each metric, a list that grows with them
func New(topN int, instanceName string) *Metrics { func New(topN int, instanceName string) *Metrics {
@@ -101,8 +101,8 @@ func New(topN int, instanceName string) *Metrics {
[]string{"window", "kind"}), []string{"window", "kind"}),
offences: counterVec("sww_offences_total", offences: counterVec("sww_offences_total",
"Offences, by kind.", []string{"kind"}), "Offences, by kind.", []string{"kind"}),
wafMatches: counterVec("sww_waf_matches_total", coreRuleSetMatches: counterVec("sww_core_rule_set_matches_total",
"Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+ "Requests that matched a rule of the Core Rule Set, by SWW_WAF_CORE_RULE_SET_MODE "+
"and the rule's id.", []string{"mode", "rule_id"}), "and the rule's id.", []string{"mode", "rule_id"}),
countries: newCountries(topN), countries: newCountries(topN),
asns: newASNs(topN), asns: newASNs(topN),
@@ -114,8 +114,8 @@ func New(topN int, instanceName string) *Metrics {
Name: "sww_geojs_failures_total", Name: "sww_geojs_failures_total",
Help: "Requests to GeoJS that failed.", Help: "Requests to GeoJS that failed.",
}), }),
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{ RequestsWithoutGeoJSAnswer: prometheus.NewCounter(prometheus.CounterOpts{
Name: "sww_geojs_unanswered_total", Name: "sww_requests_without_geojs_answer_total",
Help: "Requests that needed their client's answer from GeoJS and " + Help: "Requests that needed their client's answer from GeoJS and " +
"went on without it, because GeoJS had not given it in time.", "went on without it, because GeoJS had not given it in time.",
}), }),
@@ -139,9 +139,9 @@ func New(topN int, instanceName string) *Metrics {
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
m.inFlight, m.requests, m.requestBytes, m.responseBytes, m.inFlight, m.requests, m.requestBytes, m.responseBytes,
m.requestDuration, m.upstreamDuration, m.requestDuration, m.upstreamDuration,
m.rateLimitHits, m.offences, m.wafMatches, m.rateLimitHits, m.offences, m.coreRuleSetMatches,
m.countries, m.asns, m.countries, m.asns,
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered, m.GeoJSRequests, m.GeoJSFailures, m.RequestsWithoutGeoJSAnswer,
m.stateFileWrites, m.stateFileWriteFailures, m.stateFileWrites, m.stateFileWriteFailures,
m.stateFileLastWrite, m.stateFileSize, m.stateFileLastWrite, m.stateFileSize,
m.stateFileEditsTakenIn, m.stateFileEditsSetAside, m.stateFileEditsTakenIn, m.stateFileEditsSetAside,
@@ -214,13 +214,13 @@ func (m *Metrics) AddRules(ruleFiles *rules.Files) {
} }
// AddRemoteLog adds the metrics of sending the log lines to // AddRemoteLog adds the metrics of sending the log lines to
// SWW_LOG_REMOTE_URL, read from remote as the metrics are asked for: the // SWW_WAF_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
// lines sent, those dropped, and those waiting in the buffer. // lines sent, those dropped, and those waiting in the buffer.
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) { func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "sww_remote_log_lines_sent_total", Name: "sww_remote_log_lines_sent_total",
Help: "Log lines sent to SWW_LOG_REMOTE_URL.", Help: "Log lines sent to SWW_WAF_LOG_REMOTE_URL.",
}, func() float64 { }, func() float64 {
return float64(remote.Sent()) return float64(remote.Sent())
}), }),
@@ -232,7 +232,7 @@ func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
return float64(remote.Dropped()) return float64(remote.Dropped())
}), }),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "sww_remote_log_buffer_depth", Name: "sww_remote_log_lines_waiting",
Help: "Log lines in the buffer, waiting to be sent.", Help: "Log lines in the buffer, waiting to be sent.",
}, func() float64 { }, func() float64 {
return float64(remote.Depth()) return float64(remote.Depth())
@@ -313,7 +313,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb, // AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
// read from abuseIPDB as the metrics are asked for: the checks made, those // read from abuseIPDB as the metrics are asked for: the checks made, those
// that failed, and how many checks the day's budget has left. It is // that failed, and how many checks the day's budget has left. It is
// called once, after AddReputation, while SWW_ABUSEIPDB_KEY is set. // called once, after AddReputation, while SWW_WAF_ABUSEIPDB_KEY is set.
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) { func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource} source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
@@ -322,7 +322,7 @@ func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "sww_reputation_daily_budget_remaining", Name: "sww_reputation_daily_budget_remaining",
Help: "Checks of the day's SWW_ABUSEIPDB_DAILY_BUDGET not yet spent.", Help: "Checks of the day's SWW_WAF_ABUSEIPDB_DAILY_BUDGET not yet spent.",
ConstLabels: source, ConstLabels: source,
}, func() float64 { }, func() float64 {
return float64(abuseIPDB.BudgetLeft()) return float64(abuseIPDB.BudgetLeft())
@@ -355,16 +355,16 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
return float64(queue.Counts(name).Sent) return float64(queue.Counts(name).Sent)
}), }),
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "sww_alerts_failed_total", Name: "sww_alert_send_failures_total",
Help: "Requests to the destination that failed.", Help: "Requests to the destination that failed.",
ConstLabels: destination, ConstLabels: destination,
}, func() float64 { }, func() float64 {
return float64(queue.Counts(name).Failed) return float64(queue.Counts(name).SendFailures)
}), }),
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "sww_alerts_suppressed_total", Name: "sww_alerts_suppressed_total",
Help: "Alerts held back: repeats within SWW_ALERT_COOLDOWN, and " + Help: "Alerts held back: repeats within SWW_WAF_ALERT_COOLDOWN, and " +
"alerts past SWW_ALERT_MAX_PER_HOUR, for the hour's summary.", "alerts past SWW_WAF_ALERT_MAX_PER_HOUR, for the hour's summary.",
ConstLabels: destination, ConstLabels: destination,
}, func() float64 { }, func() float64 {
return float64(queue.Suppressed()) return float64(queue.Suppressed())
@@ -424,10 +424,10 @@ func (m *Metrics) LimitHit(hit ratelimit.Hit) {
// them. // them.
func (m *Metrics) Offences(r ratelimit.Request) { func (m *Metrics) Offences(r ratelimit.Request) {
for kind, committed := range map[string]bool{ for kind, committed := range map[string]bool{
"limit": r.BrokeLimit, "limit": r.BrokeLimit,
"attack": r.Attack, "attack": r.Attack,
"rule_blocked": r.RuleBlocked, "blocked_by_block_rule": r.BlockedByBlockRule,
"waf_blocked": r.WAFBlocked, "blocked_by_core_rule_set": r.BlockedByCoreRuleSet,
} { } {
if committed { if committed {
m.offences.WithLabelValues(kind).Inc() m.offences.WithLabelValues(kind).Inc()
@@ -441,10 +441,10 @@ func (m *Metrics) RuleMatched(id, action string) {
m.ruleMatches.WithLabelValues(id, action).Inc() m.ruleMatches.WithLabelValues(id, action).Inc()
} }
// WAFMatched counts a request that matched the Core Rule Set's rule id, // CoreRuleSetMatched counts a request that matched the Core Rule Set's rule id,
// with SWW_WAF_MODE at mode. // with SWW_WAF_CORE_RULE_SET_MODE at mode.
func (m *Metrics) WAFMatched(mode string, id int) { func (m *Metrics) CoreRuleSetMatched(mode string, id int) {
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc() m.coreRuleSetMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
} }
// StateFileWritten counts a write of the state file name, of size bytes, // StateFileWritten counts a write of the state file name, of size bytes,
+19 -18
View File
@@ -2,7 +2,7 @@
// and bytes counted over a minute, an hour and a day, as the "Counting // and bytes counted over a minute, an hour and a day, as the "Counting
// method" section of SPEC.md describes, which tell when a request takes // method" section of SPEC.md describes, which tell when a request takes
// the client over a rate limit or a byte limit, and each client's history // the client over a rate limit or a byte limit, and each client's history
// since it was first seen. At most SWW_MAX_TRACKED_CLIENTS clients are // since it was first seen. At most SWW_WAF_MAX_TRACKED_CLIENTS clients are
// kept, in memory, and written to clients.json and read from it by the // kept, in memory, and written to clients.json and read from it by the
// state package. // state package.
package ratelimit package ratelimit
@@ -122,12 +122,13 @@ type Responses struct {
type Offences struct { type Offences struct {
// Limit is its requests that broke a rate limit, a byte limit or the // Limit is its requests that broke a rate limit, a byte limit or the
// error burst, Attack those that were a clear sign of attack, a match // error burst, Attack those that were a clear sign of attack, a match
// of a ban rule or a request for a trap path, RuleBlocked those a block // of a ban rule or a request for a trap path, BlockedByBlockRule those a
// rule refused, and WAFBlocked those the Core Rule Set refused. // block rule refused, and BlockedByCoreRuleSet those the Core Rule Set
Limit int64 `json:"limit"` // refused.
Attack int64 `json:"attack"` Limit int64 `json:"limit"`
RuleBlocked int64 `json:"rule_blocked"` Attack int64 `json:"attack"`
WAFBlocked int64 `json:"waf_blocked"` BlockedByBlockRule int64 `json:"blocked_by_block_rule"`
BlockedByCoreRuleSet int64 `json:"blocked_by_core_rule_set"`
// TokenRefused is what a clients.json written by an earlier version gives, // TokenRefused is what a clients.json written by an earlier version gives,
// which must still load. Nothing adds to it. // which must still load. Nothing adds to it.
TokenRefused int64 `json:"token_refused,omitempty"` TokenRefused int64 `json:"token_refused,omitempty"`
@@ -147,16 +148,16 @@ type Request struct {
ResponseBytes int64 ResponseBytes int64
// BrokeLimit is true for a request that broke a rate limit, a byte // BrokeLimit is true for a request that broke a rate limit, a byte
// limit or the error burst, Attack for one that matched a ban rule or // limit or the error burst, Attack for one that matched a ban rule or
// asked for a trap path, RuleBlocked for one a block rule refused, and // asked for a trap path, BlockedByBlockRule for one a block rule refused,
// WAFBlocked for one the Core Rule Set refused. // and BlockedByCoreRuleSet for one the Core Rule Set refused.
BrokeLimit bool BrokeLimit bool
Attack bool Attack bool
RuleBlocked bool BlockedByBlockRule bool
WAFBlocked bool BlockedByCoreRuleSet bool
} }
// New returns a Limiter for limits, with no client counted yet, whose // New returns a Limiter for limits, with no client counted yet, whose
// table holds at most maxClients clients (SWW_MAX_TRACKED_CLIENTS). Past // table holds at most maxClients clients (SWW_WAF_MAX_TRACKED_CLIENTS). Past
// it, the least recently seen client is dropped, with its history, and // it, the least recently seen client is dropped, with its history, and
// starts afresh if it comes back. // starts afresh if it comes back.
func New(limits Limits, maxClients int) *Limiter { func New(limits Limits, maxClients int) *Limiter {
@@ -323,12 +324,12 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
h.Offences.Attack++ h.Offences.Attack++
} }
if r.RuleBlocked { if r.BlockedByBlockRule {
h.Offences.RuleBlocked++ h.Offences.BlockedByBlockRule++
} }
if r.WAFBlocked { if r.BlockedByCoreRuleSet {
h.Offences.WAFBlocked++ h.Offences.BlockedByCoreRuleSet++
} }
} }
+1 -1
View File
@@ -13,7 +13,7 @@ import (
const limit = 3 const limit = 3
// tableSize is the most clients the tests' tables hold, the default of // tableSize is the most clients the tests' tables hold, the default of
// SWW_MAX_TRACKED_CLIENTS. // SWW_WAF_MAX_TRACKED_CLIENTS.
const tableSize = 20000 const tableSize = 20000
// whole is the percentage of each limit a client gets when nothing lowers // whole is the percentage of each limit a client gets when nothing lowers
+8 -8
View File
@@ -1,5 +1,5 @@
// Package remotelog sends the lines sww writes on stdout to the // Package remotelog sends the lines sww writes on stdout to the
// remote log endpoint, SWW_LOG_REMOTE_URL, as the "Request log" section // remote log endpoint, SWW_WAF_LOG_REMOTE_URL, as the "Request log" section
// of SPEC.md describes: each line as the message of an RFC 5424 syslog // of SPEC.md describes: each line as the message of an RFC 5424 syslog
// record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow // record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
// or unreachable endpoint never holds up a request or stdout. // or unreachable endpoint never holds up a request or stdout.
@@ -24,7 +24,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// The forms of SWW_LOG_REMOTE_URL, by its scheme. // The forms of SWW_WAF_LOG_REMOTE_URL, by its scheme.
const ( const (
SchemeUDP = "syslog+udp" SchemeUDP = "syslog+udp"
SchemeTCP = "syslog+tcp" SchemeTCP = "syslog+tcp"
@@ -56,18 +56,18 @@ const (
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
// URL is the endpoint (SWW_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or // URL is the endpoint (SWW_WAF_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
// SchemeTLS, a host and a port. // SchemeTLS, a host and a port.
URL *url.URL URL *url.URL
// RootCAs are the certificates a SchemeTLS endpoint's certificate // RootCAs are the certificates a SchemeTLS endpoint's certificate
// must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil for the host's. // must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
RootCAs *x509.CertPool RootCAs *x509.CertPool
// Buffer is the most lines held while they wait to be sent // Buffer is the most lines held while they wait to be sent
// (SWW_LOG_REMOTE_BUFFER). // (SWW_WAF_LOG_REMOTE_BUFFER).
Buffer int Buffer int
// Facility is the number of the records' syslog facility // Facility is the number of the records' syslog facility
// (SWW_LOG_REMOTE_FACILITY), and AppName their APP-NAME // (SWW_WAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
// (SWW_LOG_REMOTE_APP_NAME). // (SWW_WAF_LOG_REMOTE_APP_NAME).
Facility int Facility int
AppName string AppName string
} }
@@ -243,7 +243,7 @@ func (s *Sender) send(ctx context.Context, processLog *slog.Logger) net.Conn {
} }
} }
processLog.Warn("sending to SWW_LOG_REMOTE_URL failed", processLog.Warn("sending to SWW_WAF_LOG_REMOTE_URL failed",
"error", err.Error(), "connecting_again_in", delay.String()) "error", err.Error(), "connecting_again_in", delay.String())
select { select {
+1 -1
View File
@@ -586,7 +586,7 @@ func wantRetries(t *testing.T, logged *output, delays ...string) {
var fields map[string]any var fields map[string]any
err := json.Unmarshal([]byte(line), &fields) err := json.Unmarshal([]byte(line), &fields)
if err != nil || fields["msg"] != "sending to SWW_LOG_REMOTE_URL failed" { if err != nil || fields["msg"] != "sending to SWW_WAF_LOG_REMOTE_URL failed" {
t.Fatalf("logged %q", line) t.Fatalf("logged %q", line)
} }
+5 -5
View File
@@ -58,17 +58,17 @@ type Checks struct {
// AbuseIPDBParams are what NewAbuseIPDB needs. // AbuseIPDBParams are what NewAbuseIPDB needs.
type AbuseIPDBParams struct { type AbuseIPDBParams struct {
// URL is where clients are checked, normally AbuseIPDBURL, with Key, // URL is where clients are checked, normally AbuseIPDBURL, with Key,
// the account's key (SWW_ABUSEIPDB_KEY). // the account's key (SWW_WAF_ABUSEIPDB_KEY).
URL string URL string
Key string Key string
// MinScore is the least score that is a hit (SWW_ABUSEIPDB_MIN_SCORE), // MinScore is the least score that is a hit (SWW_WAF_ABUSEIPDB_MIN_SCORE),
// and DailyBudget the most checks made in a day, in UTC // and DailyBudget the most checks made in a day, in UTC
// (SWW_ABUSEIPDB_DAILY_BUDGET). // (SWW_WAF_ABUSEIPDB_DAILY_BUDGET).
MinScore int64 MinScore int64
DailyBudget int DailyBudget int
// CacheTTL is how long a score is used after it was fetched // CacheTTL is how long a score is used after it was fetched
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a check may take // (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take
// (SWW_REPUTATION_TIMEOUT). // (SWW_WAF_REPUTATION_TIMEOUT).
CacheTTL time.Duration CacheTTL time.Duration
Timeout time.Duration Timeout time.Duration
// Now tells the time, normally time.Now in UTC. // Now tells the time, normally time.Now in UTC.
+7 -7
View File
@@ -53,15 +53,15 @@ type Verdict struct {
// DNSBLParams are what NewDNSBL needs. // DNSBLParams are what NewDNSBL needs.
type DNSBLParams struct { type DNSBLParams struct {
// Zones are the DNSBL zones clients are asked about in // Zones are the DNSBL zones clients are asked about in
// (SWW_DNSBL_ZONES). // (SWW_WAF_DNSBL_ZONES).
Zones []string Zones []string
// Resolver is the resolver they are asked through // Resolver is the resolver they are asked through
// (SWW_DNSBL_RESOLVER), or, while it is the zero AddrPort, the // (SWW_WAF_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
// host's, as /etc/resolv.conf names it. // host's, as /etc/resolv.conf names it.
Resolver netip.AddrPort Resolver netip.AddrPort
// CacheTTL is how long a verdict is used after it was fetched // CacheTTL is how long a verdict is used after it was fetched
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a query may take // (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take
// (SWW_REPUTATION_TIMEOUT). // (SWW_WAF_REPUTATION_TIMEOUT).
CacheTTL time.Duration CacheTTL time.Duration
Timeout time.Duration Timeout time.Duration
// Now tells the time, normally time.Now in UTC. // Now tells the time, normally time.Now in UTC.
@@ -127,13 +127,13 @@ func NewDNSBL(params DNSBLParams) *DNSBL {
} }
} }
// Zones returns the zones, in the order SWW_DNSBL_ZONES names them. // Zones returns the zones, in the order SWW_WAF_DNSBL_ZONES names them.
func (d *DNSBL) Zones() []string { func (d *DNSBL) Zones() []string {
return slices.Clone(d.params.Zones) return slices.Clone(d.params.Zones)
} }
// ListedBy returns the zones whose verdict on addr, a client's address, // ListedBy returns the zones whose verdict on addr, a client's address,
// lists it, in the order SWW_DNSBL_ZONES names them, each with its key // lists it, in the order SWW_WAF_DNSBL_ZONES names them, each with its key
// masked, as config.MaskZoneKey masks it, since they go to the request // masked, as config.MaskZoneKey masks it, since they go to the request
// log, the alerts and the metrics. A verdict is used until CacheTTL has // log, the alerts and the metrics. A verdict is used until CacheTTL has
// passed since it was fetched. Each zone without one is asked about addr // passed since it was fetched. Each zone without one is asked about addr
@@ -210,7 +210,7 @@ func (d *DNSBL) Snapshot() []Verdict {
} }
// Load keeps verdicts, read from reputation.json, in place of those it // Load keeps verdicts, read from reputation.json, in place of those it
// keeps, but for those of a zone SWW_DNSBL_ZONES does not name, and, // keeps, but for those of a zone SWW_WAF_DNSBL_ZONES does not name, and,
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or // past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
// more is neither used nor written, as for any verdict. // more is neither used nor written, as for any verdict.
func (d *DNSBL) Load(verdicts []Verdict) { func (d *DNSBL) Load(verdicts []Verdict) {
+3 -3
View File
@@ -40,8 +40,8 @@ const (
// zone and otherZone are the DNSBL zones the tests name. // zone and otherZone are the DNSBL zones the tests name.
zone = "dnsbl.example" zone = "dnsbl.example"
otherZone = "other.example" otherZone = "other.example"
// cacheTTL is the tests' SWW_REPUTATION_CACHE_TTL, and timeout their // cacheTTL is the tests' SWW_WAF_REPUTATION_CACHE_TTL, and timeout their
// SWW_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf // SWW_WAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
// can have Go's resolver wait for one server, so that it is the // can have Go's resolver wait for one server, so that it is the
// DNSBL's own timeout that ends a query, whatever that file says. // DNSBL's own timeout that ends a query, whatever that file says.
cacheTTL = 24 * time.Hour cacheTTL = 24 * time.Hour
@@ -419,7 +419,7 @@ func TestNeitherAVerdictOfAZoneNotNamedNorOnePastItsTTLIsKept(t *testing.T) {
dnsbl := reputation.NewDNSBL(p) dnsbl := reputation.NewDNSBL(p)
// The last verdict still in use, one fetched a TTL ago, and one of a // The last verdict still in use, one fetched a TTL ago, and one of a
// zone SWW_DNSBL_ZONES does not name. // zone SWW_WAF_DNSBL_ZONES does not name.
inUse := reputation.Verdict{ inUse := reputation.Verdict{
Zone: zone, Client: netip.MustParseAddr(listed), Listed: true, Zone: zone, Client: netip.MustParseAddr(listed), Listed: true,
Fetched: now.Add(-cacheTTL + time.Nanosecond), Fetched: now.Add(-cacheTTL + time.Nanosecond),
+12 -12
View File
@@ -1,11 +1,11 @@
// Package reputation fetches the lists the settings name by URL: the // Package reputation fetches the lists the settings name by URL: the
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines // blocklists of SWW_WAF_BLOCKLIST_URLS, the file of AS:percent lines
// SWW_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_TAG_LISTS, and // SWW_WAF_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_WAF_TAG_LISTS, and
// the decision list of the CrowdSec engine SWW_CROWDSEC_LAPI_URL names. It // the decision list of the CrowdSec engine SWW_WAF_CROWDSEC_LAPI_URL names. It
// keeps the last good copy of // keeps the last good copy of
// each, whole, comment lines included, which is used while a fetch fails, // each, whole, comment lines included, which is used while a fetch fails,
// and when each was last tried. It also asks the DNSBL zones of // and when each was last tried. It also asks the DNSBL zones of
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks // SWW_WAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks
// clients with AbuseIPDB, and keeps their scores and the checks spent // clients with AbuseIPDB, and keeps their scores and the checks spent
// today. The state package writes all of these to reputation.json and // today. The state package writes all of these to reputation.json and
// reads them from it, so that a restart keeps them too. // reads them from it, so that a restart keeps them too.
@@ -67,20 +67,20 @@ type List struct {
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
// BlocklistURLs are the blocklists (SWW_BLOCKLIST_URLS), and // BlocklistURLs are the blocklists (SWW_WAF_BLOCKLIST_URLS), and
// ASNLimitPercentURL the file of AS:percent lines // ASNLimitPercentURL the file of AS:percent lines
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset. // (SWW_WAF_ASN_LIMIT_PERCENT_URL), "" while it is unset.
BlocklistURLs []string BlocklistURLs []string
ASNLimitPercentURL string ASNLimitPercentURL string
// TagLists are the address lists that add a tag (SWW_TAG_LISTS). // TagLists are the address lists that add a tag (SWW_WAF_TAG_LISTS).
TagLists []config.TagList TagLists []config.TagList
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while // CrowdSecDecisionsURL is the CrowdSec decision list, "" while
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey // SWW_WAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
// (SWW_CROWDSEC_LAPI_KEY). // (SWW_WAF_CROWDSEC_LAPI_KEY).
CrowdSecDecisionsURL string CrowdSecDecisionsURL string
CrowdSecKey string CrowdSecKey string
// Refresh is how long after a list was last fetched or tried it is // Refresh is how long after a list was last fetched or tried it is
// fetched again (SWW_BLOCKLIST_REFRESH), but for the CrowdSec decision // fetched again (SWW_WAF_BLOCKLIST_REFRESH), but for the CrowdSec decision
// list, which is fetched again crowdSecRefresh after. // list, which is fetched again crowdSecRefresh after.
Refresh time.Duration Refresh time.Duration
// Now tells the time, normally time.Now in UTC. // Now tells the time, normally time.Now in UTC.
@@ -155,7 +155,7 @@ func New(params Params) *Lists {
} }
// URLs returns the URL of every list: the blocklists' in the order // URLs returns the URL of every list: the blocklists' in the order
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then the // SWW_WAF_BLOCKLIST_URLS names them, then SWW_WAF_ASN_LIMIT_PERCENT_URL, then the
// tag lists', then the CrowdSec decision list's. // tag lists', then the CrowdSec decision list's.
func (l *Lists) URLs() []string { func (l *Lists) URLs() []string {
urls := slices.Clone(l.params.BlocklistURLs) urls := slices.Clone(l.params.BlocklistURLs)
@@ -175,7 +175,7 @@ func (l *Lists) URLs() []string {
} }
// ListedBy returns the URLs of the blocklists whose copy lists addr, in // ListedBy returns the URLs of the blocklists whose copy lists addr, in
// the order SWW_BLOCKLIST_URLS names them. // the order SWW_WAF_BLOCKLIST_URLS names them.
func (l *Lists) ListedBy(addr netip.Addr) []string { func (l *Lists) ListedBy(addr netip.Addr) []string {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
+3 -3
View File
@@ -34,8 +34,8 @@ const (
dropURL = "https://lists.example/drop.txt" dropURL = "https://lists.example/drop.txt"
torURL = "https://lists.example/tor.txt" torURL = "https://lists.example/tor.txt"
asnURL = "https://lists.example/asn.txt" asnURL = "https://lists.example/asn.txt"
// refresh is the tests' SWW_BLOCKLIST_REFRESH, and cooldown their // refresh is the tests' SWW_WAF_BLOCKLIST_REFRESH, and cooldown their
// SWW_ALERT_COOLDOWN, longer than it. // SWW_WAF_ALERT_COOLDOWN, longer than it.
refresh = 24 * time.Hour refresh = 24 * time.Hour
cooldown = 48 * time.Hour cooldown = 48 * time.Hour
// drop is a blocklist as the Spamhaus DROP list is written, with an // drop is a blocklist as the Spamhaus DROP list is written, with an
@@ -116,7 +116,7 @@ func TestClientIsListedByEachBlocklistThatListsIt(t *testing.T) {
}} }}
lists := start(t, servers, params(torURL, dropURL)) lists := start(t, servers, params(torURL, dropURL))
// In the order SWW_BLOCKLIST_URLS names them. // In the order SWW_WAF_BLOCKLIST_URLS names them.
wantListedBy(t, lists, "203.0.113.9", torURL, dropURL) wantListedBy(t, lists, "203.0.113.9", torURL, dropURL)
wantListedBy(t, lists, "203.0.113.8", dropURL) wantListedBy(t, lists, "203.0.113.8", dropURL)
}) })
+71 -61
View File
@@ -20,39 +20,44 @@ const (
// the app. // the app.
ActionForward = "forward" ActionForward = "forward"
// ActionTooLarge is a request over the header limits. // ActionRequestHeaderTooLarge is a request over the header limits.
ActionTooLarge = "too_large" ActionRequestHeaderTooLarge = "request_header_too_large"
// ActionRateLimited is a request refused because it took its client // ActionClientIsRateLimited is a request refused because it took its
// over a rate limit, which bans the client. // client over a rate limit, which bans the client.
ActionRateLimited = "rate_limited" ActionClientIsRateLimited = "client_is_rate_limited"
// ActionBanned is a request refused because a ban covers its client, // ActionTemporarilyBanned is a request refused because a ban that runs
// or because it matched a ban rule, asked for a trap path or the // out covers its client, or because it matched a ban rule, asked for a
// CrowdSec decision list lists its client, each of which bans the // trap path or the CrowdSec decision list lists its client, and so made
// client. // such a ban.
ActionBanned = "banned" ActionTemporarilyBanned = "temporarily_banned"
// ActionRuleBlocked is a request refused because it matched a block // ActionPermanentlyBanned is a request refused because a ban that never
// rule. // runs out covers its client, or because the request made such a ban or
ActionRuleBlocked = "rule_blocked" // made its client's ban permanent.
ActionPermanentlyBanned = "permanently_banned"
// ActionWAFBlocked is a request refused because the Core Rule Set // ActionBlockedByBlockRule is a request refused because it matched a
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD. // block rule.
ActionWAFBlocked = "waf_blocked" ActionBlockedByBlockRule = "blocked_by_block_rule"
// ActionDenied is a request refused because its client is in // ActionBlockedByCoreRuleSet is a request refused because the Core Rule
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny, // Set scored it at or over SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set"
// ActionClientDenied is a request refused because its client is in
// SWW_WAF_DENY_NETS, in a blocklist while SWW_WAF_BLOCKLIST_ACTION is deny,
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while // or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY. // SWW_WAF_REPUTATION_ACTION is deny, or that carries a tag in SWW_WAF_TAG_DENY.
ActionDenied = "denied" ActionClientDenied = "client_denied"
// ActionCountryDenied is a request refused because its client's country // ActionCountryDenied is a request refused because its client's country
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES. // is not in SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
ActionCountryDenied = "country_denied" ActionCountryDenied = "country_denied"
// ActionAdmin is a request discriminator answered at one of its own // ActionOwnEndpoint is a request discriminator answered at one of its own
// endpoints, or a question about a path under /_sww/. // endpoints, or a question about a path under /_sww/.
ActionAdmin = "admin" ActionOwnEndpoint = "own_endpoint"
) )
// OffenceLimit is the offence a request line names for a request that // OffenceLimit is the offence a request line names for a request that
@@ -60,9 +65,9 @@ const (
// limit. // limit.
const OffenceLimit = "limit" const OffenceLimit = "limit"
// LimitHitErrorBurst is the limit_hit a request line names for a request // LimitHitMinuteRefusals is the limit_hit a request line names for a request
// that broke the error burst. // that broke the error burst.
const LimitHitErrorBurst = "error_burst" const LimitHitMinuteRefusals = "minute_refusals"
// The discriminator_line a request line names, which says which of // The discriminator_line a request line names, which says which of
// discriminator's two lines about a question it is. // discriminator's two lines about a question it is.
@@ -120,7 +125,7 @@ type Line struct {
ContentType string `json:"content_type,omitempty"` ContentType string `json:"content_type,omitempty"`
// ContentLength is the length of its body the request announced. // ContentLength is the length of its body the request announced.
ContentLength int64 `json:"content_length,omitempty"` ContentLength int64 `json:"content_length,omitempty"`
// RequestHeaders are the headers SWW_LOG_REQUEST_HEADERS names that // RequestHeaders are the headers SWW_WAF_LOG_REQUEST_HEADERS names that
// the request carried, by name in lower case. // the request carried, by name in lower case.
RequestHeaders map[string]string `json:"request_headers,omitempty"` RequestHeaders map[string]string `json:"request_headers,omitempty"`
HasAuthorization bool `json:"has_authorization,omitempty"` HasAuthorization bool `json:"has_authorization,omitempty"`
@@ -134,10 +139,11 @@ type Line struct {
// The decision. // The decision.
Action string `json:"action"` Action string `json:"action"`
// WouldAction is, in observe mode, the action enforce mode would have // WouldAction is, in observe mode, the action enforce mode would have
// taken with a request it would have refused: ActionDenied, // taken with a request it would have refused: ActionClientDenied,
// ActionBanned, ActionCountryDenied, ActionRateLimited, // ActionTemporarilyBanned, ActionPermanentlyBanned, ActionCountryDenied,
// ActionRuleBlocked, ActionWAFBlocked, or, in discriminator, ActionTooLarge // ActionClientIsRateLimited, ActionBlockedByBlockRule,
// for the header limits. // ActionBlockedByCoreRuleSet, or, in discriminator,
// ActionRequestHeaderTooLarge for the header limits.
WouldAction string `json:"would_action,omitempty"` WouldAction string `json:"would_action,omitempty"`
// Heuristic is the name of the heuristic that refused the request, in // Heuristic is the name of the heuristic that refused the request, in
// observe mode too, and HeuristicFigures are the figures it acted on, by // observe mode too, and HeuristicFigures are the figures it acted on, by
@@ -149,15 +155,15 @@ type Line struct {
// request. // request.
AfterAnswerHeuristic string `json:"after_answer_heuristic,omitempty"` AfterAnswerHeuristic string `json:"after_answer_heuristic,omitempty"`
AfterAnswerHeuristicFigures map[string]any `json:"after_answer_heuristic_figures,omitempty"` //nolint:lll // a struct tag cannot be split AfterAnswerHeuristicFigures map[string]any `json:"after_answer_heuristic_figures,omitempty"` //nolint:lll // a struct tag cannot be split
// LimitPercent and LimitPercentSetting are, for a request the rate // RateLimitPercent and RateLimitPercentSetting are, for a request the
// limits counted whose client a biased threshold gives a percentage of // rate limits counted whose client a biased threshold gives a percentage
// the rate limits below 100, that percentage and the setting that gave // of the rate limits below 100, that percentage and the setting that gave
// it. BytesPercent and BytesPercentSetting are the same for the byte // it. BytesLimitPercent and BytesLimitPercentSetting are the same for the
// limits. // byte limits.
LimitPercent *int64 `json:"limit_percent,omitempty"` RateLimitPercent *int64 `json:"rate_limit_percent,omitempty"`
LimitPercentSetting string `json:"limit_percent_setting,omitempty"` RateLimitPercentSetting string `json:"rate_limit_percent_setting,omitempty"`
BytesPercent *int64 `json:"bytes_percent,omitempty"` BytesLimitPercent *int64 `json:"bytes_limit_percent,omitempty"`
BytesPercentSetting string `json:"bytes_percent_setting,omitempty"` BytesLimitPercentSetting string `json:"bytes_limit_percent_setting,omitempty"`
// Counts are, for a request the rate limits counted, the client's // Counts are, for a request the rate limits counted, the client's
// requests as they counted them with this one, and its bytes as the // requests as they counted them with this one, and its bytes as the
// byte limits counted them, with this request's once it has ended if // byte limits counted them, with this request's once it has ended if
@@ -165,15 +171,15 @@ type Line struct {
Counts ratelimit.Counts `json:"counts,omitzero"` Counts ratelimit.Counts `json:"counts,omitzero"`
// RuleIDs are the ids of the rule file rules the request matched. // RuleIDs are the ids of the rule file rules the request matched.
RuleIDs []string `json:"rule_ids,omitempty"` RuleIDs []string `json:"rule_ids,omitempty"`
// WAFRuleIDs are the ids of the Core Rule Set's rules the request // CoreRuleSetRuleIDs are the ids of the Core Rule Set's rules the request
// matched, and WAFScore its anomaly score, nil for a request the Core // matched, and CoreRuleSetScore its anomaly score, nil for a request the
// Rule Set did not inspect. // Core Rule Set did not inspect.
WAFRuleIDs []int `json:"waf_rule_ids,omitempty"` CoreRuleSetRuleIDs []int `json:"core_rule_set_rule_ids,omitempty"`
WAFScore *int `json:"waf_score,omitempty"` CoreRuleSetScore *int `json:"core_rule_set_score,omitempty"`
// LimitHit is the window whose limit the request went over, named as // LimitHit is the window whose limit the request went over, named as
// Counts names its count: minute, hour or day for a rate limit, and // Counts names its count: minute, hour or day for a rate limit, and
// minute_bytes, hour_bytes or day_bytes for a byte limit; or // minute_bytes, hour_bytes or day_bytes for a byte limit; or
// LimitHitErrorBurst for the error burst. // LimitHitMinuteRefusals for the error burst.
LimitHit string `json:"limit_hit,omitempty"` LimitHit string `json:"limit_hit,omitempty"`
// Reputation are the URLs of the blocklists that list the client, then // Reputation are the URLs of the blocklists that list the client, then
// that of the CrowdSec decision list when it does, then the DNSBL zones // that of the CrowdSec decision list when it does, then the DNSBL zones
@@ -182,21 +188,25 @@ type Line struct {
Reputation []string `json:"reputation,omitempty"` Reputation []string `json:"reputation,omitempty"`
// Tags are the request's tags, sorted. // Tags are the request's tags, sorted.
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
// RouteWatchTagsMissing, while SWW_WAF_ROUTEWATCH_URL is set, says why the
// request got no tags from routewatch: no_answer_yet, request_failed or
// no_route.
RouteWatchTagsMissing string `json:"routewatch_tags_missing,omitempty"`
// Offence is the offence the request was held as, OffenceLimit. // Offence is the offence the request was held as, OffenceLimit.
Offence string `json:"offence,omitempty"` Offence string `json:"offence,omitempty"`
// BanExpires is when the ban the request made, or was refused under, // BanExpires is when the ban the request made, or was refused under,
// ends: a time, or "permanent". // ends: a time, or "permanent".
BanExpires string `json:"ban_expires,omitempty"` BanExpires string `json:"ban_expires,omitempty"`
// The timings, in milliseconds. DurationChecks is the time until the // The timings. DurationChecksMS is the time until the checks were done, and
// checks were done, and DurationWAF the part of it the Core Rule Set // DurationCoreRuleSetMS the part of it the Core Rule Set took.
// took. DurationUpstreamTotal is what the report of an allowed request gives, // DurationUpstreamTotalMS is what the report of an allowed request gives,
// from the hand-over to the app until the end of its answer. Each but // from the hand-over to the app until the end of its answer. Each but
// DurationTotal is nil for a request that did not get that far. // DurationTotalMS is nil for a request that did not get that far.
DurationTotal float64 `json:"duration_total"` DurationTotalMS float64 `json:"duration_total_ms"`
DurationChecks *float64 `json:"duration_checks,omitempty"` DurationChecksMS *float64 `json:"duration_checks_ms,omitempty"`
DurationWAF *float64 `json:"duration_waf,omitempty"` DurationCoreRuleSetMS *float64 `json:"duration_core_rule_set_ms,omitempty"`
DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"` DurationUpstreamTotalMS *float64 `json:"duration_upstream_total_ms,omitempty"`
} }
// RProxyLine is rproxy's short line in the request log about one request. The // RProxyLine is rproxy's short line in the request log about one request. The
@@ -216,13 +226,13 @@ type RProxyLine struct {
Status int `json:"status"` Status int `json:"status"`
RequestBytes int64 `json:"request_bytes"` RequestBytes int64 `json:"request_bytes"`
ResponseBytes int64 `json:"response_bytes"` ResponseBytes int64 `json:"response_bytes"`
// DurationRequest runs in milliseconds from the request's headers being read // DurationRequestMS runs from the request's headers being read until the app
// until the app has been sent the whole request, or the answer has started // has been sent the whole request, or the answer has started if that is
// if that is sooner. DurationResponse runs from then until the answer ends. // sooner. DurationResponseMS runs from then until the answer ends.
DurationRequest float64 `json:"duration_request"` DurationRequestMS float64 `json:"duration_request_ms"`
DurationResponse float64 `json:"duration_response"` DurationResponseMS float64 `json:"duration_response_ms"`
// Disposition is the valid disposition discriminator answered or, while // Disposition is the valid disposition discriminator answered or, while
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is "" // DefaultDisposition is true, the value of SWW_RPROXY_DEFAULT_DISPOSITION. It is ""
// when neither gave one. // when neither gave one.
Disposition string `json:"disposition,omitempty"` Disposition string `json:"disposition,omitempty"`
DefaultDisposition bool `json:"default_disposition,omitempty"` DefaultDisposition bool `json:"default_disposition,omitempty"`
@@ -274,8 +284,8 @@ func Milliseconds(d time.Duration) float64 {
// NewProcessLogger returns the logger for the process's own messages: // NewProcessLogger returns the logger for the process's own messages:
// JSON lines on w, marked "type":"process", with the time in the same form // JSON lines on w, marked "type":"process", with the time in the same form
// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance. // as a request line's, and instanceName, SWW_WAF_INSTANCE_NAME, as instance.
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe; // It writes only the messages at level, SWW_WAF_LOG_LEVEL, or more severe;
// the request lines Write writes are never held back. // the request lines Write writes are never held back.
func NewProcessLogger( func NewProcessLogger(
w io.Writer, w io.Writer,
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more