Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5364bf282 |
No files matched your search
+44
-56
@@ -12,8 +12,8 @@ configured only by environment variables, that provides:
|
|||||||
- R1: rate limits per minute, per hour and per day
|
- R1: rate limits per minute, per hour and per day
|
||||||
- R2: netblocks that bypass rate limiting
|
- R2: netblocks that bypass rate limiting
|
||||||
- R3: alerts on attacks through webhook, Slack or ntfy
|
- R3: alerts on attacks through webhook, Slack or ntfy
|
||||||
- R4: alerts on anomalies: requests made or bytes transferred per minute or
|
- R4: alerts on anomalies: requests or bytes per minute or hour, for given IPs
|
||||||
hour, for given IPs or netblocks, crossing a threshold
|
or netblocks, crossing a threshold
|
||||||
- R5: temporary blocks for abusers, permanent bans for repeat offenders
|
- R5: temporary blocks for abusers, permanent bans for repeat offenders
|
||||||
- R6: one or more RBLs or IP reputation APIs
|
- R6: one or more RBLs or IP reputation APIs
|
||||||
- R7: AS number lookup
|
- R7: AS number lookup
|
||||||
@@ -24,19 +24,14 @@ configured only by environment variables, that provides:
|
|||||||
|
|
||||||
## Verdict
|
## Verdict
|
||||||
|
|
||||||
Nothing existing is suitable. No surveyed tool meets R8, which asks for limits
|
Nothing existing is suitable. No surveyed tool meets R8 (scaling limits by AS
|
||||||
scaled by AS number or country. None meets R4's alerts on bytes transferred. The
|
number or country) or the bytes half of R4 at all, and the tools that come
|
||||||
tools that come closest on the rest fail R10: they need several containers, YAML
|
closest on the rest fail R10: they need several containers, YAML or a web UI.
|
||||||
or a web UI. The recommendation is to build a small single-binary sidecar;
|
The recommendation is to build a small single-binary sidecar; `SPEC.md` in this
|
||||||
`SPEC.md` in this directory begins that spec.
|
directory begins that spec. It reuses the mature parts of the surveyed field as
|
||||||
|
libraries and data sources (Coraza and the OWASP Core Rule Set for attack
|
||||||
The sidecar reuses the mature parts of the surveyed field instead of
|
detection, DNSBLs, AbuseIPDB, optionally a CrowdSec decision feed) instead of
|
||||||
reimplementing them. It takes these as libraries and data sources:
|
reimplementing them.
|
||||||
|
|
||||||
- Coraza and the OWASP Core Rule Set, for attack detection
|
|
||||||
- DNSBLs
|
|
||||||
- AbuseIPDB
|
|
||||||
- optionally, a CrowdSec decision feed
|
|
||||||
|
|
||||||
Closest existing options, and why each still falls short:
|
Closest existing options, and why each still falls short:
|
||||||
|
|
||||||
@@ -50,16 +45,13 @@ Closest existing options, and why each still falls short:
|
|||||||
external blacklist URLs, optional CrowdSec), R7 partly (AS number used for
|
external blacklist URLs, optional CrowdSec), R7 partly (AS number used for
|
||||||
blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule
|
blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule
|
||||||
Set, or Coraza plugin), env-var settings.
|
Set, or Coraza plugin), env-var settings.
|
||||||
- Fails:
|
- Fails: R8 (AS number and country can only allow or deny, never scale a
|
||||||
- R8: AS number and country can only allow or deny, never scale a limit.
|
limit), R4 (no volume or byte threshold alerts in the free edition;
|
||||||
- R4: no alerts on request-count or byte thresholds in the free edition;
|
reporting is a paid feature), R5 escalation, and R10 in spirit: since 1.6
|
||||||
reporting is a paid feature.
|
it needs a `bunkerweb` container plus a `bw-scheduler` container and a
|
||||||
- R5: no escalation for repeat offenders.
|
database, or the all-in-one image that bundles nginx, scheduler, UI and
|
||||||
- R10: since 1.6 it is not one plain sidecar. It needs a `bunkerweb`
|
Redis in one container. It is designed to be the front door for many
|
||||||
container, a `bw-scheduler` container and a database, or the
|
sites, not a per-app sidecar. AGPL-3.0.
|
||||||
all-in-one image that bundles nginx, the scheduler, the UI and Redis.
|
|
||||||
- It is designed as the proxy in front of many sites, not as a sidecar for
|
|
||||||
one app. AGPL-3.0.
|
|
||||||
- Unverified: whether several rates (minute, hour, day) can be stacked on
|
- Unverified: whether several rates (minute, hour, day) can be stacked on
|
||||||
the same URL pattern; the documented design is one rate per URL pattern.
|
the same URL pattern; the documented design is one rate per URL pattern.
|
||||||
- CrowdSec is the strongest detection and ban engine, but it is not a proxy.
|
- CrowdSec is the strongest detection and ban engine, but it is not a proxy.
|
||||||
@@ -74,20 +66,16 @@ Closest existing options, and why each still falls short:
|
|||||||
listed AS numbers is possible, but each is a hand-written YAML scenario,
|
listed AS numbers is possible, but each is a hand-written YAML scenario,
|
||||||
it reacts after the fact by banning, and it is not an inline limiter that
|
it reacts after the fact by banning, and it is not an inline limiter that
|
||||||
answers 429.
|
answers 429.
|
||||||
- Fails:
|
- Fails: R10 (needs the security engine container with persistent state, log
|
||||||
- R10, because it needs:
|
acquisition from traefik, a bouncer such as the traefik plugin, and YAML
|
||||||
- the security engine container with persistent state
|
for acquisition, profiles, scenarios and notifications), bytes half of R4.
|
||||||
- log acquisition from traefik
|
|
||||||
- a bouncer such as the traefik plugin
|
|
||||||
- YAML for acquisition, profiles, scenarios and notifications
|
|
||||||
- R4's alerts on bytes.
|
|
||||||
- CrowdSec plus traefik's own `rateLimit` middleware is the best combination
|
- CrowdSec plus traefik's own `rateLimit` middleware is the best combination
|
||||||
with no new code. It gives inline limiting (one window per middleware, keyed
|
with no new code. It gives inline limiting (one window per middleware, keyed
|
||||||
by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and
|
by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and
|
||||||
reputation lookups. It still fails R8, R4's alerts on bytes, and R10 entirely:
|
reputation. It still fails R8, the bytes half of R4, R1's three stacked
|
||||||
the configuration lives in traefik labels and CrowdSec YAML on each host, not
|
windows only by chaining three middlewares per router, and R10 entirely: the
|
||||||
in one sidecar's environment. It meets R1's three windows only by chaining
|
configuration lives in traefik labels and CrowdSec YAML on each host, not in
|
||||||
three middlewares per router.
|
one sidecar's environment.
|
||||||
|
|
||||||
## Candidate by candidate
|
## Candidate by candidate
|
||||||
|
|
||||||
@@ -99,10 +87,10 @@ Closest existing options, and why each still falls short:
|
|||||||
cached, streamed decision list (recommended), standalone against the
|
cached, streamed decision list (recommended), standalone against the
|
||||||
central API, or AppSec only. Supports captcha remediation.
|
central API, or AppSec only. Supports captcha remediation.
|
||||||
- Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict).
|
- Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict).
|
||||||
- Misses: the plugin itself does no rate limiting; R8; R4's alerts on bytes.
|
- Misses: the plugin itself does no rate limiting; R8; R4 bytes.
|
||||||
- Configuration: traefik static config to load the plugin, dynamic config or
|
- Configuration: traefik static config to load the plugin, dynamic config or
|
||||||
labels for the middleware, CrowdSec YAML for everything else.
|
labels for the middleware, CrowdSec YAML for everything else.
|
||||||
- Sidecar fit: no. It runs inside traefik and needs a separate engine
|
- Sidecar fit: no. It lives inside traefik, plus a separate engine
|
||||||
container.
|
container.
|
||||||
- Maturity: widely used (about 900 stars, listed in the traefik plugin
|
- Maturity: widely used (about 900 stars, listed in the traefik plugin
|
||||||
catalog, documented by CrowdSec itself), actively maintained. Traefik
|
catalog, documented by CrowdSec itself), actively maintained. Traefik
|
||||||
@@ -127,7 +115,7 @@ Closest existing options, and why each still falls short:
|
|||||||
- What it is: nginx-derived (Tengine) reverse proxy with a proprietary
|
- What it is: nginx-derived (Tengine) reverse proxy with a proprietary
|
||||||
semantic detection engine and a web console. About 22,000 stars.
|
semantic detection engine and a web console. About 22,000 stars.
|
||||||
- Covers: R9 well, basic per-IP rate limiting with block duration, bot
|
- Covers: R9 well, basic per-IP rate limiting with block duration, bot
|
||||||
challenge, authentication in front of the app.
|
challenge, authentication gate.
|
||||||
- Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert
|
- Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert
|
||||||
messages, syslog forwarding and the better reputation database are paid
|
messages, syslog forwarding and the better reputation database are paid
|
||||||
(Lite about 10 USD per month, Pro about 100 USD per month); the free
|
(Lite about 10 USD per month, Pro about 100 USD per month); the free
|
||||||
@@ -140,7 +128,7 @@ Closest existing options, and why each still falls short:
|
|||||||
- Coraza (`corazawaf/coraza`) and Coraza-based proxies
|
- Coraza (`corazawaf/coraza`) and Coraza-based proxies
|
||||||
- What it is: a Go library that implements the ModSecurity rule language and
|
- What it is: a Go library that implements the ModSecurity rule language and
|
||||||
runs the OWASP Core Rule Set. OWASP project, actively maintained, the
|
runs the OWASP Core Rule Set. OWASP project, actively maintained, the
|
||||||
successor to ModSecurity, which is now in maintenance only.
|
successor path now that ModSecurity is in maintenance only.
|
||||||
- Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy),
|
- Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy),
|
||||||
`coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and
|
`coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and
|
||||||
`coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set,
|
`coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set,
|
||||||
@@ -154,7 +142,7 @@ Closest existing options, and why each still falls short:
|
|||||||
- ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`)
|
- ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`)
|
||||||
- What it is: official images of Apache or nginx with ModSecurity and the
|
- What it is: official images of Apache or nginx with ModSecurity and the
|
||||||
Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`,
|
Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`,
|
||||||
paranoia level, anomaly thresholds, many more). Current images contain
|
paranoia level, anomaly thresholds, many more). Current images carry
|
||||||
ModSecurity 2.9.x on Apache and Core Rule Set 4.x.
|
ModSecurity 2.9.x on Apache and Core Rule Set 4.x.
|
||||||
- Covers: R9, and R10 (single container, env vars, one backend).
|
- Covers: R9, and R10 (single container, env vars, one backend).
|
||||||
- Misses: R1 to R8.
|
- Misses: R1 to R8.
|
||||||
@@ -164,8 +152,8 @@ Closest existing options, and why each still falls short:
|
|||||||
- Anubis (`TecharoHQ/anubis`), 1.27 current
|
- Anubis (`TecharoHQ/anubis`), 1.27 current
|
||||||
- What it is: a single-binary reverse proxy that makes browsers solve a
|
- What it is: a single-binary reverse proxy that makes browsers solve a
|
||||||
proof-of-work challenge before passing them to `TARGET`. Aimed at
|
proof-of-work challenge before passing them to `TARGET`. Aimed at
|
||||||
scrapers, which are likely a large share of the unwanted traffic on a
|
scrapers, which is likely a large share of unwanted traffic on a public
|
||||||
public code forge.
|
code forge.
|
||||||
- Covers: R10 well (one container, env vars for listener, target,
|
- Covers: R10 well (one container, env vars for listener, target,
|
||||||
difficulty, cookies). Policy rules can match on path, user agent, headers,
|
difficulty, cookies). Policy rules can match on path, user agent, headers,
|
||||||
IP ranges, and with the vendor's hosted data service also AS number and
|
IP ranges, and with the vendor's hosted data service also AS number and
|
||||||
@@ -192,13 +180,13 @@ Closest existing options, and why each still falls short:
|
|||||||
temporary ban, no escalation, state lost on traefik restart) and R2
|
temporary ban, no escalation, state lost on traefik restart) and R2
|
||||||
(allowlist). Nothing else.
|
(allowlist). Nothing else.
|
||||||
- `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock
|
- `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock
|
||||||
plugins (country allow or deny through an external lookup API): each adds
|
plugins (country allow or deny through an external lookup API): same
|
||||||
one small feature, is configured in traefik and keeps its state in memory.
|
shape, each one small feature, configured in traefik, state in memory.
|
||||||
- Sidecar fit: no; they live inside traefik.
|
- Sidecar fit: no; they live inside traefik.
|
||||||
- Traefik built-in middlewares
|
- Traefik built-in middlewares
|
||||||
- `rateLimit` (one average-and-burst window per middleware, optional Redis
|
- `rateLimit` (one average-and-burst window per middleware, optional Redis
|
||||||
in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation
|
in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation
|
||||||
lookups or AS number lookups.
|
or AS number awareness.
|
||||||
- caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x
|
- caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x
|
||||||
- What it is: a Caddy module with regex rules and anomaly scoring, per-IP
|
- What it is: a Caddy module with regex rules and anomaly scoring, per-IP
|
||||||
and per-path rate limiting with one configurable window, IP and DNS
|
and per-path rate limiting with one configurable window, IP and DNS
|
||||||
@@ -210,12 +198,12 @@ Closest existing options, and why each still falls short:
|
|||||||
- open-appsec (Check Point)
|
- open-appsec (Check Point)
|
||||||
- Machine-learning WAF agent attached to nginx, Kong, Envoy or similar, with
|
- Machine-learning WAF agent attached to nginx, Kong, Envoy or similar, with
|
||||||
a declarative policy file or the vendor's cloud console. Covers R9 only;
|
a declarative policy file or the vendor's cloud console. Covers R9 only;
|
||||||
rate limiting and richer features are in paid tiers. It attaches to an
|
rate limiting and richer features are in paid tiers. Not a sidecar in the
|
||||||
existing proxy, so it is not the sidecar R10 asks for.
|
required sense.
|
||||||
- iocaine
|
- iocaine
|
||||||
- Serves generated garbage pages to clients the fronting proxy classifies as
|
- Serves generated garbage pages to clients the fronting proxy classifies as
|
||||||
scrapers. Not a limiter, WAF or ban tool; out of scope, and only of
|
scrapers. Not a limiter, WAF or ban tool; out of scope except as a
|
||||||
passing interest for scraper traffic.
|
curiosity for scraper traffic.
|
||||||
- Pangolin
|
- Pangolin
|
||||||
- A tunnelled access platform that bundles traefik and optionally CrowdSec.
|
- A tunnelled access platform that bundles traefik and optionally CrowdSec.
|
||||||
Replaces the ingress rather than adding a sidecar; out of scope.
|
Replaces the ingress rather than adding a sidecar; out of scope.
|
||||||
@@ -226,17 +214,17 @@ Closest existing options, and why each still falls short:
|
|||||||
Everyone else offers one window per rule or none.
|
Everyone else offers one window per rule or none.
|
||||||
- R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins.
|
- R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins.
|
||||||
- R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid).
|
- R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid).
|
||||||
- R4 alerts on request counts and bytes: none. CrowdSec can approximate alerts
|
- R4 volume and byte anomalies: none. CrowdSec can approximate request-count
|
||||||
on request counts with a custom scenario; no surveyed tool alerts on bytes.
|
anomalies with a custom scenario; nothing handles bytes.
|
||||||
- R5 temporary then permanent: CrowdSec only. BunkerWeb's ban is either one
|
- R5 temporary then permanent: CrowdSec only. BunkerWeb has fixed or permanent,
|
||||||
fixed length or permanent, and does not change with a client's history.
|
not both by history.
|
||||||
- R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists).
|
- R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists).
|
||||||
- R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or
|
- R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or
|
||||||
deny), Anubis (hosted service).
|
deny), Anubis (hosted service).
|
||||||
- R8 biased thresholds: none.
|
- R8 biased thresholds: none.
|
||||||
- R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open,
|
- R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open,
|
||||||
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps Coraza
|
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps the
|
||||||
or ModSecurity).
|
first).
|
||||||
- R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis.
|
- R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis.
|
||||||
None of these covers anything beyond its one job.
|
None of these covers anything beyond its one job.
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ without an answer in time, `rproxy` passes the request to the app.
|
|||||||
It protects against:
|
It protects against:
|
||||||
|
|
||||||
- Scrapers that fetch every page of a site, all day. Each client gets a limit on
|
- Scrapers that fetch every page of a site, all day. Each client gets a limit on
|
||||||
requests and on body bytes per minute, per hour and per day. The defaults
|
requests and on bytes per minute, per hour and per day. The defaults leave
|
||||||
leave room for a person reading a site for hours. They catch a scraper that
|
room for a person reading a site for hours. They catch a scraper that keeps up
|
||||||
keeps up one request every 2 seconds. A client that breaks a limit is banned:
|
one request every 2 seconds. A client over a limit is refused and banned: for
|
||||||
for a while at first, for three times as long at each repeat, and permanently
|
a while at first, for three times as long at each repeat, and permanently when
|
||||||
when it keeps at it.
|
it keeps at it.
|
||||||
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
|
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
|
||||||
default rule file bans such a client at its first probe, and its next request
|
default rule file bans such a client at its first probe, and its next request
|
||||||
during the ban makes the ban permanent. Your own rules, or a list of trap
|
during the ban makes the ban permanent. Your own rules, or a list of trap
|
||||||
@@ -173,7 +173,7 @@ does.
|
|||||||
the request have `"action":"forward"`.
|
the request have `"action":"forward"`.
|
||||||
- To see a refusal without banning yourself, run `discriminator` with
|
- To see a refusal without banning yourself, run `discriminator` with
|
||||||
`SWW_MODE=observe` and ask for `/.env`. The request reaches the app.
|
`SWW_MODE=observe` and ask for `/.env`. The request reaches the app.
|
||||||
`discriminator`'s log lines have `"would_action":"temporarily_banned"` and
|
`discriminator`'s log lines have `"would_action":"banned"` and
|
||||||
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
|
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
|
||||||
"Clients and bans" below describes.
|
"Clients and bans" below describes.
|
||||||
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
||||||
@@ -210,132 +210,129 @@ account, a key, a token or an alert destination stays off until you give one.
|
|||||||
percentage is a whole number from 0 to 100. A path prefix starts with `/`.
|
percentage is a whole number from 0 to 100. A path prefix starts with `/`.
|
||||||
- `off` switches a limit, a threshold or a timeout off where the table says a
|
- `off` switches a limit, a threshold or a timeout off where the table says a
|
||||||
setting takes it.
|
setting takes it.
|
||||||
- The byte limits and the byte anomaly thresholds count only body bytes: the
|
|
||||||
app's response bodies, the visitors' request bodies, or both, as
|
|
||||||
`SWW_BYTES_COUNT` says. Headers are never counted. What the visitor sent over
|
|
||||||
a WebSocket counts as request bytes, and what the app sent as response bytes.
|
|
||||||
- An invalid setting stops the process at start with one log line naming the
|
- An invalid setting stops the process at start with one log line naming the
|
||||||
variable and what is wrong with the value. The settings in effect are logged
|
variable and what is wrong with the value. The settings in effect are logged
|
||||||
on one line at start, with tokens, keys and webhook paths shown as `********`.
|
on one line at start, with tokens, keys and webhook paths shown as `********`.
|
||||||
|
|
||||||
Every setting the code reads, with its default:
|
Every setting the code reads, with its default:
|
||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
| ---------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
|
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
|
||||||
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
|
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
|
||||||
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
||||||
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
||||||
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
|
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
|
||||||
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
|
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
|
||||||
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
||||||
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
||||||
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
|
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. |
|
||||||
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
|
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
|
||||||
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
|
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
|
||||||
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
|
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
|
||||||
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
|
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
|
||||||
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
||||||
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
||||||
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
||||||
| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
|
| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
|
||||||
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
||||||
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
|
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
|
||||||
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
||||||
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. |
|
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
|
||||||
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
|
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
|
||||||
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
|
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
|
||||||
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
|
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
|
||||||
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
|
||||||
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
|
||||||
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
|
||||||
| `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
|
| `SWW_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
|
||||||
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
|
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
|
||||||
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
||||||
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
||||||
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. |
|
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
|
||||||
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
||||||
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
||||||
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
||||||
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
|
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. |
|
||||||
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
||||||
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
||||||
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
||||||
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
||||||
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
|
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
|
||||||
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
||||||
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
|
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
|
||||||
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
|
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
|
||||||
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
||||||
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
||||||
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). |
|
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). |
|
||||||
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
||||||
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
||||||
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
||||||
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
||||||
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
||||||
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
||||||
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
|
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
|
||||||
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||||
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
||||||
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
||||||
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
| `SWW_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;cloud.example.com:gitea`; only those apps' fixes apply there. Unset, every app's fixes apply to every hostname. |
|
||||||
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
||||||
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||||
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||||
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
||||||
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
||||||
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
||||||
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
||||||
| `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_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
||||||
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
|
| `SWW_CROWDSEC_LAPI_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_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_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_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
||||||
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
||||||
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
||||||
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
||||||
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
||||||
| `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_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
||||||
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
|
| `SWW_LOG_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_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_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_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_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_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
|
||||||
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
||||||
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
||||||
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
| `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_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
||||||
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
||||||
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
||||||
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
||||||
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
||||||
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
|
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
||||||
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
|
||||||
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
|
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
||||||
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
|
||||||
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
||||||
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
||||||
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
||||||
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
|
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
||||||
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
|
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
|
||||||
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. |
|
||||||
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
||||||
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
|
| `SWW_ANOMALY_NET_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_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
|
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
|
||||||
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. |
|
||||||
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
||||||
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
||||||
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
||||||
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
|
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
||||||
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
|
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
|
||||||
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
|
||||||
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
||||||
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
|
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
||||||
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
|
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
|
||||||
| `SWW_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_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. |
|
||||||
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
||||||
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
||||||
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
|
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
||||||
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
|
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
|
||||||
|
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. |
|
||||||
|
|
||||||
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
[`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.
|
||||||
@@ -531,9 +528,8 @@ of the request reaches the app.
|
|||||||
|
|
||||||
17. The request is passed to the app, streaming.
|
17. The request is passed to the app, streaming.
|
||||||
18. After the answer, `rproxy` sends `discriminator` a report with the status
|
18. After the answer, `rproxy` sends `discriminator` a report with the status
|
||||||
and the body bytes each way. For an allowed request, `discriminator` counts
|
and the bytes each way. For an allowed request, `discriminator` counts the
|
||||||
the bytes `SWW_BYTES_COUNT` names toward the byte limits, bans the client if
|
bytes toward the byte limits and adds the request to the client's history.
|
||||||
they take it past one, and adds the request to the client's history.
|
|
||||||
|
|
||||||
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
||||||
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
||||||
@@ -578,11 +574,15 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
|||||||
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
||||||
front of a code forge, bodies carry what people write and publish, which the
|
front of a code forge, bodies carry what people write and publish, which the
|
||||||
rules take for attacks.
|
rules take for attacks.
|
||||||
- `smallwebwaf` changes the Core Rule Set in eight ways so that it lets ordinary
|
- `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary
|
||||||
requests to a code forge through, such as git over HTTP and sign-in through
|
requests to a code forge through, such as git over HTTP and sign-in through
|
||||||
Git Credential Manager. On gitea's own paths, its searches, file names and
|
Git Credential Manager. No setting undoes the changes; "Attack detection" in
|
||||||
branch names get through too. No setting undoes the changes; "Attack
|
[`SPEC.md`](SPEC.md) lists them.
|
||||||
detection" in [`SPEC.md`](SPEC.md) lists them.
|
- Each supported app also has its own fixes, which let its ordinary requests
|
||||||
|
through. gitea's let its searches, file names and branch names through.
|
||||||
|
`SWW_APPS` names the apps each hostname serves. A hostname gets only its own
|
||||||
|
apps' fixes, and a hostname it does not list gets none. Unset, every app's
|
||||||
|
fixes apply to every hostname.
|
||||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
||||||
fields or paths.
|
fields or paths.
|
||||||
- Rule files are plain text, one rule to a line, edited by hand and read again
|
- Rule files are plain text, one rule to a line, edited by hand and read again
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ to them.
|
|||||||
key or a token stays off until the operator supplies it.
|
key or a token stays off until the operator supplies it.
|
||||||
- It keeps a site answering while the part that decides is down, slow or
|
- It keeps a site answering while the part that decides is down, slow or
|
||||||
restarting (see "Without an answer from `discriminator`").
|
restarting (see "Without an answer from `discriminator`").
|
||||||
- It limits each client's requests and body bytes per minute, hour and day. It
|
- It limits each client's requests and bytes per minute, hour and day. It bounds
|
||||||
bounds the size and duration of every request and answer. It detects attacks.
|
the size and duration of every request and answer. It detects attacks.
|
||||||
- It bans abusers: briefly at first, for seven days on a clear sign of attack,
|
- It bans abusers: briefly at first, for seven days on a clear sign of attack,
|
||||||
and permanently when they keep at it.
|
and permanently when they keep at it.
|
||||||
- It looks up each client's AS number and country. It can refuse whole countries
|
- It looks up each client's AS number and country. It can refuse whole countries
|
||||||
@@ -81,8 +81,8 @@ to them.
|
|||||||
of bans is full.
|
of bans is full.
|
||||||
- `internal/rules`: reads the rule files at start and again when they
|
- `internal/rules`: reads the rule files at start and again when they
|
||||||
change, and tells which rules a request matches.
|
change, and tells which rules a request matches.
|
||||||
- `internal/waf`: the Core Rule Set, with the eight changes listed under
|
- `internal/waf`: the Core Rule Set, with the six changes and the apps'
|
||||||
"Attack detection", as Coraza directives.
|
fixes listed under "Attack detection", as Coraza directives.
|
||||||
- `internal/lookup`: looks clients up through GeoJS or in the lookup
|
- `internal/lookup`: looks clients up through GeoJS or in the lookup
|
||||||
database.
|
database.
|
||||||
- `internal/reputation`: fetches the lists, asks the DNS blocklists and
|
- `internal/reputation`: fetches the lists, asks the DNS blocklists and
|
||||||
@@ -192,12 +192,11 @@ request log's `heuristic` field gives that name. `refusal_limit` and
|
|||||||
sent is removed.
|
sent is removed.
|
||||||
18. After the visitor's answer has ended, `rproxy` sends the report (see "The
|
18. After the visitor's answer has ended, `rproxy` sends the report (see "The
|
||||||
report"). `discriminator` then counts:
|
report"). `discriminator` then counts:
|
||||||
- For a request it allowed, the body bytes of the response, of the request
|
- For a request it allowed, the response bytes, the request bytes or both
|
||||||
or of both are added to the byte counters, as `SWW_BYTES_COUNT` says. For
|
are added to the byte counters, as `SWW_BYTES_COUNT` says. For a
|
||||||
a WebSocket, what it carried each way counts once it closes. A client
|
WebSocket, what it carried each way counts once it closes. A client whose
|
||||||
whose total passes a byte limit times the percentage has broken that limit
|
total passes a byte limit times the percentage has broken that limit and
|
||||||
and is banned (`byte_limits`). The request that broke it has already been
|
is banned (`byte_limits`). The response in progress is not cut off.
|
||||||
passed on whole; nothing is cut off.
|
|
||||||
- For a request it allowed, the client's history and the anomaly counters
|
- For a request it allowed, the client's history and the anomaly counters
|
||||||
are updated, and the alert thresholds are evaluated.
|
are updated, and the alert thresholds are evaluated.
|
||||||
|
|
||||||
@@ -260,8 +259,7 @@ to a ban, each by its own rule:
|
|||||||
clear sign of attack bans it permanently at once.
|
clear sign of attack bans it permanently at once.
|
||||||
- A broken limit, one of:
|
- A broken limit, one of:
|
||||||
- a request over a rate limit;
|
- a request over a rate limit;
|
||||||
- the bytes counted for a request and its response taking the client's total
|
- a response that takes the client's byte total past a byte limit;
|
||||||
past a byte limit;
|
|
||||||
- more than `SWW_ERROR_BURST_THRESHOLD` refusals within a minute.
|
- more than `SWW_ERROR_BURST_THRESHOLD` refusals within a minute.
|
||||||
|
|
||||||
The bans for a broken limit:
|
The bans for a broken limit:
|
||||||
@@ -317,13 +315,6 @@ ban is dropped alike. A scanner whose ban was dropped that way is refused and
|
|||||||
banned again by its next probe. A ban whose cause is `admin` is never dropped
|
banned again by its next probe. A ban whose cause is `admin` is never dropped
|
||||||
and does not count toward `SWW_MAX_BANS`, so such bans can never fill the table.
|
and does not count toward `SWW_MAX_BANS`, so such bans can never fill the table.
|
||||||
|
|
||||||
A request refused under a ban that runs out is logged with the action
|
|
||||||
`temporarily_banned`. Under a permanent ban the action is `permanently_banned`.
|
|
||||||
A request refused for a clear sign of attack or a CrowdSec decision takes the
|
|
||||||
action for the ban it made. A request that makes a ban permanent is logged as
|
|
||||||
`permanently_banned`. A request over a rate limit is logged as
|
|
||||||
`client_is_rate_limited`, whichever ban it makes.
|
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
`docs/settings.md` lists every setting with its default and the values it takes,
|
`docs/settings.md` lists every setting with its default and the values it takes,
|
||||||
@@ -457,15 +448,10 @@ the same way.
|
|||||||
|
|
||||||
### Byte limits, per client
|
### Byte limits, per client
|
||||||
|
|
||||||
The byte limits count body bytes: the app's response bodies, the visitors'
|
By default there is no minute limit, so a client can download in a burst at full
|
||||||
request bodies, or both, as `SWW_BYTES_COUNT` says. Headers are never counted.
|
speed. The hour limit defaults to 50 GiB and the day limit to 100 GiB. They
|
||||||
What the visitor sent over a WebSocket counts as request bytes, and what the app
|
allow several downloads of a few GiB each. A response's bytes are counted when
|
||||||
sent as response bytes. The bytes counted for a request and its response are
|
it ends (see step 18).
|
||||||
added to the client's total once the response has ended (see step 18). So the
|
|
||||||
request that takes a client past a limit is passed on whole, and the ban refuses
|
|
||||||
the client's requests from then on. By default there is no minute limit, so a
|
|
||||||
client can download in a burst at full speed. The hour limit defaults to 50 GiB
|
|
||||||
and the day limit to 100 GiB. They allow several downloads of a few GiB each.
|
|
||||||
|
|
||||||
### Lookup of AS number and country
|
### Lookup of AS number and country
|
||||||
|
|
||||||
@@ -568,10 +554,9 @@ lower case, except a country code, which is in capitals. `SWW_TAG_DENY` and
|
|||||||
|
|
||||||
Two settings act on tags:
|
Two settings act on tags:
|
||||||
|
|
||||||
- `SWW_TAG_DENY` lists tags whose requests are refused with the action
|
- `SWW_TAG_DENY` lists tags whose requests are refused with the action `denied`
|
||||||
`client_denied` (`tag_deny`). The check runs twice: just before the rate
|
(`tag_deny`). The check runs twice: just before the rate limits, and again
|
||||||
limits, and again after the Core Rule Set, so that `exploitsignature` can
|
after the Core Rule Set, so that `exploitsignature` can refuse too.
|
||||||
refuse too.
|
|
||||||
- `SWW_TAG_LIMIT_PERCENT` lists tags with a percentage from 1 to 99. Their
|
- `SWW_TAG_LIMIT_PERCENT` lists tags with a percentage from 1 to 99. Their
|
||||||
clients get that percentage of every request and byte limit. The lowest
|
clients get that percentage of every request and byte limit. The lowest
|
||||||
percentage among a client's tags applies. `exploitsignature` cannot be listed,
|
percentage among a client's tags applies. `exploitsignature` cannot be listed,
|
||||||
@@ -653,7 +638,7 @@ files, the limits and the bans still apply to the client that sends it.
|
|||||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as
|
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as
|
||||||
Coraza directives. An exclusion switches one rule off for some fields or paths
|
Coraza directives. An exclusion switches one rule off for some fields or paths
|
||||||
only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file
|
only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file
|
||||||
holds an app's own exclusions; the eighth change below holds gitea's.
|
holds an app's own exclusions; gitea's fixes below hold gitea's.
|
||||||
- `discriminator` loads the file after `smallwebwaf`'s own setup rules and
|
- `discriminator` loads the file after `smallwebwaf`'s own setup rules and
|
||||||
before the Core Rule Set's rules. The Core Rule Set loads its own
|
before the Core Rule Set's rules. The Core Rule Set loads its own
|
||||||
`REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point.
|
`REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point.
|
||||||
@@ -699,7 +684,7 @@ files, the limits and the bans still apply to the client that sends it.
|
|||||||
scanner from an ordinary client. In front of a code forge, registry and git
|
scanner from an ordinary client. In front of a code forge, registry and git
|
||||||
clients get `404` and `401` by design, hundreds of times a minute.
|
clients get `404` and `401` by design, hundreds of times a minute.
|
||||||
|
|
||||||
`smallwebwaf` changes the Core Rule Set 4.25.0 in eight ways, since in front of
|
`smallwebwaf` changes the Core Rule Set 4.25.0 in six ways, since in front of
|
||||||
gitea it would otherwise refuse ordinary requests. The changes hold in front of
|
gitea it would otherwise refuse ordinary requests. The changes hold in front of
|
||||||
every app, and no setting undoes them:
|
every app, and no setting undoes them:
|
||||||
|
|
||||||
@@ -718,8 +703,52 @@ every app, and no setting undoes them:
|
|||||||
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
|
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
|
||||||
`http://127.0.0.1` on the user's own machine. The server never fetches that
|
`http://127.0.0.1` on the user's own machine. The server never fetches that
|
||||||
address.
|
address.
|
||||||
4. Ten query parameters are not checked against the Core Rule Set's lists of
|
4. `Referer` is not checked for a Unix command given without arguments (932340),
|
||||||
system files (930120), shell paths (932160) and command names (932260):
|
for Java starting a process (944110) or for a Java class name (944130).
|
||||||
|
`Referer` names the page a request comes from, on everything that page loads
|
||||||
|
and on every link followed from it. The Core Rule Set takes some page
|
||||||
|
addresses for attacks:
|
||||||
|
- a search for `env`;
|
||||||
|
- OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`;
|
||||||
|
- a file named `java.lang.String.md`.
|
||||||
|
|
||||||
|
Every request from such a page would be refused. `Referer` keeps the other
|
||||||
|
rules, except after some gitea searches (gitea's fixes). An app that passes
|
||||||
|
`Referer` to a shell must check it itself.
|
||||||
|
|
||||||
|
5. Responses are not inspected. A raw file from a repository, such as a shell
|
||||||
|
script, looks to the response rules like source code leaking from the server.
|
||||||
|
6. The `from` parameter of a container registry's upload path,
|
||||||
|
`/v2/<image>/blobs/uploads/`, is not checked for command names (932260). When
|
||||||
|
a push reuses a layer, `from` names the image the layer comes from. An image
|
||||||
|
such as `owner/python3-app` would have that push refused.
|
||||||
|
|
||||||
|
Each supported app also has its own fixes, which let its ordinary requests
|
||||||
|
through. An app's fixes hold for the hostnames `SWW_APPS` lists the app for:
|
||||||
|
|
||||||
|
- `SWW_APPS` holds entries separated by semicolons. Each is a hostname, a colon,
|
||||||
|
then one or more apps separated by commas, for example
|
||||||
|
`git.example.com:gitea;code.example.com:gitea`.
|
||||||
|
- The request's `Host` is matched by exact name, without its port and without
|
||||||
|
regard to case. There are no wildcards.
|
||||||
|
- A hostname not listed gets no app's fixes, so every rule checks it in full.
|
||||||
|
Set empty, `SWW_APPS` lists no hostname.
|
||||||
|
- Unset, every app's fixes hold for every hostname. An app's fixes then weaken
|
||||||
|
the check of any other app that serves the same paths or fields.
|
||||||
|
- Any of these mistakes stops the start:
|
||||||
|
- an unknown app;
|
||||||
|
- a hostname that is not a DNS name, such as `*.example.com`;
|
||||||
|
- a hostname listed twice;
|
||||||
|
- an app listed twice for one hostname;
|
||||||
|
- an entry without a colon.
|
||||||
|
|
||||||
|
`gitea` is the only app supported. Each app's fixes are in one file, such as
|
||||||
|
`internal/waf/gitea.conf`, and one entry of `apps` in `internal/waf/waf.go`.
|
||||||
|
gitea's fixes are:
|
||||||
|
|
||||||
|
1. Ten query parameters are not checked against the Core Rule Set's lists of
|
||||||
|
system files (930120), shell paths (932160) and command names (932260), on
|
||||||
|
any path:
|
||||||
- `path`, `files`, `skip-to` and `sub_path`;
|
- `path`, `files`, `skip-to` and `sub_path`;
|
||||||
- `ref`, `sha`, `branch` and `workflow`;
|
- `ref`, `sha`, `branch` and `workflow`;
|
||||||
- `artifactName` and `redirect_to`.
|
- `artifactName` and `redirect_to`.
|
||||||
@@ -732,41 +761,18 @@ every app, and no setting undoes them:
|
|||||||
catch reaches the app, such as the command `|cat /etc/passwd`. Path
|
catch reaches the app, such as the command `|cat /etc/passwd`. Path
|
||||||
traversal, SQL and script injection, and PHP, Java and Node.js code are
|
traversal, SQL and script injection, and PHP, Java and Node.js code are
|
||||||
still refused there. Other parameters keep all three rules, except on the
|
still refused there. Other parameters keep all three rules, except on the
|
||||||
gitea paths the eighth change names. An app that uses one of these
|
gitea paths the third fix names.
|
||||||
parameters as a file on the server, or passes it to a shell, must check the
|
|
||||||
value itself. A rule file can refuse it instead.
|
|
||||||
|
|
||||||
5. Two cookies are left out, and `Referer` keeps fewer rules:
|
2. The Core Rule Set reads the request without the `gitea_flash` and
|
||||||
- The Core Rule Set reads the request without the `gitea_flash` and
|
`redirect_to` cookies. gitea puts a message naming what was just done into
|
||||||
`redirect_to` cookies. gitea puts a message naming what was just done into
|
`gitea_flash`. It puts into `redirect_to` the page to return to after signing
|
||||||
`gitea_flash`. It puts into `redirect_to` the page to return to after
|
in. The Core Rule Set takes file names in them, such as `package.json`, for
|
||||||
signing in. The Core Rule Set takes file names in them, such as
|
attacks. A browser sends such a cookie with every request until gitea
|
||||||
`package.json`, for attacks. A browser sends such a cookie with every
|
replaces it. gitea cannot replace it while those requests are refused, so one
|
||||||
request until gitea replaces it. gitea cannot replace it while those
|
refusal would keep the browser out of the whole site. gitea shows the message
|
||||||
requests are refused, so one refusal would keep the browser out of the
|
with any script removed and returns only to its own pages. Every other cookie
|
||||||
whole site. gitea shows the message with any script removed and returns
|
is read in full.
|
||||||
only to its own pages. Every other cookie is read in full. An app that
|
3. On gitea's paths, some fields skip the rules that refused ordinary gitea
|
||||||
reads a cookie of either name must check it itself.
|
|
||||||
- `Referer` is not checked for a Unix command given without arguments
|
|
||||||
(932340), for Java starting a process (944110) or for a Java class name
|
|
||||||
(944130). `Referer` names the page a request comes from, on everything
|
|
||||||
that page loads and on every link followed from it. The Core Rule Set
|
|
||||||
takes some page addresses for attacks:
|
|
||||||
- a search for `env`;
|
|
||||||
- OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`;
|
|
||||||
- a file named `java.lang.String.md`.
|
|
||||||
|
|
||||||
Every request from such a page would be refused. `Referer` keeps the
|
|
||||||
other rules, except after some gitea searches (the eighth change). An
|
|
||||||
app that passes `Referer` to a shell must check it itself.
|
|
||||||
|
|
||||||
6. Responses are not inspected. A raw file from a repository, such as a shell
|
|
||||||
script, looks to the response rules like source code leaking from the server.
|
|
||||||
7. The `from` parameter of a container registry's upload path,
|
|
||||||
`/v2/<image>/blobs/uploads/`, is not checked for command names (932260). When
|
|
||||||
a push reuses a layer, `from` names the image the layer comes from. An image
|
|
||||||
such as `owner/python3-app` would have that push refused.
|
|
||||||
8. On gitea's paths, some fields skip the rules that refused ordinary gitea
|
|
||||||
requests there. They are the fields where gitea sends a search, a file name
|
requests there. They are the fields where gitea sends a search, a file name
|
||||||
or a branch name. `internal/waf/gitea.conf` lists each rule and the request
|
or a branch name. `internal/waf/gitea.conf` lists each rule and the request
|
||||||
it refused. The fields are:
|
it refused. The fields are:
|
||||||
@@ -784,8 +790,7 @@ every app, and no setting undoes them:
|
|||||||
- packages.
|
- packages.
|
||||||
|
|
||||||
A user's or an organisation's page is any path of one segment, so on
|
A user's or an organisation's page is any path of one segment, so on
|
||||||
every such path, on any app, `q` skips these three rules, for example on
|
every such path `q` skips these three rules, for example on `/search`.
|
||||||
`/search`.
|
|
||||||
|
|
||||||
Some of these searches also skip the rule for Java class names (944130),
|
Some of these searches also skip the rule for Java class names (944130),
|
||||||
which refused `org.apache.commons.logging`.
|
which refused `org.apache.commons.logging`.
|
||||||
@@ -823,11 +828,14 @@ every app, and no setting undoes them:
|
|||||||
`actions/upload-artifact@v3` (920450).
|
`actions/upload-artifact@v3` (920450).
|
||||||
|
|
||||||
Elsewhere these fields keep every rule. The paths are those of gitea at the
|
Elsewhere these fields keep every rule. The paths are those of gitea at the
|
||||||
site root. An app other than gitea that serves one of them must check these
|
site root.
|
||||||
fields itself.
|
|
||||||
|
|
||||||
The parameter and cookie names in these changes are matched without regard to
|
On a hostname that gets gitea's fixes, an app other than gitea must check these
|
||||||
case, as Coraza matches them.
|
fields and cookies itself. Listing each hostname's own apps in `SWW_APPS` keeps
|
||||||
|
the full check for the others.
|
||||||
|
|
||||||
|
The parameter names in these changes and fixes are matched without regard to
|
||||||
|
case, and the cookie names exactly.
|
||||||
|
|
||||||
### Ban lengths, response and scope
|
### Ban lengths, response and scope
|
||||||
|
|
||||||
@@ -968,8 +976,7 @@ which the metrics show. They reveal a widely distributed scraper, thousands of
|
|||||||
addresses at low rates each, which the per-client limits do not see. A low
|
addresses at low rates each, which the per-client limits do not see. A low
|
||||||
percentage for its `asn=` tag in `SWW_TAG_LIMIT_PERCENT` is the response.
|
percentage for its `asn=` tag in `SWW_TAG_LIMIT_PERCENT` is the response.
|
||||||
|
|
||||||
- Requests and body bytes, as `SWW_BYTES_COUNT` counts them, are counted per
|
- Requests and bytes are counted per minute and per hour, for each of:
|
||||||
minute and per hour, for each of:
|
|
||||||
- a client;
|
- a client;
|
||||||
- the surrounding netblock;
|
- the surrounding netblock;
|
||||||
- an AS number;
|
- an AS number;
|
||||||
@@ -1279,9 +1286,9 @@ short.
|
|||||||
### Answering in time
|
### Answering in time
|
||||||
|
|
||||||
`rproxy` stops waiting for `discriminator` after `SWRP_TIMEOUT_MS`, by default 2
|
`rproxy` stops waiting for `discriminator` after `SWRP_TIMEOUT_MS`, by default 2
|
||||||
seconds, and `SWRP_DEFAULT_DISPOSITION` then decides. The longest a question
|
seconds, and `SWRP_DEFAULT_DISPOSITION` then decides. So at the defaults,
|
||||||
waits on an outside source is `SWW_LOOKUP_TIMEOUT`, by default 800 milliseconds,
|
`discriminator` answers a request well within that time, whatever the request
|
||||||
well within that time.
|
and whatever its outside sources do.
|
||||||
|
|
||||||
GeoJS is the only outside source a question can wait on. A question waits only
|
GeoJS is the only outside source a question can wait on. A question waits only
|
||||||
when step 6 says so, and for at most `SWW_LOOKUP_TIMEOUT`, by default 800
|
when step 6 says so, and for at most `SWW_LOOKUP_TIMEOUT`, by default 800
|
||||||
@@ -1325,9 +1332,9 @@ over either one:
|
|||||||
`Transfer-Encoding` do not count. `X-Request-ID` and `X-Forwarded-Proto` do.
|
`Transfer-Encoding` do not count. `X-Request-ID` and `X-Forwarded-Proto` do.
|
||||||
|
|
||||||
Real clients never reach these limits. The answer is `deny` with `431`, and the
|
Real clients never reach these limits. The answer is `deny` with `431`, and the
|
||||||
log line's action is `request_header_too_large`. The refusal makes no ban and is
|
log line's action is `too_large`. The refusal makes no ban and is no offence.
|
||||||
no offence. The rate limits count the request as usual, so a client that keeps
|
The rate limits count the request as usual, so a client that keeps sending such
|
||||||
sending such requests breaks a limit.
|
requests breaks a limit.
|
||||||
|
|
||||||
The limits apply on every path and in every `SWW_WAF_MODE`. A client in
|
The limits apply on every path and in every `SWW_WAF_MODE`. A client in
|
||||||
`SWW_ALLOW_NETS` skips them, as it skips the Core Rule Set. In `observe` mode
|
`SWW_ALLOW_NETS` skips them, as it skips the Core Rule Set. In `observe` mode
|
||||||
@@ -1377,9 +1384,9 @@ answer.
|
|||||||
For a request it holds, `discriminator` writes the request's last log line (see
|
For a request it holds, `discriminator` writes the request's last log line (see
|
||||||
"The log"). For a request it allowed, it first:
|
"The log"). For a request it allowed, it first:
|
||||||
|
|
||||||
- counts the body bytes `SWW_BYTES_COUNT` names, toward the client's byte limits
|
- counts the bytes as `SWW_BYTES_COUNT` says, toward the client's byte limits at
|
||||||
at the percentages it gave that request;
|
the percentages it gave that request;
|
||||||
- bans a client whose total passes a byte limit;
|
- bans a client that passes a byte limit;
|
||||||
- adds the request with its status to the client's history, the anomaly counters
|
- adds the request with its status to the client's history, the anomaly counters
|
||||||
and the metrics.
|
and the metrics.
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ What `GET /_sww/metrics` answers with, at `discriminator`'s own address.
|
|||||||
keeps the `instance` label as `exported_instance` unless the scrape sets
|
keeps the `instance` label as `exported_instance` unless the scrape sets
|
||||||
`honor_labels: true`. `rproxy` has no metrics.
|
`honor_labels: true`. `rproxy` has no metrics.
|
||||||
|
|
||||||
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
|
|
||||||
body bytes, never headers, whatever `SWW_BYTES_COUNT` says.
|
|
||||||
|
|
||||||
## Traffic
|
## Traffic
|
||||||
|
|
||||||
- `sww_requests_total`, `sww_request_bytes_total` and
|
- `sww_requests_total`, `sww_request_bytes_total` and
|
||||||
|
|||||||
+13
-17
@@ -96,15 +96,13 @@ Every one of these lines has:
|
|||||||
|
|
||||||
- `action`: what was done:
|
- `action`: what was done:
|
||||||
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
||||||
- `client_denied`: refused for `SWW_DENY_NETS`, for a reputation source with
|
- `denied`: refused for `SWW_DENY_NETS`, for a reputation source with the
|
||||||
the action `deny`, or for a tag in `SWW_TAG_DENY`;
|
action `deny`, or for a tag in `SWW_TAG_DENY`;
|
||||||
- `temporarily_banned`: refused under a ban that runs out, or for a `ban`
|
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
|
||||||
rule, a trap path or a CrowdSec decision that made such a ban;
|
CrowdSec decision, each of which bans the client;
|
||||||
- `permanently_banned`: refused under a ban that never runs out, or for a
|
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
||||||
request that made such a ban or made its client's ban permanent;
|
refused by that check;
|
||||||
- `country_denied`, `client_is_rate_limited`, `blocked_by_block_rule` and
|
- `too_large`: over a header limit;
|
||||||
`waf_blocked`: refused by that check;
|
|
||||||
- `request_header_too_large`: over a header limit;
|
|
||||||
- `admin`: answered at one of `discriminator`'s own endpoints, or `404` for
|
- `admin`: answered at one of `discriminator`'s own endpoints, or `404` for
|
||||||
a path under `/_sww/`.
|
a path under `/_sww/`.
|
||||||
- `would_action`: in `observe` mode, what `enforce` mode would have done with a
|
- `would_action`: in `observe` mode, what `enforce` mode would have done with a
|
||||||
@@ -147,22 +145,20 @@ Every one of these lines has:
|
|||||||
included. It is left out when rate limiting did not count the request. It
|
included. It is left out when rate limiting did not count the request. It
|
||||||
holds:
|
holds:
|
||||||
- `minute`, `hour` and `day`: requests;
|
- `minute`, `hour` and `day`: requests;
|
||||||
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
|
- `minute_bytes`, `hour_bytes` and `day_bytes`: bytes.
|
||||||
`SWW_BYTES_COUNT` names, in that window.
|
|
||||||
|
|
||||||
A count can have a fraction (see "Counting method" in
|
A count can have a fraction (see "Counting method" in
|
||||||
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
|
[`SPEC.md`](../SPEC.md)). The bytes of an allowed request are counted when
|
||||||
response are added when its report comes. So on the `decision` line, the
|
its report comes. So on the `decision` line, the bytes are without this
|
||||||
three byte totals leave them out.
|
request's.
|
||||||
|
|
||||||
- `limit_hit`: the limit broken:
|
- `limit_hit`: the limit broken:
|
||||||
- `minute`, `hour` or `day` for a rate limit;
|
- `minute`, `hour` or `day` for a rate limit;
|
||||||
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
||||||
- `error_burst`.
|
- `error_burst`.
|
||||||
|
|
||||||
When the bytes counted for a request and its response break a limit, the
|
A request whose bytes broke a limit was passed on whole, so its `action`
|
||||||
request has been passed on whole, so its `action` stays `forward`. Only its
|
stays `forward`. Only its `report` line gives that limit.
|
||||||
`report` line gives that limit.
|
|
||||||
|
|
||||||
- `rule_ids`: the rule file rules that matched, in order, up to the one that
|
- `rule_ids`: the rule file rules that matched, in order, up to the one that
|
||||||
refused.
|
refused.
|
||||||
|
|||||||
+26
-23
@@ -98,8 +98,6 @@ between the two (see "The image's command" below).
|
|||||||
|
|
||||||
## Request rate limits, per client
|
## Request rate limits, per client
|
||||||
|
|
||||||
The request past one of these limits is refused, and the client is banned.
|
|
||||||
|
|
||||||
- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
|
- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
|
||||||
client may make in a minute. A count or `off`. The default leaves room for a
|
client may make in a minute. A count or `off`. The default leaves room for a
|
||||||
person opening two pages at once with an empty cache, up to 60 files each.
|
person opening two pages at once with an empty cache, up to 60 files each.
|
||||||
@@ -122,22 +120,11 @@ The request past one of these limits is refused, and the client is banned.
|
|||||||
|
|
||||||
## Byte limits, per client
|
## Byte limits, per client
|
||||||
|
|
||||||
These count body bytes, never headers. What the visitor sent over a WebSocket
|
|
||||||
counts as request bytes, and what the app sent as response bytes. The bytes
|
|
||||||
counted for a request and its response are added to the client's total once the
|
|
||||||
response has ended. The request that takes a client's total past a limit is not
|
|
||||||
cut off; the client is banned.
|
|
||||||
|
|
||||||
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
|
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
|
||||||
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes
|
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes
|
||||||
counted for a client in each window, a size or `off`. `SWW_BYTES_COUNT` says
|
counted for a client in each window. A size or `off`.
|
||||||
which bytes count: response bodies, request bodies or both. The request that
|
- `SWW_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
||||||
passes the limit is not cut off, and the client is banned when its response
|
`response`, `request` or `both`.
|
||||||
ends.
|
|
||||||
- `SWW_BYTES_COUNT` (default `both`): what the byte limits and the byte anomaly
|
|
||||||
thresholds count: `response`, the app's response bodies; `request`, the
|
|
||||||
visitors' request bodies; or `both`. What the visitor sent over a WebSocket
|
|
||||||
counts as request bytes, and what the app sent as response bytes.
|
|
||||||
|
|
||||||
## Lookup of AS number and country
|
## Lookup of AS number and country
|
||||||
|
|
||||||
@@ -246,9 +233,26 @@ the request first.
|
|||||||
a range. So does a directive that removes or changes rules as the file loads,
|
a range. So does a directive that removes or changes rules as the file loads,
|
||||||
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
||||||
yet. It has no `_FILE` form.
|
yet. It has no `_FILE` form.
|
||||||
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get
|
- `SWW_APPS` (default unset): the apps each hostname serves. A request gets the
|
||||||
in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
fixes of its hostname's apps only (see "Attack detection" in
|
||||||
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
|
- Entries are separated by semicolons. Each is a hostname, a colon, then one
|
||||||
|
or more apps separated by commas, for example
|
||||||
|
`git.example.com:gitea;code.example.com:gitea`.
|
||||||
|
- `gitea` is the only app supported.
|
||||||
|
- The request's `Host` is matched by exact name, without its port and
|
||||||
|
without regard to case. There are no wildcards.
|
||||||
|
- A hostname not listed gets no app's fixes, so every rule checks it in
|
||||||
|
full. Set empty, it lists no hostname.
|
||||||
|
- Unset, every app's fixes apply to every hostname.
|
||||||
|
- Any of these mistakes stops the start:
|
||||||
|
- an unknown app;
|
||||||
|
- a hostname that is not a DNS name, such as `*.example.com`;
|
||||||
|
- a hostname listed twice;
|
||||||
|
- an app listed twice for one hostname;
|
||||||
|
- an entry without a colon.
|
||||||
|
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client in
|
||||||
|
a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
|
||||||
|
|
||||||
## Ban lengths, response and scope
|
## Ban lengths, response and scope
|
||||||
|
|
||||||
@@ -325,10 +329,9 @@ the request first.
|
|||||||
|
|
||||||
## Anomaly thresholds
|
## Anomaly thresholds
|
||||||
|
|
||||||
Each scope has four thresholds: the most requests and the most body bytes
|
Each scope has four thresholds: the most requests and the most bytes counted in
|
||||||
counted in a minute and in an hour. The bytes are those `SWW_BYTES_COUNT` names.
|
a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by
|
||||||
Requests are counts, bytes are sizes. Each is `off` by default. The four
|
default. The four thresholds per client are:
|
||||||
thresholds per client are:
|
|
||||||
|
|
||||||
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
||||||
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
||||||
|
|||||||
+3
-4
@@ -61,11 +61,10 @@ since it was first seen:
|
|||||||
- the first and last time seen;
|
- the first and last time seen;
|
||||||
- the AS number, AS name and country last looked up, and when;
|
- the AS number, AS name and country last looked up, and when;
|
||||||
- total requests, and how many were forwarded and how many refused;
|
- total requests, and how many were forwarded and how many refused;
|
||||||
- body bytes of its requests, and of the responses it was sent;
|
- bytes in each direction;
|
||||||
- responses by status class;
|
- responses by status class;
|
||||||
- offences by kind: `limit`, `attack`, `blocked_by_block_rule` and
|
- offences by kind: `limit`, `attack`, `rule_blocked` and `waf_blocked`. A file
|
||||||
`waf_blocked`. A file may also hold `token_refused`, which is kept, and which
|
may also hold `token_refused`, which is kept, and which nothing adds to.
|
||||||
nothing adds to.
|
|
||||||
|
|
||||||
## `lookups.json`
|
## `lookups.json`
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the settings of `sww discriminator`. A time or size limit of
|
// Config is the settings of `sww discriminator`. A time or size limit of
|
||||||
@@ -231,6 +232,11 @@ type Config struct {
|
|||||||
WAFExemptPaths []string
|
WAFExemptPaths []string
|
||||||
WAFBodyLimit int64
|
WAFBodyLimit int64
|
||||||
CoreRuleSetExclusionsFile string
|
CoreRuleSetExclusionsFile string
|
||||||
|
// Apps are the names of the apps each hostname serves, by hostname in
|
||||||
|
// lowercase (SWW_APPS): a request gets only its hostname's apps' fixes
|
||||||
|
// to the Core Rule Set. Apps is nil while SWW_APPS is unset, and every
|
||||||
|
// request gets every app's fixes.
|
||||||
|
Apps map[string][]string
|
||||||
// TrapPaths are the paths a request for which is a clear sign of
|
// TrapPaths are the paths a request for which is a clear sign of
|
||||||
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
|
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
|
||||||
TrapPaths []string
|
TrapPaths []string
|
||||||
@@ -400,6 +406,11 @@ var (
|
|||||||
errNotTrapPath = errors.New(
|
errNotTrapPath = errors.New(
|
||||||
"is not a path starting with / and without a ?, such as /wp-login.php",
|
"is not a path starting with / and without a ?, such as /wp-login.php",
|
||||||
)
|
)
|
||||||
|
errNotAppsEntry = errors.New(
|
||||||
|
"is not a hostname, : and a list of apps, such as git.example.com:gitea",
|
||||||
|
)
|
||||||
|
errNotHostname = errors.New("is not a DNS name such as git.example.com")
|
||||||
|
errNotApp = errors.New("is not an app sww has fixes for; the apps are")
|
||||||
errNotWAFMode = errors.New("is not off, detect or block")
|
errNotWAFMode = errors.New("is not off, detect or block")
|
||||||
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
||||||
errNotRuleID = errors.New(
|
errNotRuleID = errors.New(
|
||||||
@@ -566,6 +577,7 @@ func FromEnvironment(
|
|||||||
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
|
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
|
||||||
CoreRuleSetExclusionsFile: env.filePath(
|
CoreRuleSetExclusionsFile: env.filePath(
|
||||||
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
||||||
|
Apps: env.apps("SWW_APPS"),
|
||||||
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
|
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
|
||||||
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
|
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
|
||||||
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
|
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
|
||||||
@@ -821,6 +833,25 @@ func (e *environment) pathPrefixes(name, defaultValue string) []string {
|
|||||||
return prefixes
|
return prefixes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// apps reads the setting that is the apps each hostname serves. It is nil
|
||||||
|
// while the setting is unset, and logged as null, unlike a setting set
|
||||||
|
// empty.
|
||||||
|
func (e *environment) apps(name string) map[string][]string {
|
||||||
|
value, set := e.lookup(name)
|
||||||
|
if !set {
|
||||||
|
e.settings = append(e.settings, slog.Any(name, nil))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
e.settings = append(e.settings, slog.String(name, value))
|
||||||
|
|
||||||
|
apps, err := parseApps(value)
|
||||||
|
e.check(name, err)
|
||||||
|
|
||||||
|
return apps
|
||||||
|
}
|
||||||
|
|
||||||
// trapPaths reads the setting that is the list of trap paths. It is empty
|
// trapPaths reads the setting that is the list of trap paths. It is empty
|
||||||
// by default.
|
// by default.
|
||||||
func (e *environment) trapPaths(name string) []string {
|
func (e *environment) trapPaths(name string) []string {
|
||||||
@@ -1811,6 +1842,65 @@ func parseTrapPaths(value string) ([]string, error) {
|
|||||||
return paths, nil
|
return paths, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseApps reads the apps each hostname serves: entries separated by
|
||||||
|
// semicolons, each a hostname, a colon, and a comma-separated list of the
|
||||||
|
// names waf.AppNames returns, such as
|
||||||
|
// git.example.com:gitea;cloud.example.com:nextcloud,wordpress. Each
|
||||||
|
// hostname is a DNS name, without a wildcard. It returns them by hostname
|
||||||
|
// in lowercase, since a hostname is matched without regard to case. An
|
||||||
|
// empty value lists no hostname.
|
||||||
|
func parseApps(value string) (map[string][]string, error) {
|
||||||
|
apps := map[string][]string{}
|
||||||
|
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return apps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry := range strings.SplitSeq(value, ";") {
|
||||||
|
hostname, list, found := strings.Cut(entry, ":")
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname = strings.ToLower(strings.TrimSpace(hostname))
|
||||||
|
if hostname == "" {
|
||||||
|
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isZone(hostname) {
|
||||||
|
return nil, fmt.Errorf("%q %w", hostname, errNotHostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, listed := apps[hostname]; listed {
|
||||||
|
return nil, fmt.Errorf("%q %w", hostname, errListedTwice)
|
||||||
|
}
|
||||||
|
|
||||||
|
names, err := parseList(list)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, name := range names {
|
||||||
|
if !slices.Contains(waf.AppNames(), name) {
|
||||||
|
return nil, fmt.Errorf("%q %w %s", name, errNotApp,
|
||||||
|
strings.Join(waf.AppNames(), ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(names[:i], name) {
|
||||||
|
return nil, fmt.Errorf("%q %w", name, errListedTwice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apps[hostname] = names
|
||||||
|
}
|
||||||
|
|
||||||
|
return apps, nil
|
||||||
|
}
|
||||||
|
|
||||||
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
|
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
|
||||||
// rules, each a whole number above zero and outside firstSetupRuleID to
|
// rules, each a whole number above zero and outside firstSetupRuleID to
|
||||||
// lastSetupRuleID.
|
// lastSetupRuleID.
|
||||||
@@ -2406,7 +2496,8 @@ func MaskZoneKey(zone string) string {
|
|||||||
return masked + "." + rest
|
return masked + "." + rest
|
||||||
}
|
}
|
||||||
|
|
||||||
// isZone reports whether each label of zone is as parseZones takes it.
|
// isZone reports whether each label of zone is as parseZones takes it. It
|
||||||
|
// also checks the hostnames of SWW_APPS, which are DNS names too.
|
||||||
func isZone(zone string) bool {
|
func isZone(zone string) bool {
|
||||||
for label := range strings.SplitSeq(zone, ".") {
|
for label := range strings.SplitSeq(zone, ".") {
|
||||||
badChar := strings.ContainsFunc(label, func(char rune) bool {
|
badChar := strings.ContainsFunc(label, func(char rune) bool {
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ const (
|
|||||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
||||||
|
apps = "SWW_APPS"
|
||||||
trapPaths = "SWW_TRAP_PATHS"
|
trapPaths = "SWW_TRAP_PATHS"
|
||||||
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||||
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
||||||
@@ -581,6 +582,92 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApps(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
env environment
|
||||||
|
want map[string][]string
|
||||||
|
}{
|
||||||
|
{environment{}, nil},
|
||||||
|
{environment{apps: ""}, map[string][]string{}},
|
||||||
|
{
|
||||||
|
environment{apps: "Git.Example.com:gitea; cloud.example.com : gitea "},
|
||||||
|
map[string][]string{
|
||||||
|
"git.example.com": {"gitea"}, "cloud.example.com": {"gitea"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
cfg := fromEnvironment(t, tc.env)
|
||||||
|
if !reflect.DeepEqual(cfg.Apps, tc.want) {
|
||||||
|
t.Errorf("%v gave %#v, want %#v", tc.env, cfg.Apps, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unset and set empty, SWW_APPS has opposite effects, so it logs differently.
|
||||||
|
func TestUnsetAndEmptyAppsAreLoggedDifferently(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
env environment
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{environment{}, `"` + apps + `":null`},
|
||||||
|
{environment{apps: ""}, `"` + apps + `":""`},
|
||||||
|
} {
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
slog.New(slog.NewJSONHandler(&out, nil)).
|
||||||
|
Info("starting", "settings", fromEnvironment(t, tc.env))
|
||||||
|
|
||||||
|
if !strings.Contains(out.String(), tc.want) {
|
||||||
|
t.Errorf("%v logged %s, want %s in it", tc.env, out.String(), tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidAppsStopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
notEntry = " is not a hostname, : and a list of apps, " +
|
||||||
|
"such as git.example.com:gitea"
|
||||||
|
notHostname = " is not a DNS name such as git.example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, tc := range []struct{ value, want string }{
|
||||||
|
{"git.example.com:gitea;blog.example.com", `"blog.example.com"` + notEntry},
|
||||||
|
{"git.example.com:gitea;", `""` + notEntry},
|
||||||
|
{":gitea", `":gitea"` + notEntry},
|
||||||
|
{"git.example.com:", `"git.example.com:"` + notEntry},
|
||||||
|
{"*.example.com:gitea", `"*.example.com"` + notHostname},
|
||||||
|
{"git.example.com/:gitea", `"git.example.com/"` + notHostname},
|
||||||
|
{"git example.com:gitea", `"git example.com"` + notHostname},
|
||||||
|
{
|
||||||
|
"git.example.com:gitea,nextcloud",
|
||||||
|
`"nextcloud" is not an app sww has fixes for; the apps are gitea`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"git.example.com:gitea;GIT.example.com:gitea",
|
||||||
|
`"git.example.com" is listed twice`,
|
||||||
|
},
|
||||||
|
{"git.example.com:gitea,gitea", `"gitea" is listed twice`},
|
||||||
|
{"git.example.com:gitea,", `"gitea," has an empty item in its list`},
|
||||||
|
} {
|
||||||
|
t.Run(tc.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := config.FromEnvironment(environment{apps: tc.value}.lookupEnv)
|
||||||
|
|
||||||
|
want := apps + ": " + tc.want
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("error %v, want %s", err, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWAFBodyLimitOf64K(t *testing.T) {
|
func TestWAFBodyLimitOf64K(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -2530,6 +2617,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
wafExemptPaths: "",
|
wafExemptPaths: "",
|
||||||
wafBodyLimit: off,
|
wafBodyLimit: off,
|
||||||
exclusionsFile: "",
|
exclusionsFile: "",
|
||||||
|
apps: "", // logged as null, which decodes as ""
|
||||||
trapPaths: "",
|
trapPaths: "",
|
||||||
errorBurstThreshold: "30",
|
errorBurstThreshold: "30",
|
||||||
logRemoteURL: "",
|
logRemoteURL: "",
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &verdict{
|
return &verdict{
|
||||||
action: requestlog.ActionClientDenied,
|
action: requestlog.ActionDenied,
|
||||||
figures: map[string]any{
|
figures: map[string]any{
|
||||||
figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore,
|
figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
|||||||
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
|
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
|
||||||
http.StatusOK), want)
|
http.StatusOK), want)
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
if line.BanExpires != requestlog.FormatTime(expires) {
|
if line.BanExpires != requestlog.FormatTime(expires) {
|
||||||
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
|
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
|
||||||
}
|
}
|
||||||
@@ -276,7 +276,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
|||||||
// limit of two a minute, and bans it.
|
// limit of two a minute, and bans it.
|
||||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
// Asked about by its address in IPv6 form too.
|
// Asked about by its address in IPv6 form too.
|
||||||
for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
|
for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
|
|||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||||
ban := server.Ledger.Bans(netblock)[0]
|
ban := server.Ledger.Bans(netblock)[0]
|
||||||
|
|
||||||
// A request refused under the ban raises no other alert.
|
// A request refused under the ban raises no other alert.
|
||||||
clk.advance(time.Minute)
|
clk.advance(time.Minute)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{
|
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{
|
||||||
Netblock: netblock, Cause: bans.CauseLimit,
|
Netblock: netblock, Cause: bans.CauseLimit,
|
||||||
@@ -62,24 +62,22 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
|
|||||||
|
|
||||||
// The probe bans the client for seven days, and its next request makes
|
// The probe bans the client for seven days, and its next request makes
|
||||||
// the ban permanent. The request after that changes nothing.
|
// the ban permanent. The request after that changes nothing.
|
||||||
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
attackBan := server.Ledger.Bans(netblock)[0]
|
attackBan := server.Ledger.Bans(netblock)[0]
|
||||||
|
|
||||||
clk.advance(time.Minute)
|
clk.advance(time.Minute)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
permanentBan := server.Ledger.Bans(netblock)[0]
|
permanentBan := server.Ledger.Bans(netblock)[0]
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
// Another client's probe after its first ban has run out without a
|
// Another client's probe after its first ban has run out without a
|
||||||
// request makes a permanent ban at once.
|
// request makes a permanent ban at once.
|
||||||
s.request(otherClient, "/.env", http.StatusForbidden,
|
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
requestlog.ActionTemporarilyBanned)
|
|
||||||
clk.advance(7 * 24 * time.Hour)
|
clk.advance(7 * 24 * time.Hour)
|
||||||
s.request(otherClient, "/.env", http.StatusForbidden,
|
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
requestlog.ActionPermanentlyBanned)
|
|
||||||
|
|
||||||
otherBans := server.Ledger.Bans(other)
|
otherBans := server.Ledger.Bans(other)
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
|
|||||||
{allowed, "/", http.StatusOK, requestlog.ActionForward},
|
{allowed, "/", http.StatusOK, requestlog.ActionForward},
|
||||||
{exempt, "/", http.StatusOK, requestlog.ActionForward},
|
{exempt, "/", http.StatusOK, requestlog.ActionForward},
|
||||||
{client, "/static/app.js", http.StatusOK, requestlog.ActionForward},
|
{client, "/static/app.js", http.StatusOK, requestlog.ActionForward},
|
||||||
{denied, "/", http.StatusForbidden, requestlog.ActionClientDenied},
|
{denied, "/", http.StatusForbidden, requestlog.ActionDenied},
|
||||||
} {
|
} {
|
||||||
for range 3 {
|
for range 3 {
|
||||||
s.request(sent.from, sent.path, sent.status, sent.action)
|
s.request(sent.from, sent.path, sent.status, sent.action)
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
|||||||
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||||||
|
|
||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||||
|
|
||||||
if line.DurationWAF != nil {
|
if line.DurationWAF != nil {
|
||||||
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF)
|
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF)
|
||||||
@@ -157,7 +157,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
|||||||
|
|
||||||
if tc.refused {
|
if tc.refused {
|
||||||
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -219,10 +219,9 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
|||||||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||||
|
|
||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
if line.WouldAction != requestlog.ActionRequestHeaderTooLarge ||
|
if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil {
|
||||||
line.DurationWAF != nil {
|
|
||||||
t.Errorf("would_action %q and duration_waf %v, want %q and none",
|
t.Errorf("would_action %q and duration_waf %v, want %q and none",
|
||||||
line.WouldAction, line.DurationWAF, requestlog.ActionRequestHeaderTooLarge)
|
line.WouldAction, line.DurationWAF, requestlog.ActionTooLarge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +240,7 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
|||||||
wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny)
|
wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny)
|
||||||
|
|
||||||
lines := s.out.requestLines(t, 2)
|
lines := s.out.requestLines(t, 2)
|
||||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
||||||
@@ -250,8 +249,8 @@ func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name, path, action string
|
name, path, action string
|
||||||
}{
|
}{
|
||||||
{"a trap path", "/wp-login.php", requestlog.ActionTemporarilyBanned},
|
{"a trap path", "/wp-login.php", requestlog.ActionBanned},
|
||||||
{"a block rule", "/blocked", requestlog.ActionBlockedByBlockRule},
|
{"a block rule", "/blocked", requestlog.ActionRuleBlocked},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -281,7 +280,7 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
|||||||
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||||||
|
|
||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||||
|
|
||||||
if line.LimitPercent != nil {
|
if line.LimitPercent != nil {
|
||||||
t.Errorf("limit percentage %d, want none", *line.LimitPercent)
|
t.Errorf("limit percentage %d, want none", *line.LimitPercent)
|
||||||
@@ -305,8 +304,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
|
|||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
||||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
|
wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
requestlog.ActionClientIsRateLimited)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
||||||
@@ -414,37 +412,33 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "SWW_DENY_NETS", env: map[string]string{denyNets: client},
|
name: "SWW_DENY_NETS", env: map[string]string{denyNets: client},
|
||||||
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionClientDenied,
|
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "a ban",
|
name: "a ban",
|
||||||
prepare: banClient, status: forbidden, action: requestlog.ActionTemporarilyBanned,
|
prepare: banClient, status: forbidden, action: requestlog.ActionBanned,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
|
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
|
||||||
prepare: countClient, status: forbidden,
|
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
|
||||||
action: requestlog.ActionClientIsRateLimited,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "the rate limits, with a biased threshold",
|
name: "the rate limits, with a biased threshold",
|
||||||
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
|
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
|
||||||
prepare: countClient, status: forbidden,
|
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
|
||||||
action: requestlog.ActionClientIsRateLimited,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
|
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
|
||||||
prepare: listClient, status: forbidden, action: requestlog.ActionClientDenied,
|
prepare: listClient, status: forbidden, action: requestlog.ActionDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "the CrowdSec decision list",
|
name: "the CrowdSec decision list",
|
||||||
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
|
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
|
||||||
prepare: decideOnClient, status: forbidden,
|
prepare: decideOnClient, status: forbidden, action: requestlog.ActionBanned,
|
||||||
action: requestlog.ActionTemporarilyBanned,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "the header limits", cookie: cookies(cookieLimit + 1),
|
name: "the header limits", cookie: cookies(cookieLimit + 1),
|
||||||
prepare: leaveAsItIs, status: tooLarge,
|
prepare: leaveAsItIs, status: tooLarge, action: requestlog.ActionTooLarge,
|
||||||
action: requestlog.ActionRequestHeaderTooLarge,
|
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -50,23 +50,13 @@ func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &verdict{
|
return &verdict{
|
||||||
action: banAction(ban.Permanent()),
|
action: requestlog.ActionBanned,
|
||||||
figures: map[string]any{
|
figures: map[string]any{
|
||||||
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
|
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// banAction is the action of a request refused under a ban or for making one:
|
|
||||||
// permanent says the ban never runs out.
|
|
||||||
func banAction(permanent bool) string {
|
|
||||||
if permanent {
|
|
||||||
return requestlog.ActionPermanentlyBanned
|
|
||||||
}
|
|
||||||
|
|
||||||
return requestlog.ActionTemporarilyBanned
|
|
||||||
}
|
|
||||||
|
|
||||||
// limitFigures are the figures of a heuristic that broke hit, a limit: its
|
// limitFigures are the figures of a heuristic that broke hit, a limit: its
|
||||||
// window, the count and the limit.
|
// window, the count and the limit.
|
||||||
func limitFigures(hit ratelimit.Hit) map[string]any {
|
func limitFigures(hit ratelimit.Hit) map[string]any {
|
||||||
@@ -100,8 +90,8 @@ func (rq *request) countedBytes() int64 {
|
|||||||
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
||||||
// the client's counters back to zero. In observe mode it makes no ban and
|
// the client's counters back to zero. In observe mode it makes no ban and
|
||||||
// sets nothing back, and raises the alert for the ban it would have made,
|
// sets nothing back, and raises the alert for the ban it would have made,
|
||||||
// if that alert would be sent. It returns the action for that ban.
|
// if that alert would be sent.
|
||||||
func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) string {
|
func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
||||||
switch hit.Kind {
|
switch hit.Kind {
|
||||||
case ratelimit.KindBytes:
|
case ratelimit.KindBytes:
|
||||||
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
|
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
|
||||||
@@ -116,7 +106,7 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) str
|
|||||||
|
|
||||||
netblock := rq.h.netblock(rq.client)
|
netblock := rq.h.netblock(rq.client)
|
||||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
|
||||||
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseLimit))
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notes := bans.Notes{
|
notes := bans.Notes{
|
||||||
@@ -145,7 +135,7 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) str
|
|||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return banAction(ban.Permanent())
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ban, made := rq.h.ledger.BanForLimit(netblock, now, notes)
|
ban, made := rq.h.ledger.BanForLimit(netblock, now, notes)
|
||||||
@@ -155,19 +145,17 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) str
|
|||||||
if made {
|
if made {
|
||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return banAction(ban.Permanent())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// banForAttack bans the client's netblock at now for a clear sign of
|
// banForAttack bans the client's netblock at now for a clear sign of
|
||||||
// attack, which notes name: the ban rule that matched, or the trap path
|
// attack, which notes name: the ban rule that matched, or the trap path
|
||||||
// asked for. It fills in the rest of the notes. In observe mode it makes
|
// asked for. It fills in the rest of the notes. In observe mode it makes
|
||||||
// no ban, and raises the alert for the ban it would have made, if that
|
// no ban, and raises the alert for the ban it would have made, if that
|
||||||
// alert would be sent. It returns the action for that ban.
|
// alert would be sent.
|
||||||
func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
|
func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
|
||||||
netblock := rq.h.netblock(rq.client)
|
netblock := rq.h.netblock(rq.client)
|
||||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
|
||||||
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseAttack))
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notes.ASN = rq.line.ASN
|
notes.ASN = rq.line.ASN
|
||||||
@@ -183,7 +171,7 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
|
|||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return banAction(ban.Permanent())
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
|
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
|
||||||
@@ -192,18 +180,16 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
|
|||||||
if made {
|
if made {
|
||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return banAction(ban.Permanent())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// banForCrowdSec bans the client's netblock at now until decision,
|
// banForCrowdSec bans the client's netblock at now until decision,
|
||||||
// CrowdSec's decision on the client, ends. In observe mode it makes no
|
// CrowdSec's decision on the client, ends. In observe mode it makes no
|
||||||
// ban, and raises the alert for the ban it would have made, if that alert
|
// ban, and raises the alert for the ban it would have made, if that alert
|
||||||
// would be sent. It returns the action for that ban.
|
// would be sent.
|
||||||
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) string {
|
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
|
||||||
netblock := rq.h.netblock(rq.client)
|
netblock := rq.h.netblock(rq.client)
|
||||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
|
||||||
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseCrowdSec))
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notes := bans.Notes{
|
notes := bans.Notes{
|
||||||
@@ -222,7 +208,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) s
|
|||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return banAction(ban.Permanent())
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
|
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
|
||||||
@@ -232,8 +218,6 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) s
|
|||||||
if made {
|
if made {
|
||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return banAction(ban.Permanent())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
|
|||||||
// client for an hour, the default.
|
// client for an hour, the default.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
||||||
line.BanExpires != expires {
|
line.BanExpires != expires {
|
||||||
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
||||||
@@ -51,7 +51,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
|
|||||||
// Every request while the ban lasts is refused.
|
// Every request while the ban lasts is refused.
|
||||||
clk.advance(time.Hour - time.Second)
|
clk.advance(time.Hour - time.Second)
|
||||||
|
|
||||||
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" {
|
if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" {
|
||||||
t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+
|
t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+
|
||||||
"want %s and neither of the others", line.BanExpires, line.Offence,
|
"want %s and neither of the others", line.BanExpires, line.Offence,
|
||||||
@@ -78,9 +78,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
|||||||
breakLimit := func() string {
|
breakLimit := func() string {
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
return s.get(client, http.StatusForbidden, requestlog.ActionRateLimited).BanExpires
|
||||||
|
|
||||||
return line.BanExpires
|
|
||||||
}
|
}
|
||||||
wantExpires := func(got string, length time.Duration) {
|
wantExpires := func(got string, length time.Duration) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -113,7 +111,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clk.advance(365 * 24 * time.Hour)
|
clk.advance(365 * 24 * time.Hour)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
||||||
@@ -125,10 +123,10 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
|||||||
// client for an hour.
|
// client for an hour.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
for range 3 {
|
for range 3 {
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Later the same day the client has its whole allowance again: the
|
// Later the same day the client has its whole allowance again: the
|
||||||
@@ -137,7 +135,7 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
|||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||||
if len(banned) != 2 || banned[0].Notes.Refused != 3 {
|
if len(banned) != 2 || banned[0].Notes.Refused != 3 {
|
||||||
@@ -192,10 +190,10 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
s, _, _ := startWithClock(t, "", env)
|
s, _, _ := startWithClock(t, "", env)
|
||||||
|
|
||||||
s.get(tc.breaker, http.StatusOK, requestlog.ActionForward)
|
s.get(tc.breaker, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
for _, sent := range tc.refused {
|
for _, sent := range tc.refused {
|
||||||
s.get(sent, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(sent, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, sent := range tc.let {
|
for _, sent := range tc.let {
|
||||||
@@ -218,9 +216,9 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
|||||||
|
|
||||||
// fromDE's ban covers otherClient, which is refused unasked about.
|
// fromDE's ban covers otherClient, which is refused unasked about.
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
if line.Country != "" {
|
if line.Country != "" {
|
||||||
t.Errorf("log line has country %q, want none", line.Country)
|
t.Errorf("log line has country %q, want none", line.Country)
|
||||||
}
|
}
|
||||||
@@ -239,9 +237,9 @@ func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
|
|||||||
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
|
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
|
||||||
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
wantHeuristic(t, line, "active_ban",
|
wantHeuristic(t, line, "active_ban",
|
||||||
`{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`)
|
`{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`)
|
||||||
}
|
}
|
||||||
@@ -277,11 +275,11 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
|||||||
|
|
||||||
s, _, _ := startWithClock(t, geojsURL, env)
|
s, _, _ := startWithClock(t, geojsURL, env)
|
||||||
|
|
||||||
s.get(denied, tc.status, requestlog.ActionClientDenied)
|
s.get(denied, tc.status, requestlog.ActionDenied)
|
||||||
s.get(fromKP, tc.status, requestlog.ActionClientDenied)
|
s.get(fromKP, tc.status, requestlog.ActionDenied)
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(fromDE, tc.status, requestlog.ActionClientIsRateLimited)
|
s.get(fromDE, tc.status, requestlog.ActionRateLimited)
|
||||||
s.get(fromDE, tc.status, requestlog.ActionTemporarilyBanned)
|
s.get(fromDE, tc.status, requestlog.ActionBanned)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,9 +297,9 @@ func TestBanNotes(t *testing.T) {
|
|||||||
|
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.request(fromDE, "/repo/commits?page=2",
|
s.request(fromDE, "/repo/commits?page=2",
|
||||||
http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
netblock := netip.MustParsePrefix(fromDE + "/32")
|
netblock := netip.MustParsePrefix(fromDE + "/32")
|
||||||
want := bans.Ban{
|
want := bans.Ban{
|
||||||
@@ -344,7 +342,7 @@ func TestBanNotes(t *testing.T) {
|
|||||||
// The next ban counts this one among the earlier.
|
// The next ban counts this one among the earlier.
|
||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
got = ledger.Bans(netblock)
|
got = ledger.Bans(netblock)
|
||||||
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||||
@@ -362,12 +360,12 @@ func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
|||||||
|
|
||||||
// One ban is held, so otherClient's ban drops client's.
|
// One ban is held, so otherClient's ban drops client's.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
// clock is the time a test sets, by which sww counts requests and
|
// clock is the time a test sets, by which sww counts requests and
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{http.StatusOK, requestlog.ActionForward},
|
{http.StatusOK, requestlog.ActionForward},
|
||||||
{http.StatusOK, requestlog.ActionForward},
|
{http.StatusOK, requestlog.ActionForward},
|
||||||
{http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
{http.StatusForbidden, requestlog.ActionRateLimited},
|
||||||
} {
|
} {
|
||||||
line := s.get(tc.from, sent.status, sent.action)
|
line := s.get(tc.from, sent.status, sent.action)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
@@ -192,7 +192,7 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
|||||||
|
|
||||||
// The first request breaks the limit, and bans the client; the log line
|
// The first request breaks the limit, and bans the client; the log line
|
||||||
// gives the 0.
|
// gives the 0.
|
||||||
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
if line.fields["limit_percent"] != float64(0) ||
|
if line.fields["limit_percent"] != float64(0) ||
|
||||||
line.fields["limit_percent_setting"] != unknownLimitPercent {
|
line.fields["limit_percent_setting"] != unknownLimitPercent {
|
||||||
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
|
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
|
||||||
@@ -200,7 +200,7 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
|||||||
unknownLimitPercent)
|
unknownLimitPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(unplaced, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(unplaced, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLowestPercentageApplies(t *testing.T) {
|
func TestLowestPercentageApplies(t *testing.T) {
|
||||||
@@ -250,7 +250,7 @@ func TestLowestPercentageApplies(t *testing.T) {
|
|||||||
// A quarter of 4 requests a minute: the second breaks the limit.
|
// A quarter of 4 requests a minute: the second breaks the limit.
|
||||||
s.get(tc.from, http.StatusOK, requestlog.ActionForward)
|
s.get(tc.from, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
tc.want)
|
tc.want)
|
||||||
})
|
})
|
||||||
@@ -269,7 +269,7 @@ func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(t *test
|
|||||||
for _, from := range []string{unplaced, "10.0.0.8"} {
|
for _, from := range []string{unplaced, "10.0.0.8"} {
|
||||||
s.get(from, http.StatusOK, requestlog.ActionForward)
|
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(from, http.StatusOK, requestlog.ActionForward)
|
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(from, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
}
|
}
|
||||||
|
|
||||||
// One in a country has the whole limit.
|
// One in a country has the whole limit.
|
||||||
@@ -293,7 +293,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
|||||||
serveFromDE(t, server, http.MethodGet, http.NoBody)
|
serveFromDE(t, server, http.MethodGet, http.NoBody)
|
||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
"0 from "+unknownLimitPercent)
|
"0 from "+unknownLimitPercent)
|
||||||
})
|
})
|
||||||
@@ -374,7 +374,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
|||||||
// A quarter of 12 requests a minute is 3: the fourth breaks it.
|
// A quarter of 12 requests a minute is 3: the fourth breaks it.
|
||||||
"a rate limit",
|
"a rate limit",
|
||||||
map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
|
map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
|
||||||
3, http.StatusForbidden, requestlog.ActionClientIsRateLimited,
|
3, http.StatusForbidden, requestlog.ActionRateLimited,
|
||||||
"requests per minute over the limit of 3", "25 from " + tagLimitPercent,
|
"requests per minute over the limit of 3", "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &verdict{
|
return &verdict{
|
||||||
action: requestlog.ActionClientDenied,
|
action: requestlog.ActionDenied,
|
||||||
figures: map[string]any{"lists": listedBy},
|
figures: map[string]any{"lists": listedBy},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ package discriminator
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// byteLimits counts the request's bytes, as countedBytes gives them, for the
|
// byteLimits counts the request's bytes, as countedBytes gives them, for the
|
||||||
@@ -35,7 +37,7 @@ func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &verdict{
|
rq.banForLimit(now, hit, rq.out.status)
|
||||||
action: rq.banForLimit(now, hit, rq.out.status), figures: limitFigures(hit),
|
|
||||||
}
|
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
|
|||||||
line.BanExpires, tc.window, expires)
|
line.BanExpires, tc.window, expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+
|
wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+
|
||||||
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
|
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
|
||||||
@@ -125,7 +125,7 @@ func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
|
|||||||
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
|
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
||||||
@@ -147,7 +147,7 @@ func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
|||||||
answerBytes, minuteBytes, expires)
|
answerBytes, minuteBytes, expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
||||||
@@ -176,7 +176,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
|||||||
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
|
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@ func TestBytesCountSaysWhichBytesCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,7 +386,7 @@ func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T)
|
|||||||
// counted, and the byte limit is not broken. Its line gives the bytes
|
// counted, and the byte limit is not broken. Its line gives the bytes
|
||||||
// counted before it.
|
// counted before it.
|
||||||
line := s.upload()
|
line := s.upload()
|
||||||
wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
|
wantWouldAction(t, line, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes {
|
if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes {
|
||||||
t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d",
|
t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
@@ -21,6 +22,7 @@ const (
|
|||||||
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
|
apps = "SWW_APPS"
|
||||||
block = "block"
|
block = "block"
|
||||||
detect = "detect"
|
detect = "detect"
|
||||||
)
|
)
|
||||||
@@ -92,8 +94,7 @@ func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
|
|||||||
wafMode: detect, tagDeny: "exploitsignature",
|
wafMode: detect, tagDeny: "exploitsignature",
|
||||||
})
|
})
|
||||||
|
|
||||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
line := s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
requestlog.ActionClientDenied)
|
|
||||||
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
|
|
||||||
// A request a rule file refuses is not inspected.
|
// A request a rule file refuses is not inspected.
|
||||||
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
||||||
requestlog.ActionBlockedByBlockRule)
|
requestlog.ActionRuleBlocked)
|
||||||
wantWAF(t, line, nil)
|
wantWAF(t, line, nil)
|
||||||
|
|
||||||
// With SWW_WAF_MODE off, no request is.
|
// With SWW_WAF_MODE off, no request is.
|
||||||
@@ -283,6 +284,27 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
@@ -404,7 +426,7 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
|
|||||||
line.LimitHit, line.Offence)
|
line.LimitHit, line.Offence)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
want := ratelimit.Offences{Limit: 1, WAFBlocked: 3}
|
want := ratelimit.Offences{Limit: 1, WAFBlocked: 3}
|
||||||
if offences := historyOf(t, server, client).Offences; offences != want {
|
if offences := historyOf(t, server, client).Offences; offences != want {
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|||||||
rq.addTag(config.CrowdSecTag)
|
rq.addTag(config.CrowdSecTag)
|
||||||
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
|
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
|
||||||
"listed by the CrowdSec decision list")
|
"listed by the CrowdSec decision list")
|
||||||
|
rq.banForCrowdSec(now, decision)
|
||||||
|
|
||||||
return &verdict{
|
return &verdict{
|
||||||
action: rq.banForCrowdSec(now, decision),
|
action: requestlog.ActionBanned,
|
||||||
figures: map[string]any{
|
figures: map[string]any{
|
||||||
"scenario": decision.Scenario,
|
"scenario": decision.Scenario,
|
||||||
"expires": requestlog.FormatTime(decision.Expires),
|
"expires": requestlog.FormatTime(decision.Expires),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
|
|||||||
expires := requestlog.FormatTime(fetched.Add(4 * time.Hour))
|
expires := requestlog.FormatTime(fetched.Add(4 * time.Hour))
|
||||||
|
|
||||||
// Its first request is refused, and bans it until the decision ends.
|
// Its first request is refused, and bans it until the decision ends.
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
wantReputation(t, line, decisionsURL)
|
wantReputation(t, line, decisionsURL)
|
||||||
|
|
||||||
if line.BanExpires != expires {
|
if line.BanExpires != expires {
|
||||||
@@ -72,7 +72,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
|
|||||||
// ban, and once it has ended the client is let through.
|
// ban, and once it has ended the client is let through.
|
||||||
clk.advance(4*time.Hour - time.Second)
|
clk.advance(4*time.Hour - time.Second)
|
||||||
|
|
||||||
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
wantReputation(t, line)
|
wantReputation(t, line)
|
||||||
|
|
||||||
if line.BanExpires != expires {
|
if line.BanExpires != expires {
|
||||||
@@ -105,7 +105,7 @@ func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
|
|||||||
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
||||||
`"value": "`+client+`"}]`)
|
`"value": "`+client+`"}]`)
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
|
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
|
||||||
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
|
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
|
||||||
wantTags(t, line, "crowdsec")
|
wantTags(t, line, "crowdsec")
|
||||||
@@ -129,9 +129,9 @@ func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T
|
|||||||
// Just before its end, the decision bans a client in the netblock, and
|
// Just before its end, the decision bans a client in the netblock, and
|
||||||
// one on an IPv6 address bans the address's group, the /64.
|
// one on an IPv6 address bans the address's group, the /64.
|
||||||
clk.advance(time.Minute - time.Nanosecond)
|
clk.advance(time.Minute - time.Nanosecond)
|
||||||
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
// Once it has ended, it bans no other client, and the bans it made end
|
// Once it has ended, it bans no other client, and the bans it made end
|
||||||
// with it.
|
// with it.
|
||||||
@@ -164,7 +164,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
|||||||
`"value": "`+client+`"}]`)
|
`"value": "`+client+`"}]`)
|
||||||
|
|
||||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||||
wantReputation(t, line, decisionsURL)
|
wantReputation(t, line, decisionsURL)
|
||||||
|
|
||||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func (denyNets) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|||||||
for _, netblock := range rq.h.config.DenyNets {
|
for _, netblock := range rq.h.config.DenyNets {
|
||||||
if netblock.Contains(rq.client) {
|
if netblock.Contains(rq.client) {
|
||||||
return &verdict{
|
return &verdict{
|
||||||
action: requestlog.ActionClientDenied,
|
action: requestlog.ActionDenied,
|
||||||
figures: map[string]any{figureNetblock: netblock.String()},
|
figures: map[string]any{figureNetblock: netblock.String()},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -639,7 +639,7 @@ func TestQuestionDeniedGetsTheBanResponse(t *testing.T) {
|
|||||||
got := s.ask(client, "/")
|
got := s.ask(client, "/")
|
||||||
wantDisposition(t, got, status, discriminator.DispositionDeny)
|
wantDisposition(t, got, status, discriminator.DispositionDeny)
|
||||||
wantBody(t, got, http.StatusText(status)+"\n")
|
wantBody(t, got, http.StatusText(status)+"\n")
|
||||||
wantLine(t, s.out.requestLine(t), status, requestlog.ActionClientDenied)
|
wantLine(t, s.out.requestLine(t), status, requestlog.ActionDenied)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -655,8 +655,8 @@ func TestBanResponseCloseIsAnAnswerOfItsOwn(t *testing.T) {
|
|||||||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionClose)
|
wantDisposition(t, got, http.StatusOK, discriminator.DispositionClose)
|
||||||
wantBody(t, got, "")
|
wantBody(t, got, "")
|
||||||
// The visitor is sent nothing.
|
// The visitor is sent nothing.
|
||||||
wantLine(t, s.out.requestLine(t), 0, requestlog.ActionClientDenied)
|
wantLine(t, s.out.requestLine(t), 0, requestlog.ActionDenied)
|
||||||
wantMetric(t, s.metrics(), `sww_requests_total{action="client_denied",instance="`+
|
wantMetric(t, s.metrics(), `sww_requests_total{action="denied",instance="`+
|
||||||
alertInstance+`",status_class="none"}`, 1)
|
alertInstance+`",status_class="none"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,12 +671,12 @@ func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(t *testing.T) {
|
|||||||
got := s.ask(client, "/blocked")
|
got := s.ask(client, "/blocked")
|
||||||
wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny)
|
wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny)
|
||||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
|
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
|
||||||
requestlog.ActionBlockedByBlockRule)
|
requestlog.ActionRuleBlocked)
|
||||||
|
|
||||||
got = s.ask(client, "/.env")
|
got = s.ask(client, "/.env")
|
||||||
wantDisposition(t, got, http.StatusTooManyRequests, discriminator.DispositionDeny)
|
wantDisposition(t, got, http.StatusTooManyRequests, discriminator.DispositionDeny)
|
||||||
wantLine(t, s.out.requestLines(t, 2)[1], http.StatusTooManyRequests,
|
wantLine(t, s.out.requestLines(t, 2)[1], http.StatusTooManyRequests,
|
||||||
requestlog.ActionTemporarilyBanned)
|
requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) {
|
func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) {
|
||||||
@@ -709,7 +709,7 @@ func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) {
|
|||||||
|
|
||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionClientDenied)
|
wantWouldAction(t, line, requestlog.ActionDenied)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) {
|
func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) {
|
||||||
@@ -894,7 +894,7 @@ func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) {
|
|||||||
t.Errorf("history is %+v, want the refused request alone", history)
|
t.Errorf("history is %+v, want the refused request alone", history)
|
||||||
}
|
}
|
||||||
|
|
||||||
const series = `sww_requests_total{action="client_is_rate_limited",instance="` +
|
const series = `sww_requests_total{action="rate_limited",instance="` +
|
||||||
alertInstance + `",status_class="4xx"}`
|
alertInstance + `",status_class="4xx"}`
|
||||||
|
|
||||||
wantMetric(t, s.metrics(), series, 1)
|
wantMetric(t, s.metrics(), series, 1)
|
||||||
@@ -1298,11 +1298,11 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"deny", map[string]string{denyNets: client}, "/", http.StatusForbidden,
|
"deny", map[string]string{denyNets: client}, "/", http.StatusForbidden,
|
||||||
requestlog.ActionClientDenied,
|
requestlog.ActionDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"close", map[string]string{denyNets: client, banResponse: banResponseClose}, "/",
|
"close", map[string]string{denyNets: client, banResponse: banResponseClose}, "/",
|
||||||
0, requestlog.ActionClientDenied,
|
0, requestlog.ActionDenied,
|
||||||
},
|
},
|
||||||
{"own prefix", nil, discriminator.HealthPath, http.StatusNotFound,
|
{"own prefix", nil, discriminator.HealthPath, http.StatusNotFound,
|
||||||
requestlog.ActionAdmin},
|
requestlog.ActionAdmin},
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &verdict{
|
return &verdict{
|
||||||
action: requestlog.ActionClientDenied,
|
action: requestlog.ActionDenied,
|
||||||
figures: map[string]any{"zones": listedBy},
|
figures: map[string]any{"zones": listedBy},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &verdict{
|
return &verdict{
|
||||||
action: requestlog.ActionRequestHeaderTooLarge,
|
action: requestlog.ActionTooLarge,
|
||||||
figures: map[string]any{
|
figures: map[string]any{
|
||||||
"fields": fields, "fields_limit": maxHeaderFields,
|
"fields": fields, "fields_limit": maxHeaderFields,
|
||||||
"cookies": cookies, "cookies_limit": maxCookies,
|
"cookies": cookies, "cookies_limit": maxCookies,
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
clk.advance(time.Second)
|
clk.advance(time.Second)
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
clk.advance(time.Second)
|
clk.advance(time.Second)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
want := ratelimit.History{
|
want := ratelimit.History{
|
||||||
FirstSeen: start,
|
FirstSeen: start,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T)
|
|||||||
// comes too. None waits for GeoJS.
|
// comes too. None waits for GeoJS.
|
||||||
for _, line := range []logLine{
|
for _, line := range []logLine{
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited),
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited),
|
||||||
s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
||||||
} {
|
} {
|
||||||
got := asnAndCountry{line.ASN, line.ASName, line.Country}
|
got := asnAndCountry{line.ASN, line.ASName, line.Country}
|
||||||
@@ -179,7 +179,7 @@ func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.
|
|||||||
|
|
||||||
for _, line := range []logLine{
|
for _, line := range []logLine{
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited),
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited),
|
||||||
} {
|
} {
|
||||||
if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want {
|
if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want {
|
||||||
t.Errorf("log line has %+v, want %+v", got, want)
|
t.Errorf("log line has %+v, want %+v", got, want)
|
||||||
@@ -264,7 +264,7 @@ func TestClientsAreLookedUpInTheLookupDatabaseAndGeoJSIsNotAsked(t *testing.T) {
|
|||||||
want asnAndCountry
|
want asnAndCountry
|
||||||
}{
|
}{
|
||||||
{s.get(fromDE, http.StatusOK, requestlog.ActionForward), de},
|
{s.get(fromDE, http.StatusOK, requestlog.ActionForward), de},
|
||||||
{s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited), de},
|
{s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited), de},
|
||||||
{
|
{
|
||||||
s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
||||||
asnAndCountry{asnKP, asNameKP, "KP"},
|
asnAndCountry{asnKP, asNameKP, "KP"},
|
||||||
|
|||||||
@@ -125,15 +125,15 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// SWW_BAN_RESPONSE=close sends no status at all.
|
// SWW_BAN_RESPONSE=close sends no status at all.
|
||||||
s.get(denied, 0, requestlog.ActionClientDenied)
|
s.get(denied, 0, requestlog.ActionDenied)
|
||||||
|
|
||||||
// A first broken limit bans for an hour.
|
// A first broken limit bans for an hour.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, 0, requestlog.ActionClientIsRateLimited)
|
s.get(client, 0, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_requests_total{action="client_denied",instance="app",status_class="none"}`, 1)
|
`sww_requests_total{action="denied",instance="app",status_class="none"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
||||||
`kind="requests",window="minute"}`, 1)
|
`kind="requests",window="minute"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
|
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
|
||||||
@@ -147,7 +147,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
// A limit broken again right after would ban for three hours, longer
|
// A limit broken again right after would ban for three hours, longer
|
||||||
// than SWW_MAX_BAN_DURATION, so the ban is permanent.
|
// than SWW_MAX_BAN_DURATION, so the ban is permanent.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, 0, requestlog.ActionClientIsRateLimited)
|
s.get(client, 0, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
metrics = s.scrape()
|
metrics = s.scrape()
|
||||||
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
||||||
|
|||||||
@@ -14,70 +14,6 @@ import (
|
|||||||
// observe is the value of SWW_MODE for observe mode.
|
// observe is the value of SWW_MODE for observe mode.
|
||||||
const observe = "observe"
|
const observe = "observe"
|
||||||
|
|
||||||
func TestBanActionSaysWhetherTheBanRunsOut(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const trapPath = "/wp-login.php"
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
cause string
|
|
||||||
// endsIn is how long after the request the ban ends: negative for a
|
|
||||||
// ban that has ended, and zero for a permanent ban.
|
|
||||||
endsIn time.Duration
|
|
||||||
path string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"temporary ban", bans.CauseAdmin, time.Hour, "/",
|
|
||||||
requestlog.ActionTemporarilyBanned,
|
|
||||||
},
|
|
||||||
{"permanent ban", bans.CauseAdmin, 0, "/", requestlog.ActionPermanentlyBanned},
|
|
||||||
{
|
|
||||||
"request that makes a ban permanent", bans.CauseAttack, time.Hour, "/",
|
|
||||||
requestlog.ActionPermanentlyBanned,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"trap path after an attack ban ended", bans.CauseAttack, -time.Hour,
|
|
||||||
trapPath, requestlog.ActionPermanentlyBanned,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"first trap path after another ban ended", bans.CauseAdmin, -time.Hour,
|
|
||||||
trapPath, requestlog.ActionTemporarilyBanned,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
for _, modeValue := range []string{"enforce", observe} {
|
|
||||||
t.Run(tc.name+" in "+modeValue, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, clk, server := startWithClock(t, "", map[string]string{
|
|
||||||
trapPaths: trapPathList, mode: modeValue,
|
|
||||||
})
|
|
||||||
|
|
||||||
ban := bans.Ban{
|
|
||||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
|
||||||
Start: clk.Now().Add(-2 * time.Hour),
|
|
||||||
Cause: tc.cause,
|
|
||||||
}
|
|
||||||
if tc.endsIn != 0 {
|
|
||||||
ban.Expires = clk.Now().Add(tc.endsIn)
|
|
||||||
}
|
|
||||||
|
|
||||||
server.Ledger.Load([]bans.Ban{ban})
|
|
||||||
|
|
||||||
if modeValue == observe {
|
|
||||||
line := s.request(client, tc.path, http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantWouldAction(t, line, tc.want)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.request(client, tc.path, http.StatusForbidden, tc.want)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
|
func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -86,7 +22,7 @@ func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionClientDenied)
|
wantWouldAction(t, line, requestlog.ActionDenied)
|
||||||
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`)
|
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,10 +69,10 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
|||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
for _, sent := range []struct{ from, refusal string }{
|
for _, sent := range []struct{ from, refusal string }{
|
||||||
{denied, requestlog.ActionClientDenied},
|
{denied, requestlog.ActionDenied},
|
||||||
{banned, requestlog.ActionTemporarilyBanned},
|
{banned, requestlog.ActionBanned},
|
||||||
{fromKP, requestlog.ActionClientDenied},
|
{fromKP, requestlog.ActionDenied},
|
||||||
{fromDE, requestlog.ActionClientIsRateLimited},
|
{fromDE, requestlog.ActionRateLimited},
|
||||||
} {
|
} {
|
||||||
if !tc.observe {
|
if !tc.observe {
|
||||||
line := s.get(sent.from, http.StatusForbidden, sent.refusal)
|
line := s.get(sent.from, http.StatusForbidden, sent.refusal)
|
||||||
@@ -174,7 +110,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
|
|||||||
|
|
||||||
for range 2 {
|
for range 2 {
|
||||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
|
wantWouldAction(t, line, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
||||||
line.BanExpires != "" {
|
line.BanExpires != "" {
|
||||||
@@ -187,7 +123,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
|
|||||||
// The ban read from bans.json refuses nothing, and so counts no
|
// The ban read from bans.json refuses nothing, and so counts no
|
||||||
// refusal in its notes, but is kept.
|
// refusal in its notes, but is kept.
|
||||||
line := s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
line := s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||||
|
|
||||||
if line.BanExpires != requestlog.FormatTime(kept.Expires) {
|
if line.BanExpires != requestlog.FormatTime(kept.Expires) {
|
||||||
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires,
|
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires,
|
||||||
|
|||||||
@@ -212,7 +212,8 @@ func newReputation(
|
|||||||
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
|
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
|
||||||
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
|
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
|
||||||
// up to SWW_WAF_BODY_LIMIT, with the exclusions in
|
// up to SWW_WAF_BODY_LIMIT, with the exclusions in
|
||||||
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It
|
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_APPS,
|
||||||
|
// or nil while SWW_WAF_MODE is off. It
|
||||||
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
||||||
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
||||||
// JSON or XML body over its limit. An exclusions file that waf.New refuses is
|
// JSON or XML body over its limit. An exclusions file that waf.New refuses is
|
||||||
@@ -227,6 +228,7 @@ func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) {
|
|||||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
||||||
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
||||||
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
||||||
|
Apps: cfg.Apps,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict
|
|||||||
|
|
||||||
rq.banForLimit(now, hit, rq.h.config.BanResponse)
|
rq.banForLimit(now, hit, rq.h.config.BanResponse)
|
||||||
|
|
||||||
return &verdict{
|
return &verdict{action: requestlog.ActionRateLimited, figures: limitFigures(hit)}
|
||||||
action: requestlog.ActionClientIsRateLimited, figures: limitFigures(hit),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// overAWholeRateLimit reports whether the heuristic rate_limits will refuse the
|
// overAWholeRateLimit reports whether the heuristic rate_limits will refuse the
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
|
|||||||
if sent.want == http.StatusOK {
|
if sent.want == http.StatusOK {
|
||||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||||
} else {
|
} else {
|
||||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
if line.LimitHit != minute {
|
if line.LimitHit != minute {
|
||||||
t.Errorf("log line has limit_hit %q, want minute", line.LimitHit)
|
t.Errorf("log line has limit_hit %q, want minute", line.LimitHit)
|
||||||
@@ -79,7 +79,7 @@ func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
|||||||
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`)
|
wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
|||||||
status int
|
status int
|
||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
{rateLimitPerMinute, "1", http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
{rateLimitPerMinute, "1", http.StatusForbidden, requestlog.ActionRateLimited},
|
||||||
{bytesLimitPerMinute, byteLimit, http.StatusOK, requestlog.ActionForward},
|
{bytesLimitPerMinute, byteLimit, http.StatusOK, requestlog.ActionForward},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.setting, func(t *testing.T) {
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
@@ -160,17 +160,14 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
|
|
||||||
// A path outside every prefix is counted: /assets is not under
|
// A path outside every prefix is counted: /assets is not under
|
||||||
// /assets/, and breaks the limit.
|
// /assets/, and breaks the limit.
|
||||||
s.request(client, "/assets", http.StatusForbidden,
|
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
requestlog.ActionClientIsRateLimited)
|
|
||||||
|
|
||||||
// A ban, SWW_DENY_NETS and SWW_TAG_DENY still refuse a path under a
|
// A ban, SWW_DENY_NETS and SWW_TAG_DENY still refuse a path under a
|
||||||
// prefix.
|
// prefix.
|
||||||
s.request(client, "/assets/app.js", http.StatusForbidden,
|
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
requestlog.ActionTemporarilyBanned)
|
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
|
||||||
s.request(denied, "/assets/app.js", http.StatusForbidden,
|
|
||||||
requestlog.ActionClientDenied)
|
|
||||||
s.request(fromKP, "/assets/app.js",
|
s.request(fromKP, "/assets/app.js",
|
||||||
http.StatusForbidden, requestlog.ActionClientDenied)
|
http.StatusForbidden, requestlog.ActionDenied)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
|
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
|
||||||
@@ -214,7 +211,7 @@ func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
|
|||||||
// Counted, the second request breaks the limit of one request
|
// Counted, the second request breaks the limit of one request
|
||||||
// a minute.
|
// a minute.
|
||||||
s.request(client, sent, http.StatusOK, requestlog.ActionForward)
|
s.request(client, sent, http.StatusOK, requestlog.ActionForward)
|
||||||
s.request(client, sent, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.request(client, sent, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdic
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !rq.attack && !rq.blockedByBlockRule && !rq.wafBlocked {
|
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,11 +42,13 @@ func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdic
|
|||||||
status := rq.out.status
|
status := rq.out.status
|
||||||
|
|
||||||
switch rq.line.WouldAction {
|
switch rq.line.WouldAction {
|
||||||
case requestlog.ActionBlockedByBlockRule, requestlog.ActionWAFBlocked:
|
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
|
||||||
status = http.StatusForbidden
|
status = http.StatusForbidden
|
||||||
case requestlog.ActionTemporarilyBanned, requestlog.ActionPermanentlyBanned:
|
case requestlog.ActionBanned:
|
||||||
status = cfg.BanResponse
|
status = cfg.BanResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
return &verdict{action: rq.banForLimit(now, hit, status), figures: limitFigures(hit)}
|
rq.banForLimit(now, hit, status)
|
||||||
|
|
||||||
|
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
|
||||||
}
|
}
|
||||||
@@ -37,10 +37,9 @@ func (r refused) send(s *sender, from string) logLine {
|
|||||||
switch r {
|
switch r {
|
||||||
case blockRule:
|
case blockRule:
|
||||||
return s.request(from, blockedPath, http.StatusForbidden,
|
return s.request(from, blockedPath, http.StatusForbidden,
|
||||||
requestlog.ActionBlockedByBlockRule)
|
requestlog.ActionRuleBlocked)
|
||||||
case banRule:
|
case banRule:
|
||||||
return s.request(from, probePath, http.StatusForbidden,
|
return s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
requestlog.ActionTemporarilyBanned)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.t.Fatalf("no request for the refusal %d", r)
|
s.t.Fatalf("no request for the refusal %d", r)
|
||||||
@@ -72,20 +71,9 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
// refusals are four, one over the threshold of three.
|
// refusals are four, one over the threshold of three.
|
||||||
refusals []refused
|
refusals []refused
|
||||||
// nextAction is the action of the client's next request: the next
|
|
||||||
// request after a ban for a ban rule makes it permanent.
|
|
||||||
nextAction string
|
|
||||||
}{
|
}{
|
||||||
{
|
{"block rule", []refused{blockRule, blockRule, blockRule, blockRule}},
|
||||||
"block rule",
|
{"a ban rule last", []refused{blockRule, blockRule, blockRule, banRule}},
|
||||||
[]refused{blockRule, blockRule, blockRule, blockRule},
|
|
||||||
requestlog.ActionTemporarilyBanned,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a ban rule last",
|
|
||||||
[]refused{blockRule, blockRule, blockRule, banRule},
|
|
||||||
requestlog.ActionPermanentlyBanned,
|
|
||||||
},
|
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -113,7 +101,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
|||||||
"and limit", line.LimitHit, line.Offence)
|
"and limit", line.LimitHit, line.Offence)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, tc.nextAction)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,7 +169,7 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
|||||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantOffences := ratelimit.Offences{Limit: 1, BlockedByBlockRule: 3}
|
wantOffences := ratelimit.Offences{Limit: 1, RuleBlocked: 3}
|
||||||
if offences := historyOf(t, server, client).Offences; offences != wantOffences {
|
if offences := historyOf(t, server, client).Offences; offences != wantOffences {
|
||||||
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
|
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
|
||||||
}
|
}
|
||||||
@@ -191,7 +179,7 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
|||||||
`kind="refusals",window="minute"}`, 1)
|
`kind="refusals",window="minute"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
|
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 3)
|
`sww_offences_total{instance="app",kind="rule_blocked"}`, 3)
|
||||||
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +298,7 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
|||||||
t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit)
|
t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(exempt, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
|
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
|
||||||
@@ -353,7 +341,7 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
|||||||
for range 2 {
|
for range 2 {
|
||||||
line := s.request(otherClient, blockedPath, http.StatusOK,
|
line := s.request(otherClient, blockedPath, http.StatusOK,
|
||||||
requestlog.ActionForward)
|
requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||||
|
|
||||||
if line.LimitHit != "" {
|
if line.LimitHit != "" {
|
||||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||||
@@ -365,7 +353,7 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
|||||||
s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
|
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
|
||||||
|
|
||||||
if line.LimitHit != requestlog.LimitHitErrorBurst || line.BanExpires != "" {
|
if line.LimitHit != requestlog.LimitHitErrorBurst || line.BanExpires != "" {
|
||||||
t.Errorf("log line has limit_hit %q and ban_expires %q, want error_burst "+
|
t.Errorf("log line has limit_hit %q and ban_expires %q, want error_burst "+
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const (
|
|||||||
func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
|
func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
|
forward, denied := requestlog.ActionForward, requestlog.ActionDenied
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
action string
|
action string
|
||||||
@@ -66,7 +66,7 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
|
|||||||
{
|
{
|
||||||
// Half of 4 requests a minute: the third breaks the limit.
|
// Half of 4 requests a minute: the third breaks the limit.
|
||||||
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
||||||
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
|
[]string{forward, forward, requestlog.ActionRateLimited},
|
||||||
"50 from " + blocklistAction,
|
"50 from " + blocklistAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -162,7 +162,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T)
|
|||||||
s, server, _ := startWithLookups(t, tc.env)
|
s, server, _ := startWithLookups(t, tc.env)
|
||||||
tc.load(t, server)
|
tc.load(t, server)
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
wantHeuristic(t, line, tc.heuristic, tc.figures)
|
wantHeuristic(t, line, tc.heuristic, tc.figures)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -193,7 +193,7 @@ func TestObserveModeForwardsAClientABlocklistDeniesAndAlertsIt(t *testing.T) {
|
|||||||
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
||||||
|
|
||||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionClientDenied)
|
wantWouldAction(t, line, requestlog.ActionDenied)
|
||||||
wantReputation(t, line, dropURL)
|
wantReputation(t, line, dropURL)
|
||||||
|
|
||||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
@@ -380,7 +380,7 @@ const (
|
|||||||
func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
|
forward, denied := requestlog.ActionForward, requestlog.ActionDenied
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
action string
|
action string
|
||||||
@@ -397,7 +397,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
|||||||
{
|
{
|
||||||
// Half of 4 requests a minute: the third breaks the limit.
|
// Half of 4 requests a minute: the third breaks the limit.
|
||||||
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
||||||
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
|
[]string{forward, forward, requestlog.ActionRateLimited},
|
||||||
"50 from " + reputationAction,
|
"50 from " + reputationAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -468,7 +468,7 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
|
|||||||
// The blocklist refuses fromKP before its verdict is looked at, and
|
// The blocklist refuses fromKP before its verdict is looked at, and
|
||||||
// fromDE, in SWW_ALLOW_NETS, is not checked at all: neither is noted
|
// fromDE, in SWW_ALLOW_NETS, is not checked at all: neither is noted
|
||||||
// for the zone, nor alerted, nor asked about.
|
// for the zone, nor alerted, nor asked about.
|
||||||
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
|
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
|
||||||
dropURL)
|
dropURL)
|
||||||
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
||||||
|
|
||||||
@@ -532,7 +532,7 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
|
|||||||
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
||||||
|
|
||||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionClientDenied)
|
wantWouldAction(t, line, requestlog.ActionDenied)
|
||||||
wantReputation(t, line, dnsblZone)
|
wantReputation(t, line, dnsblZone)
|
||||||
|
|
||||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
@@ -741,8 +741,8 @@ func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(t *testing.T
|
|||||||
s.get(fromDE, http.StatusOK, forward)
|
s.get(fromDE, http.StatusOK, forward)
|
||||||
s.get(fromDE, http.StatusOK, forward)
|
s.get(fromDE, http.StatusOK, forward)
|
||||||
s.get(fromKP, http.StatusOK, forward)
|
s.get(fromKP, http.StatusOK, forward)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
wantAbuseIPDBChecks(t, server, 0)
|
wantAbuseIPDBChecks(t, server, 0)
|
||||||
|
|
||||||
// Once the ban has ended, fromDE's first request has it checked in the
|
// Once the ban has ended, fromDE's first request has it checked in the
|
||||||
@@ -781,7 +781,7 @@ func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing
|
|||||||
s.get(addresses[0], http.StatusOK, forward)
|
s.get(addresses[0], http.StatusOK, forward)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(addresses[0], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(addresses[0], http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
|
|
||||||
// Once the ban has ended, which set its counters back to zero, a
|
// Once the ban has ended, which set its counters back to zero, a
|
||||||
@@ -814,20 +814,20 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
|
|||||||
want ratelimit.Offences
|
want ratelimit.Offences
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"a block rule", blockedPath, http.StatusForbidden,
|
"a block rule", blockedPath, http.StatusForbidden, requestlog.ActionRuleBlocked,
|
||||||
requestlog.ActionBlockedByBlockRule, ratelimit.Offences{BlockedByBlockRule: 1},
|
ratelimit.Offences{RuleBlocked: 1},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the Core Rule Set", sqlInjection, http.StatusForbidden,
|
"the Core Rule Set", sqlInjection, http.StatusForbidden,
|
||||||
requestlog.ActionWAFBlocked, ratelimit.Offences{WAFBlocked: 1},
|
requestlog.ActionWAFBlocked, ratelimit.Offences{WAFBlocked: 1},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"a ban rule", probePath, http.StatusForbidden, requestlog.ActionTemporarilyBanned,
|
"a ban rule", probePath, http.StatusForbidden, requestlog.ActionBanned,
|
||||||
ratelimit.Offences{Attack: 1},
|
ratelimit.Offences{Attack: 1},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"a trap path", "/xmlrpc.php", http.StatusForbidden,
|
"a trap path", "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned,
|
||||||
requestlog.ActionTemporarilyBanned, ratelimit.Offences{Attack: 1},
|
ratelimit.Offences{Attack: 1},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -858,7 +858,7 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
|
|||||||
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) {
|
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
|
forward, denied := requestlog.ActionForward, requestlog.ActionDenied
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
action string
|
action string
|
||||||
@@ -875,7 +875,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
|
|||||||
{
|
{
|
||||||
// Half of 4 requests a minute: the third breaks the limit.
|
// Half of 4 requests a minute: the third breaks the limit.
|
||||||
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
||||||
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
|
[]string{forward, forward, requestlog.ActionRateLimited},
|
||||||
"50 from " + reputationAction,
|
"50 from " + reputationAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -972,7 +972,7 @@ func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
|
|||||||
// fromDE's score is not used, and once it has committed an offence it
|
// fromDE's score is not used, and once it has committed an offence it
|
||||||
// is not checked either.
|
// is not checked either.
|
||||||
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
||||||
wantAbuseIPDBChecks(t, server, 0)
|
wantAbuseIPDBChecks(t, server, 0)
|
||||||
@@ -996,10 +996,10 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"for a broken rate limit", func(s *sender, from string) {
|
{"for a broken rate limit", func(s *sender, from string) {
|
||||||
s.get(from, http.StatusOK, requestlog.ActionForward)
|
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(from, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
}},
|
}},
|
||||||
{"for a clear sign of attack", func(s *sender, from string) {
|
{"for a clear sign of attack", func(s *sender, from string) {
|
||||||
s.request(from, probePath, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}},
|
}},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ type decision struct {
|
|||||||
counted bool
|
counted bool
|
||||||
limitPercent, bytesPercent percentage
|
limitPercent, bytesPercent percentage
|
||||||
// attack is true for a request that matched a ban rule or asked for a
|
// attack is true for a request that matched a ban rule or asked for a
|
||||||
// trap path, blockedByBlockRule for one a block rule refused, and
|
// trap path, ruleBlocked for one a block rule refused, and wafBlocked for
|
||||||
// wafBlocked for one the Core Rule Set refused, each an offence its
|
// one the Core Rule Set refused, each an offence its client's history
|
||||||
// client's history counts.
|
// counts.
|
||||||
attack, blockedByBlockRule, wafBlocked bool
|
attack, ruleBlocked, wafBlocked bool
|
||||||
// reputation is the reputation sources that list the client, for the
|
// reputation is the reputation sources that list the client, for the
|
||||||
// notes of a ban the request makes.
|
// notes of a ban the request makes.
|
||||||
reputation []bans.ReputationHit
|
reputation []bans.ReputationHit
|
||||||
@@ -187,10 +187,9 @@ func (rq *request) check(ctx context.Context) *refusal {
|
|||||||
rq.line.WouldAction = action
|
rq.line.WouldAction = action
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
case action == requestlog.ActionBlockedByBlockRule ||
|
case action == requestlog.ActionRuleBlocked || action == requestlog.ActionWAFBlocked:
|
||||||
action == requestlog.ActionWAFBlocked:
|
|
||||||
return &refusal{status: http.StatusForbidden, action: action}
|
return &refusal{status: http.StatusForbidden, action: action}
|
||||||
case action == requestlog.ActionRequestHeaderTooLarge:
|
case action == requestlog.ActionTooLarge:
|
||||||
return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action}
|
return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action}
|
||||||
default:
|
default:
|
||||||
return rq.banResponse(action)
|
return rq.banResponse(action)
|
||||||
@@ -304,15 +303,15 @@ func timing(start, end time.Time) *float64 {
|
|||||||
// when it comes.
|
// when it comes.
|
||||||
func (rq *request) addToHistory() {
|
func (rq *request) addToHistory() {
|
||||||
request := ratelimit.Request{
|
request := ratelimit.Request{
|
||||||
Forwarded: rq.allowed,
|
Forwarded: rq.allowed,
|
||||||
Refused: rq.refused != nil,
|
Refused: rq.refused != nil,
|
||||||
Status: rq.out.status,
|
Status: rq.out.status,
|
||||||
RequestBytes: rq.requestBytes,
|
RequestBytes: rq.requestBytes,
|
||||||
ResponseBytes: rq.out.bytes,
|
ResponseBytes: rq.out.bytes,
|
||||||
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
||||||
Attack: rq.attack,
|
Attack: rq.attack,
|
||||||
BlockedByBlockRule: rq.blockedByBlockRule,
|
RuleBlocked: rq.ruleBlocked,
|
||||||
WAFBlocked: rq.wafBlocked,
|
WAFBlocked: rq.wafBlocked,
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request)
|
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request)
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ import (
|
|||||||
// ruleFiles checks the request against the rules of the rule files at now,
|
// ruleFiles checks the request against the rules of the rule files at now,
|
||||||
// notes the ids of those it matches in the log line, adds the tag
|
// notes the ids of those it matches in the log line, adds the tag
|
||||||
// exploitsignature when it matches any, and refuses the request
|
// exploitsignature when it matches any, and refuses the request
|
||||||
// when the last rule it matches refuses it: with ActionBlockedByBlockRule for a
|
// when the last rule it matches refuses it: with ActionRuleBlocked for a block
|
||||||
// block rule, and for a ban rule with the action for the ban it makes. A ban
|
// rule, and ActionBanned for a ban rule. A ban rule bans the client's netblock
|
||||||
// rule bans the client's netblock for a clear sign of attack, or in observe
|
// for a clear sign of attack, or in observe mode raises the alert for the ban
|
||||||
// mode raises the alert for the ban
|
|
||||||
// it would have made. Either rule's match is noted as an offence, for the
|
// it would have made. Either rule's match is noted as an offence, for the
|
||||||
// client's history. Its figures are the rule's id and action.
|
// client's history. Its figures are the rule's id and action.
|
||||||
type ruleFiles struct{}
|
type ruleFiles struct{}
|
||||||
@@ -46,16 +45,14 @@ func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|||||||
|
|
||||||
switch last.Action {
|
switch last.Action {
|
||||||
case rules.ActionBlock:
|
case rules.ActionBlock:
|
||||||
rq.blockedByBlockRule = true
|
rq.ruleBlocked = true
|
||||||
|
|
||||||
return &verdict{action: requestlog.ActionBlockedByBlockRule, figures: figures}
|
return &verdict{action: requestlog.ActionRuleBlocked, figures: figures}
|
||||||
case rules.ActionBan:
|
case rules.ActionBan:
|
||||||
rq.attack = true
|
rq.attack = true
|
||||||
|
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
|
||||||
|
|
||||||
return &verdict{
|
return &verdict{action: requestlog.ActionBanned, figures: figures}
|
||||||
action: rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target}),
|
|
||||||
figures: figures,
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,14 +37,13 @@ func TestEachRuleAction(t *testing.T) {
|
|||||||
// A block rule refuses with 403, whatever SWW_BAN_RESPONSE is, and
|
// A block rule refuses with 403, whatever SWW_BAN_RESPONSE is, and
|
||||||
// bans no one.
|
// bans no one.
|
||||||
line = s.request(client, "/blocked", http.StatusForbidden,
|
line = s.request(client, "/blocked", http.StatusForbidden,
|
||||||
requestlog.ActionBlockedByBlockRule)
|
requestlog.ActionRuleBlocked)
|
||||||
wantRuleIDs(t, line, "noted", "blocked")
|
wantRuleIDs(t, line, "noted", "blocked")
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
// A ban rule refuses with SWW_BAN_RESPONSE, and bans the client for
|
// A ban rule refuses with SWW_BAN_RESPONSE, and bans the client for
|
||||||
// seven days, the default.
|
// seven days, the default.
|
||||||
line = s.request(client, "/.env", http.StatusTooManyRequests,
|
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||||
requestlog.ActionTemporarilyBanned)
|
|
||||||
wantRuleIDs(t, line, "noted", "probe")
|
wantRuleIDs(t, line, "noted", "probe")
|
||||||
|
|
||||||
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
|
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
|
||||||
@@ -83,7 +82,7 @@ func TestEachRuleAction(t *testing.T) {
|
|||||||
// against the rules, and makes the ban permanent.
|
// against the rules, and makes the ban permanent.
|
||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
|
|
||||||
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
|
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||||
wantRuleIDs(t, line)
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
if line.BanExpires != permanent {
|
if line.BanExpires != permanent {
|
||||||
@@ -91,7 +90,7 @@ func TestEachRuleAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clk.advance(365 * 24 * time.Hour)
|
clk.advance(365 * 24 * time.Hour)
|
||||||
s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
|
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
|
func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
|
||||||
@@ -104,7 +103,7 @@ func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
|
|||||||
wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward),
|
wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward),
|
||||||
"exploitsignature")
|
"exploitsignature")
|
||||||
wantTags(t, s.request(client, "/blocked", http.StatusForbidden,
|
wantTags(t, s.request(client, "/blocked", http.StatusForbidden,
|
||||||
requestlog.ActionBlockedByBlockRule), "exploitsignature")
|
requestlog.ActionRuleBlocked), "exploitsignature")
|
||||||
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
|
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,11 +115,10 @@ func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
line := s.request(client, "/blocked", http.StatusForbidden,
|
line := s.request(client, "/blocked", http.StatusForbidden,
|
||||||
requestlog.ActionBlockedByBlockRule)
|
requestlog.ActionRuleBlocked)
|
||||||
wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`)
|
wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`)
|
||||||
|
|
||||||
line = s.request(client, "/.env", http.StatusForbidden,
|
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
requestlog.ActionTemporarilyBanned)
|
|
||||||
wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`)
|
wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,8 +131,7 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// The first probe bans for SWW_ATTACK_BAN_DURATION.
|
// The first probe bans for SWW_ATTACK_BAN_DURATION.
|
||||||
line := s.request(client, "/.env", http.StatusForbidden,
|
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
requestlog.ActionTemporarilyBanned)
|
|
||||||
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
|
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
|
||||||
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
||||||
}
|
}
|
||||||
@@ -144,8 +141,7 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
|||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line = s.request(client, "/.env", http.StatusForbidden,
|
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
requestlog.ActionPermanentlyBanned)
|
|
||||||
if line.BanExpires != permanent {
|
if line.BanExpires != permanent {
|
||||||
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
||||||
}
|
}
|
||||||
@@ -173,8 +169,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
|||||||
// A probe over the rate limit breaks the limit before any rule sees
|
// A probe over the rate limit breaks the limit before any rule sees
|
||||||
// it.
|
// it.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
line = s.request(client, "/.env", http.StatusForbidden,
|
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
requestlog.ActionClientIsRateLimited)
|
|
||||||
wantRuleIDs(t, line)
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||||
@@ -184,7 +179,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
|||||||
|
|
||||||
// A client the rate limits do not apply to is still checked.
|
// A client the rate limits do not apply to is still checked.
|
||||||
s.get(exempt, http.StatusOK, requestlog.ActionForward)
|
s.get(exempt, http.StatusOK, requestlog.ActionForward)
|
||||||
s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
|
func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
|
||||||
@@ -196,11 +191,11 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward)
|
line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
|
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
|
||||||
wantRuleIDs(t, line, "blocked")
|
wantRuleIDs(t, line, "blocked")
|
||||||
|
|
||||||
line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward)
|
line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||||
wantRuleIDs(t, line, "probe")
|
wantRuleIDs(t, line, "probe")
|
||||||
|
|
||||||
if line.BanExpires != "" {
|
if line.BanExpires != "" {
|
||||||
@@ -224,10 +219,9 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
|||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
})
|
})
|
||||||
|
|
||||||
s.request(client, "/blocked", http.StatusForbidden,
|
s.request(client, "/blocked", http.StatusForbidden, requestlog.ActionRuleBlocked)
|
||||||
requestlog.ActionBlockedByBlockRule)
|
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
|
||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
@@ -235,10 +229,10 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
|||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
|
`sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2)
|
wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2)
|
||||||
wantMetric(t, metrics, `sww_requests_total{action="blocked_by_block_rule",`+
|
wantMetric(t, metrics, `sww_requests_total{action="rule_blocked",`+
|
||||||
`instance="app",status_class="4xx"}`, 1)
|
`instance="app",status_class="4xx"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 1)
|
`sww_offences_total{instance="app",kind="rule_blocked"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1)
|
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0)
|
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func TestRequestFromAllowNetsIsNotCounted(t *testing.T) {
|
|||||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
|
|||||||
wantStatus(t, do(t, req), http.StatusForbidden)
|
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||||
|
|
||||||
line := out.endedLine(t)
|
line := out.endedLine(t)
|
||||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientDenied)
|
wantLine(t, line, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
|
|
||||||
if len(asked()) != 0 {
|
if len(asked()) != 0 {
|
||||||
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
||||||
@@ -108,7 +108,7 @@ func TestDenyNetsRefusalNamesItsHeuristicAndNetblock(t *testing.T) {
|
|||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet})
|
s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet})
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`)
|
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,10 +125,10 @@ func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
|
|||||||
// listedAddr's refused requests are not counted, so the first request
|
// listedAddr's refused requests are not counted, so the first request
|
||||||
// from unlistedAddr is within the limit of one a minute.
|
// from unlistedAddr is within the limit of one a minute.
|
||||||
wantAnswers(t, addr, out, []sentRequest{
|
wantAnswers(t, addr, out, []sentRequest{
|
||||||
{listedAddr, http.StatusForbidden, requestlog.ActionClientDenied},
|
{listedAddr, http.StatusForbidden, requestlog.ActionDenied},
|
||||||
{listedAddr, http.StatusForbidden, requestlog.ActionClientDenied},
|
{listedAddr, http.StatusForbidden, requestlog.ActionDenied},
|
||||||
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,8 +152,8 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
||||||
{fromKP, http.StatusForbidden, requestlog.ActionClientDenied},
|
{fromKP, http.StatusForbidden, requestlog.ActionDenied},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func (tagDeny) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|||||||
for _, tag := range rq.line.Tags {
|
for _, tag := range rq.line.Tags {
|
||||||
if slices.Contains(rq.h.config.TagDeny, tag) {
|
if slices.Contains(rq.h.config.TagDeny, tag) {
|
||||||
return &verdict{
|
return &verdict{
|
||||||
action: requestlog.ActionClientDenied,
|
action: requestlog.ActionDenied,
|
||||||
figures: map[string]any{"tag": tag},
|
figures: map[string]any{"tag": tag},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
|
|||||||
// in capitals.
|
// in capitals.
|
||||||
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"})
|
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"})
|
||||||
|
|
||||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
|
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`)
|
wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`)
|
||||||
wantTags(t, line, "asn=64511", "country=KP")
|
wantTags(t, line, "asn=64511", "country=KP")
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ func TestTagDenyRefusesAnASNTag(t *testing.T) {
|
|||||||
|
|
||||||
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"})
|
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"})
|
||||||
|
|
||||||
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
|
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
|
||||||
"tag_deny", `{"tag": "asn=64511"}`)
|
"tag_deny", `{"tag": "asn=64511"}`)
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ func TestTagDenyRefusesAListTagBeforeTheRateLimitsCount(t *testing.T) {
|
|||||||
|
|
||||||
// Each is refused for its tag: none is counted to break the limit of one.
|
// Each is refused for its tag: none is counted to break the limit of one.
|
||||||
for range 3 {
|
for range 3 {
|
||||||
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
|
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionDenied),
|
||||||
"tag_deny", `{"tag": "torexit"}`)
|
"tag_deny", `{"tag": "torexit"}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
|
|||||||
})
|
})
|
||||||
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
|
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
|
||||||
|
|
||||||
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
|
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionDenied),
|
||||||
"tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`)
|
"tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
|
|||||||
rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature",
|
rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature",
|
||||||
})
|
})
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
||||||
wantTags(t, line, "exploitsignature")
|
wantTags(t, line, "exploitsignature")
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
|
|||||||
// A quarter of 4 requests a minute: the second breaks the limit.
|
// A quarter of 4 requests a minute: the second breaks the limit.
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
"25 from "+tagLimitPercent)
|
"25 from "+tagLimitPercent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
|
|||||||
})
|
})
|
||||||
loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}})
|
loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}})
|
||||||
|
|
||||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
|
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
if line.Heuristic != "deny_nets" {
|
if line.Heuristic != "deny_nets" {
|
||||||
t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic)
|
t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic)
|
||||||
}
|
}
|
||||||
@@ -89,9 +89,9 @@ func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
|
|||||||
|
|
||||||
// The second request breaks the limit of one a minute, which bans the client.
|
// The second request breaks the limit of one a minute, which bans the client.
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
line = s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
line = s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
if line.Heuristic != "active_ban" {
|
if line.Heuristic != "active_ban" {
|
||||||
t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic)
|
t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,9 +30,10 @@ func (trapPaths) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rq.attack = true
|
rq.attack = true
|
||||||
|
rq.banForAttack(now, bans.Notes{TrapPath: path})
|
||||||
|
|
||||||
return &verdict{
|
return &verdict{
|
||||||
action: rq.banForAttack(now, bans.Notes{TrapPath: path}),
|
action: requestlog.ActionBanned,
|
||||||
figures: map[string]any{"path": path},
|
figures: map[string]any{"path": path},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
|||||||
|
|
||||||
// The query is not part of the path.
|
// The query is not part of the path.
|
||||||
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusTooManyRequests,
|
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusTooManyRequests,
|
||||||
requestlog.ActionTemporarilyBanned)
|
requestlog.ActionBanned)
|
||||||
wantRuleIDs(t, line)
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
|
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
|
||||||
@@ -77,7 +77,7 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The next request is refused under the ban, and makes it permanent.
|
// The next request is refused under the ban, and makes it permanent.
|
||||||
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
|
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||||
if line.BanExpires != permanent {
|
if line.BanExpires != permanent {
|
||||||
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ func TestTrapPathRefusalNamesItsHeuristicAndPath(t *testing.T) {
|
|||||||
s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
|
s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
|
||||||
|
|
||||||
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden,
|
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden,
|
||||||
requestlog.ActionTemporarilyBanned)
|
requestlog.ActionBanned)
|
||||||
wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`)
|
wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,8 +100,7 @@ func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
|
|||||||
|
|
||||||
// %2577 decodes once to %77, not to w.
|
// %2577 decodes once to %77, not to w.
|
||||||
s.request(otherClient, "/%2577p-login.php", http.StatusOK, requestlog.ActionForward)
|
s.request(otherClient, "/%2577p-login.php", http.StatusOK, requestlog.ActionForward)
|
||||||
s.request(client, "/%77p-login.php", http.StatusForbidden,
|
s.request(client, "/%77p-login.php", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
requestlog.ActionTemporarilyBanned)
|
|
||||||
|
|
||||||
// The ban's notes give the path as the client sent it.
|
// The ban's notes give the path as the client sent it.
|
||||||
got := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
got := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||||
@@ -118,8 +117,7 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
|
|||||||
"SWW_RULES_ENABLED": "false",
|
"SWW_RULES_ENABLED": "false",
|
||||||
})
|
})
|
||||||
|
|
||||||
s.request(client, "/xmlrpc.php", http.StatusForbidden,
|
s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
requestlog.ActionTemporarilyBanned)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
|
func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
|
||||||
@@ -131,7 +129,7 @@ func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||||
|
|
||||||
// No ban was made.
|
// No ban was made.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|||||||
@@ -424,10 +424,10 @@ func (m *Metrics) LimitHit(hit ratelimit.Hit) {
|
|||||||
// them.
|
// them.
|
||||||
func (m *Metrics) Offences(r ratelimit.Request) {
|
func (m *Metrics) Offences(r ratelimit.Request) {
|
||||||
for kind, committed := range map[string]bool{
|
for kind, committed := range map[string]bool{
|
||||||
"limit": r.BrokeLimit,
|
"limit": r.BrokeLimit,
|
||||||
"attack": r.Attack,
|
"attack": r.Attack,
|
||||||
"blocked_by_block_rule": r.BlockedByBlockRule,
|
"rule_blocked": r.RuleBlocked,
|
||||||
"waf_blocked": r.WAFBlocked,
|
"waf_blocked": r.WAFBlocked,
|
||||||
} {
|
} {
|
||||||
if committed {
|
if committed {
|
||||||
m.offences.WithLabelValues(kind).Inc()
|
m.offences.WithLabelValues(kind).Inc()
|
||||||
|
|||||||
@@ -122,12 +122,12 @@ type Responses struct {
|
|||||||
type Offences struct {
|
type Offences struct {
|
||||||
// Limit is its requests that broke a rate limit, a byte limit or the
|
// Limit is its requests that broke a rate limit, a byte limit or the
|
||||||
// error burst, Attack those that were a clear sign of attack, a match
|
// error burst, Attack those that were a clear sign of attack, a match
|
||||||
// of a ban rule or a request for a trap path, BlockedByBlockRule those a
|
// of a ban rule or a request for a trap path, RuleBlocked those a block
|
||||||
// block rule refused, and WAFBlocked those the Core Rule Set refused.
|
// rule refused, and WAFBlocked those the Core Rule Set refused.
|
||||||
Limit int64 `json:"limit"`
|
Limit int64 `json:"limit"`
|
||||||
Attack int64 `json:"attack"`
|
Attack int64 `json:"attack"`
|
||||||
BlockedByBlockRule int64 `json:"blocked_by_block_rule"`
|
RuleBlocked int64 `json:"rule_blocked"`
|
||||||
WAFBlocked int64 `json:"waf_blocked"`
|
WAFBlocked int64 `json:"waf_blocked"`
|
||||||
// TokenRefused is what a clients.json written by an earlier version gives,
|
// TokenRefused is what a clients.json written by an earlier version gives,
|
||||||
// which must still load. Nothing adds to it.
|
// which must still load. Nothing adds to it.
|
||||||
TokenRefused int64 `json:"token_refused,omitempty"`
|
TokenRefused int64 `json:"token_refused,omitempty"`
|
||||||
@@ -147,12 +147,12 @@ type Request struct {
|
|||||||
ResponseBytes int64
|
ResponseBytes int64
|
||||||
// BrokeLimit is true for a request that broke a rate limit, a byte
|
// BrokeLimit is true for a request that broke a rate limit, a byte
|
||||||
// limit or the error burst, Attack for one that matched a ban rule or
|
// limit or the error burst, Attack for one that matched a ban rule or
|
||||||
// asked for a trap path, BlockedByBlockRule for one a block rule refused,
|
// asked for a trap path, RuleBlocked for one a block rule refused, and
|
||||||
// and WAFBlocked for one the Core Rule Set refused.
|
// WAFBlocked for one the Core Rule Set refused.
|
||||||
BrokeLimit bool
|
BrokeLimit bool
|
||||||
Attack bool
|
Attack bool
|
||||||
BlockedByBlockRule bool
|
RuleBlocked bool
|
||||||
WAFBlocked bool
|
WAFBlocked bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Limiter for limits, with no client counted yet, whose
|
// New returns a Limiter for limits, with no client counted yet, whose
|
||||||
@@ -323,8 +323,8 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
|
|||||||
h.Offences.Attack++
|
h.Offences.Attack++
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.BlockedByBlockRule {
|
if r.RuleBlocked {
|
||||||
h.Offences.BlockedByBlockRule++
|
h.Offences.RuleBlocked++
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.WAFBlocked {
|
if r.WAFBlocked {
|
||||||
|
|||||||
@@ -20,37 +20,32 @@ const (
|
|||||||
// the app.
|
// the app.
|
||||||
ActionForward = "forward"
|
ActionForward = "forward"
|
||||||
|
|
||||||
// ActionRequestHeaderTooLarge is a request over the header limits.
|
// ActionTooLarge is a request over the header limits.
|
||||||
ActionRequestHeaderTooLarge = "request_header_too_large"
|
ActionTooLarge = "too_large"
|
||||||
|
|
||||||
// ActionClientIsRateLimited is a request refused because it took its
|
// ActionRateLimited is a request refused because it took its client
|
||||||
// client over a rate limit, which bans the client.
|
// over a rate limit, which bans the client.
|
||||||
ActionClientIsRateLimited = "client_is_rate_limited"
|
ActionRateLimited = "rate_limited"
|
||||||
|
|
||||||
// ActionTemporarilyBanned is a request refused because a ban that runs
|
// ActionBanned is a request refused because a ban covers its client,
|
||||||
// out covers its client, or because it matched a ban rule, asked for a
|
// or because it matched a ban rule, asked for a trap path or the
|
||||||
// trap path or the CrowdSec decision list lists its client, and so made
|
// CrowdSec decision list lists its client, each of which bans the
|
||||||
// such a ban.
|
// client.
|
||||||
ActionTemporarilyBanned = "temporarily_banned"
|
ActionBanned = "banned"
|
||||||
|
|
||||||
// ActionPermanentlyBanned is a request refused because a ban that never
|
// ActionRuleBlocked is a request refused because it matched a block
|
||||||
// runs out covers its client, or because the request made such a ban or
|
// rule.
|
||||||
// made its client's ban permanent.
|
ActionRuleBlocked = "rule_blocked"
|
||||||
ActionPermanentlyBanned = "permanently_banned"
|
|
||||||
|
|
||||||
// ActionBlockedByBlockRule is a request refused because it matched a
|
|
||||||
// block rule.
|
|
||||||
ActionBlockedByBlockRule = "blocked_by_block_rule"
|
|
||||||
|
|
||||||
// ActionWAFBlocked is a request refused because the Core Rule Set
|
// ActionWAFBlocked is a request refused because the Core Rule Set
|
||||||
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
|
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
ActionWAFBlocked = "waf_blocked"
|
ActionWAFBlocked = "waf_blocked"
|
||||||
|
|
||||||
// ActionClientDenied is a request refused because its client is in
|
// ActionDenied is a request refused because its client is in
|
||||||
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
|
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
|
||||||
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
||||||
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
|
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
|
||||||
ActionClientDenied = "client_denied"
|
ActionDenied = "denied"
|
||||||
|
|
||||||
// ActionCountryDenied is a request refused because its client's country
|
// ActionCountryDenied is a request refused because its client's country
|
||||||
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||||
@@ -139,10 +134,10 @@ type Line struct {
|
|||||||
// The decision.
|
// The decision.
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
// WouldAction is, in observe mode, the action enforce mode would have
|
// WouldAction is, in observe mode, the action enforce mode would have
|
||||||
// taken with a request it would have refused: ActionClientDenied,
|
// taken with a request it would have refused: ActionDenied,
|
||||||
// ActionTemporarilyBanned, ActionPermanentlyBanned, ActionCountryDenied,
|
// ActionBanned, ActionCountryDenied, ActionRateLimited,
|
||||||
// ActionClientIsRateLimited, ActionBlockedByBlockRule, ActionWAFBlocked,
|
// ActionRuleBlocked, ActionWAFBlocked, or, in discriminator, ActionTooLarge
|
||||||
// or, in discriminator, ActionRequestHeaderTooLarge for the header limits.
|
// for the header limits.
|
||||||
WouldAction string `json:"would_action,omitempty"`
|
WouldAction string `json:"would_action,omitempty"`
|
||||||
// Heuristic is the name of the heuristic that refused the request, in
|
// Heuristic is the name of the heuristic that refused the request, in
|
||||||
// observe mode too, and HeuristicFigures are the figures it acted on, by
|
// observe mode too, and HeuristicFigures are the figures it acted on, by
|
||||||
|
|||||||
@@ -373,13 +373,13 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
|
|||||||
out := runUntilStopped(t, env, func(url string) {
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
wantRefused(t, url)
|
wantRefused(t, url)
|
||||||
})
|
})
|
||||||
out.line(t, "action", "client_is_rate_limited")
|
out.line(t, "action", "rate_limited")
|
||||||
|
|
||||||
// After another, the ban still refuses it.
|
// After another, the ban still refuses it.
|
||||||
out = runUntilStopped(t, env, func(url string) {
|
out = runUntilStopped(t, env, func(url string) {
|
||||||
wantRefused(t, url)
|
wantRefused(t, url)
|
||||||
})
|
})
|
||||||
out.line(t, "action", "temporarily_banned")
|
out.line(t, "action", "banned")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
|
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
|
||||||
@@ -526,7 +526,7 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
|
|||||||
time.Sleep(pollInterval)
|
time.Sleep(pollInterval)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
out.line(t, "action", "blocked_by_block_rule")
|
out.line(t, "action", "rule_blocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) {
|
func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) {
|
||||||
@@ -740,7 +740,7 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
|
|||||||
time.Sleep(pollInterval)
|
time.Sleep(pollInterval)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
wantDeniedByList(t, out.line(t, "action", "client_denied"), dropURL)
|
wantDeniedByList(t, out.line(t, "action", "denied"), dropURL)
|
||||||
|
|
||||||
if fetches := torFetches.Load(); fetches != 1 {
|
if fetches := torFetches.Load(); fetches != 1 {
|
||||||
t.Errorf("tor.txt fetched %d times, want once, before the restart", fetches)
|
t.Errorf("tor.txt fetched %d times, want once, before the restart", fetches)
|
||||||
@@ -843,8 +843,7 @@ func wantDeniedByList(t *testing.T, line map[string]any, listURL string) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
reputation, _ := line["reputation"].([]any)
|
reputation, _ := line["reputation"].([]any)
|
||||||
if line["action"] != "client_denied" || len(reputation) != 1 ||
|
if line["action"] != "denied" || len(reputation) != 1 || reputation[0] != listURL {
|
||||||
reputation[0] != listURL {
|
|
||||||
t.Errorf("request log line %v, want one denied for %s", line, listURL)
|
t.Errorf("request log line %v, want one denied for %s", line, listURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-7
@@ -1,8 +1,51 @@
|
|||||||
# The eighth change: on gitea's paths, some fields skip the rules that refused
|
# gitea's fixes: some fields skip the rules that refused ordinary gitea requests.
|
||||||
# ordinary gitea requests there. They are the fields where gitea sends a search,
|
# They are the fields where gitea sends a search, a file name or a branch name.
|
||||||
# a file name or a branch name. Each exclusion runs with the request, before the
|
# Each exclusion runs with the request, before the rules it switches off, those
|
||||||
# rules it switches off, those of phase 1 included, so this file is loaded before
|
# of phase 1 included, so this file is loaded before the Core Rule Set's rules.
|
||||||
# the Core Rule Set's rules. Each comment names a request the rule refused.
|
# Each comment names a request the rule refused.
|
||||||
|
|
||||||
|
# A request whose hostname SWW_APPS does not list gitea for skips every rule
|
||||||
|
# below. Inspect sets tx.app_gitea on the others.
|
||||||
|
SecRule &TX:app_gitea "@eq 0" \
|
||||||
|
"id:900590,phase:1,pass,nolog,skipAfter:END_GITEA"
|
||||||
|
|
||||||
|
# The query parameters in which gitea sends names within a repository or its
|
||||||
|
# own records, or a page of its own site, are not checked against the lists of
|
||||||
|
# system files, shell paths and command names, on any path:
|
||||||
|
# 930120: .gitignore
|
||||||
|
# 932160: bin/docker-entrypoint
|
||||||
|
# 932260: docker-build
|
||||||
|
SecAction "id:900591,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:files,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:skip-to,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:sub_path,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:ref,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:sha,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:branch,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:workflow,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:artifactName,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:files,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:skip-to,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:sub_path,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:ref,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:sha,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:branch,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:workflow,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:artifactName,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:files,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:skip-to,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:sub_path,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:ref,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:sha,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:branch,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:workflow,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:artifactName,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:redirect_to"
|
||||||
|
|
||||||
# gitea's searches of code, of issues and pull requests, and of commits send the
|
# gitea's searches of code, of issues and pull requests, and of commits send the
|
||||||
# text searched for in q, which skips these rules there:
|
# text searched for in q, which skips these rules there:
|
||||||
@@ -155,8 +198,8 @@ SecRule REQUEST_FILENAME \
|
|||||||
# 942140: information_schema.sql
|
# 942140: information_schema.sql
|
||||||
# 944110: java/lang/Runtime.java
|
# 944110: java/lang/Runtime.java
|
||||||
# 944130: java.lang.String.md
|
# 944130: java.lang.String.md
|
||||||
# Each request skips the rules that refused it. The fourth change already has
|
# Each request skips the rules that refused it. Rule 900591 already has path and
|
||||||
# path and redirect_to skip 930120, 932160 and 932260 on every path.
|
# redirect_to skip 930120, 932160 and 932260 on every path.
|
||||||
|
|
||||||
# A file's commit graph, in file.
|
# A file's commit graph, in file.
|
||||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
|
||||||
@@ -281,3 +324,5 @@ SecRule REQUEST_FILENAME \
|
|||||||
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
|
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
|
||||||
"id:900640,phase:1,pass,nolog,\
|
"id:900640,phase:1,pass,nolog,\
|
||||||
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
||||||
|
|
||||||
|
SecMarker END_GITEA
|
||||||
+101
-57
@@ -1,9 +1,9 @@
|
|||||||
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
||||||
// method, the URL with its query and the headers of a request, and on its
|
// method, the URL with its query and the headers of a request, and on its
|
||||||
// body while SWW_WAF_BODY_LIMIT is set, with the eight changes sww
|
// body while SWW_WAF_BODY_LIMIT is set, with the six changes sww
|
||||||
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
|
// makes to it, the fixes for the apps SWW_APPS names, and the exclusions in
|
||||||
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
|
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings"
|
||||||
// response.
|
// in SPEC.md describes them. It reads no response.
|
||||||
//
|
//
|
||||||
// sww writes only to its state directory, so Coraza is built with
|
// sww writes only to its state directory, so Coraza is built with
|
||||||
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
@@ -32,8 +34,8 @@ import (
|
|||||||
|
|
||||||
// directives are the Core Rule Set as sww runs it, with the
|
// directives are the Core Rule Set as sww runs it, with the
|
||||||
// paranoia level for %d, bodyDirectives for the first %s while
|
// paranoia level for %d, bodyDirectives for the first %s while
|
||||||
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and
|
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and the
|
||||||
// giteaExclusions for the third.
|
// directives of every app in apps for the third.
|
||||||
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
||||||
// Core Rule Set keeps for the rules that set it up, which
|
// Core Rule Set keeps for the rules that set it up, which
|
||||||
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
|
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
|
||||||
@@ -81,44 +83,28 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
|
|||||||
# before the rules of phase 1 too.
|
# before the rules of phase 1 too.
|
||||||
%s
|
%s
|
||||||
|
|
||||||
# The eighth change comes before the rules for the same reason.
|
# The apps' fixes come before the rules for the same reason.
|
||||||
%s
|
%s
|
||||||
|
|
||||||
# The sixth: only the rules for requests are loaded, and no response is
|
# The fifth: only the rules for requests are loaded, and no response is
|
||||||
# inspected.
|
# inspected.
|
||||||
Include @owasp_crs/REQUEST-*.conf
|
Include @owasp_crs/REQUEST-*.conf
|
||||||
|
|
||||||
# The third: redirect_uri is not checked for a URL naming an IP address or
|
# The third: redirect_uri is not checked for a URL naming an IP address or
|
||||||
# localhost. Coraza matches a parameter name here, and in the fourth,
|
# localhost. Coraza matches a parameter name here without regard to case.
|
||||||
# without regard to case. ARGS holds the fields of a form data or multipart
|
# ARGS holds the fields of a form data or multipart body Coraza reads as
|
||||||
# body Coraza reads as well as the query parameters, so a field of one of
|
# well as the query parameters, so a field of this name is left out too.
|
||||||
# these names is left out too.
|
|
||||||
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
|
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
|
||||||
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
|
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
|
||||||
|
|
||||||
# The fourth: the query parameters in which gitea sends names within a
|
# The fourth: Referer is not checked for a Unix command without arguments,
|
||||||
# repository or its own records, or a page of its own site, are not
|
# for Java starting a process, or for a Java class name. 944130 refused
|
||||||
# checked against the lists of system files, shell paths and command
|
# every page opened from the page of java.lang.String.md.
|
||||||
# names. Coraza takes one rule id per directive.
|
|
||||||
SecRuleUpdateTargetById 930120 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
||||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
||||||
!ARGS:artifactName|!ARGS:redirect_to"
|
|
||||||
SecRuleUpdateTargetById 932160 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
||||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
||||||
!ARGS:artifactName|!ARGS:redirect_to"
|
|
||||||
SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
||||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
||||||
!ARGS:artifactName|!ARGS:redirect_to"
|
|
||||||
|
|
||||||
# The fifth, for Referer: it is not checked for a Unix command without
|
|
||||||
# arguments, for Java starting a process, or for a Java class name. 944130
|
|
||||||
# refused every page opened from the page of java.lang.String.md. The
|
|
||||||
# cookies are left out in Inspect.
|
|
||||||
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
||||||
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
||||||
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
|
||||||
|
|
||||||
# The seventh: a container image push that reuses a layer names the image
|
# The sixth: a container image push that reuses a layer names the image
|
||||||
# it takes the layer from in the query parameter from, which is not
|
# it takes the layer from in the query parameter from, which is not
|
||||||
# checked for command names. 932260 refused the push of a layer from
|
# checked for command names. 932260 refused the push of a layer from
|
||||||
# owner/python3-app.
|
# owner/python3-app.
|
||||||
@@ -168,18 +154,36 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
|
|||||||
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
||||||
`
|
`
|
||||||
|
|
||||||
// giteaExclusions are the eighth change: the fields of gitea's own
|
// app is an app whose ordinary requests the Core Rule Set would refuse,
|
||||||
// requests that skip the rules that refused them there, as Coraza
|
// with the fixes that let them pass.
|
||||||
// directives.
|
type app struct {
|
||||||
//
|
// directives are Coraza exclusions, loaded before the Core Rule Set's
|
||||||
//go:embed gitea.conf
|
// rules. Their first rule skips the rest unless Inspect sets the
|
||||||
var giteaExclusions string
|
// transaction variable app_ and the app's name, and they end with the
|
||||||
|
// marker it skips to.
|
||||||
|
directives string
|
||||||
|
// cookies are the cookies the Core Rule Set reads a request without.
|
||||||
|
cookies []string
|
||||||
|
}
|
||||||
|
|
||||||
// cookiesNotRead are the cookies the Core Rule Set reads a request
|
//go:embed gitea.conf
|
||||||
// without, the rest of the fifth change.
|
var giteaDirectives string
|
||||||
|
|
||||||
|
// apps are the apps whose fixes sww has, by the name SWW_APPS gives each.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a map
|
||||||
var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
|
var apps = map[string]app{
|
||||||
|
"gitea": {
|
||||||
|
directives: giteaDirectives,
|
||||||
|
cookies: []string{"gitea_flash", "redirect_to"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppNames returns the names of the apps whose fixes sww has, in
|
||||||
|
// alphabetical order.
|
||||||
|
func AppNames() []string {
|
||||||
|
return slices.Sorted(maps.Keys(apps))
|
||||||
|
}
|
||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
@@ -195,6 +199,10 @@ type Params struct {
|
|||||||
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
||||||
// there is none.
|
// there is none.
|
||||||
ExclusionsFile string
|
ExclusionsFile string
|
||||||
|
// Apps are the names of the apps each hostname serves, by hostname in
|
||||||
|
// lowercase (SWW_APPS). A request gets the fixes of its hostname's
|
||||||
|
// apps only, or of every app while Apps is nil.
|
||||||
|
Apps map[string][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// The ids of sww's own rules, which an exclusions file may not
|
// The ids of sww's own rules, which an exclusions file may not
|
||||||
@@ -286,13 +294,15 @@ func checkExclusions(exclusions string) error {
|
|||||||
type CoreRuleSet struct {
|
type CoreRuleSet struct {
|
||||||
waf coraza.WAF
|
waf coraza.WAF
|
||||||
bodyLimit int64
|
bodyLimit int64
|
||||||
|
apps map[string][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns the Core Rule Set with the eight changes, at params'
|
// New returns the Core Rule Set with the six changes and the apps' fixes,
|
||||||
// paranoia level, without the rules it switches off, reading request
|
// at params' paranoia level, without the rules it switches off, reading
|
||||||
// bodies up to params' limit, and with params' exclusions file loaded
|
// request bodies up to params' limit, and with params' exclusions file
|
||||||
// before the Core Rule Set's rules. An exclusions file is an error if it
|
// loaded before the Core Rule Set's rules. An exclusions file is an error
|
||||||
// cannot be read or does not parse, or if checkExclusions refuses it.
|
// if it cannot be read or does not parse, or if checkExclusions refuses
|
||||||
|
// it.
|
||||||
func New(params Params) (*CoreRuleSet, error) {
|
func New(params Params) (*CoreRuleSet, error) {
|
||||||
body := ""
|
body := ""
|
||||||
if params.BodyLimit > 0 {
|
if params.BodyLimit > 0 {
|
||||||
@@ -315,8 +325,13 @@ func New(params Params) (*CoreRuleSet, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fixes strings.Builder
|
||||||
|
for _, name := range AppNames() {
|
||||||
|
fixes.WriteString(apps[name].directives + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
||||||
giteaExclusions)
|
fixes.String())
|
||||||
|
|
||||||
if len(params.DisabledRules) > 0 {
|
if len(params.DisabledRules) > 0 {
|
||||||
ids := make([]string, len(params.DisabledRules))
|
ids := make([]string, len(params.DisabledRules))
|
||||||
@@ -334,7 +349,7 @@ func New(params Params) (*CoreRuleSet, error) {
|
|||||||
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}, nil
|
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit, apps: params.Apps}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result is what the Core Rule Set found in a request.
|
// Result is what the Core Rule Set found in a request.
|
||||||
@@ -346,11 +361,12 @@ type Result struct {
|
|||||||
Score int
|
Score int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inspect runs the Core Rule Set on r, a request from client: on its
|
// Inspect runs the Core Rule Set on r, a request from client, with the
|
||||||
// method, its URL with the query, and its headers, the Cookie header
|
// fixes of the apps appsFor names for r's hostname: on its method, its URL
|
||||||
// without the cookies in cookiesNotRead, and on body, r's body as the
|
// with the query, and its headers, the Cookie header without those apps'
|
||||||
// caller has it, as readBody reads it. It returns what it found, what it
|
// cookies, and on body, r's body as the caller has it, as readBody reads
|
||||||
// read of body, and the error that ended the reading early, if one did.
|
// it. It returns what it found, what it read of body, and the error that
|
||||||
|
// ended the reading early, if one did.
|
||||||
func (c *CoreRuleSet) Inspect(
|
func (c *CoreRuleSet) Inspect(
|
||||||
r *http.Request, client netip.Addr, body io.Reader,
|
r *http.Request, client netip.Addr, body io.Reader,
|
||||||
) (Result, []byte, error) {
|
) (Result, []byte, error) {
|
||||||
@@ -358,13 +374,24 @@ func (c *CoreRuleSet) Inspect(
|
|||||||
// Closing would remove the files Coraza wrote, and it writes none.
|
// Closing would remove the files Coraza wrote, and it writes none.
|
||||||
defer func() { _ = tx.Close() }()
|
defer func() { _ = tx.Close() }()
|
||||||
|
|
||||||
|
// The transaction's variables are set only through Coraza's interface
|
||||||
|
// for plugins.
|
||||||
|
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
|
||||||
|
|
||||||
|
var cookiesNotRead []string
|
||||||
|
|
||||||
|
for _, name := range c.appsFor(r.Host) {
|
||||||
|
state.Variables().TX().Set("app_"+name, []string{"1"})
|
||||||
|
cookiesNotRead = append(cookiesNotRead, apps[name].cookies...)
|
||||||
|
}
|
||||||
|
|
||||||
tx.ProcessConnection(client.String(), 0, "", 0)
|
tx.ProcessConnection(client.String(), 0, "", 0)
|
||||||
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
|
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
|
||||||
|
|
||||||
for name, values := range r.Header {
|
for name, values := range r.Header {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if name == "Cookie" {
|
if name == "Cookie" {
|
||||||
value = withoutCookiesNotRead(value)
|
value = withoutCookies(value, cookiesNotRead)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
continue // it held those cookies alone
|
continue // it held those cookies alone
|
||||||
}
|
}
|
||||||
@@ -457,9 +484,26 @@ func score(tx types.Transaction) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// withoutCookiesNotRead returns value, a Cookie header's, without the
|
// appsFor returns the names of the apps whose fixes a request for host,
|
||||||
// cookies in cookiesNotRead.
|
// its Host header, gets: every app while SWW_APPS is unset, and otherwise
|
||||||
func withoutCookiesNotRead(value string) string {
|
// those SWW_APPS lists for host's name, without the port and in
|
||||||
|
// lowercase.
|
||||||
|
func (c *CoreRuleSet) appsFor(host string) []string {
|
||||||
|
if c.apps == nil {
|
||||||
|
return AppNames()
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname, _, err := net.SplitHostPort(host)
|
||||||
|
if err != nil {
|
||||||
|
hostname = host // it has no port
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.apps[strings.ToLower(hostname)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutCookies returns value, a Cookie header's, without the cookies
|
||||||
|
// named in cookiesNotRead.
|
||||||
|
func withoutCookies(value string, cookiesNotRead []string) string {
|
||||||
var kept []string
|
var kept []string
|
||||||
|
|
||||||
for cookie := range strings.SplitSeq(value, ";") {
|
for cookie := range strings.SplitSeq(value, ";") {
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ func atDefaults(t *testing.T) *waf.CoreRuleSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wantChange checks that crs lets through passes, a gitea request one of
|
// wantChange checks that crs lets through passes, a gitea request one of
|
||||||
// the eight changes is for, and still finds result in refused, a request
|
// the six changes or gitea's fixes are for, and still finds result in
|
||||||
// like it that the change is not for.
|
// refused, a request like it that they are not for.
|
||||||
func wantChange(
|
func wantChange(
|
||||||
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
|
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
|
||||||
) {
|
) {
|
||||||
@@ -381,6 +381,96 @@ 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) (*waf.CoreRuleSet, error) {
|
||||||
|
|||||||
+4
-5
@@ -187,16 +187,15 @@ main() {
|
|||||||
echo "example-app: rproxy passes a request to the app and its answer back"
|
echo "example-app: rproxy passes a request to the app and its answer back"
|
||||||
|
|
||||||
refused || fail "a second request in a minute was not refused"
|
refused || fail "a second request in a minute was not refused"
|
||||||
wait_for "discriminator logged no ban" \
|
wait_for "discriminator logged no ban" logged "$DISCRIMINATOR" '"action":"rate_limited"'
|
||||||
logged "$DISCRIMINATOR" '"action":"client_is_rate_limited"'
|
|
||||||
echo "example-app: a second request in a minute bans the client"
|
echo "example-app: a second request in a minute bans the client"
|
||||||
|
|
||||||
refused_from 203.0.113.9 /.env || fail "a probe for /.env was not refused"
|
refused_from 203.0.113.9 /.env || fail "a probe for /.env was not refused"
|
||||||
wait_for "discriminator logged no ban for the probe" \
|
wait_for "discriminator logged no ban for the probe" \
|
||||||
logged "$DISCRIMINATOR" '"action":"temporarily_banned"' '"rule_ids":["env-file"]'
|
logged "$DISCRIMINATOR" '"action":"banned"' '"rule_ids":["env-file"]'
|
||||||
refused_from 203.0.113.9 / || fail "the client of the probe was let through"
|
refused_from 203.0.113.9 / || fail "the client of the probe was let through"
|
||||||
wait_for "the client's next request did not make its ban permanent" \
|
wait_for "the client's next request did not make its ban permanent" \
|
||||||
logged "$DISCRIMINATOR" '"action":"permanently_banned"' '"ban_expires":"permanent"'
|
logged "$DISCRIMINATOR" '"ban_expires":"permanent"'
|
||||||
echo "example-app: a probe for /.env bans the client, its next request for good"
|
echo "example-app: a probe for /.env bans the client, its next request for good"
|
||||||
|
|
||||||
docker exec "$DISCRIMINATOR" sv stop sww >/dev/null ||
|
docker exec "$DISCRIMINATOR" sv stop sww >/dev/null ||
|
||||||
@@ -233,7 +232,7 @@ main() {
|
|||||||
start_app
|
start_app
|
||||||
refused || fail "the new discriminator container let the banned client through"
|
refused || fail "the new discriminator container let the banned client through"
|
||||||
wait_for "discriminator logged no request refused under the ban" \
|
wait_for "discriminator logged no request refused under the ban" \
|
||||||
logged "$DISCRIMINATOR" '"action":"temporarily_banned"'
|
logged "$DISCRIMINATOR" '"action":"banned"'
|
||||||
echo "example-app: a new discriminator container on the same volume keeps the ban"
|
echo "example-app: a new discriminator container on the same volume keeps the ban"
|
||||||
|
|
||||||
docker rm --force "$DISCRIMINATOR" >/dev/null
|
docker rm --force "$DISCRIMINATOR" >/dev/null
|
||||||
|
|||||||
Reference in new issue
Block a user