Compare commits

...
1 Commits
Author SHA1 Message Date
clawbot e265e8cd04 AbuseIPDB scores for clients that committed an offence, within a daily budget (closes #105)
check / check (push) Waiting to run
With SWWAF_ABUSEIPDB_KEY set, a client whose history counts an offence
(a broken limit, a ban rule's match or a block rule's refusal, counted
by kind) is checked in the background, at most
SWWAF_ABUSEIPDB_DAILY_BUDGET checks a day, the count kept in
reputation.json. A client, an IPv4 address or an IPv6 /64, is checked by
the address it sent from, and its score serves all its addresses. A
score at or over SWWAF_ABUSEIPDB_MIN_SCORE is a hit for
SWWAF_REPUTATION_ACTION, logged as abuseipdb and alerted with its score.
A failure or the used-up budget gives no score and raises
source_failure. The key goes only in the Key header.

Judgement call: the budget's day is UTC; AbuseIPDB documents no reset time.
Judgement call: each check sent spends budget; a minute's pause after a failure.

Model: opus-5-5
2026-10-07 20:27:55 +00:00
23 changed files with 2045 additions and 309 deletions
+244 -147
View File
@@ -26,36 +26,39 @@ Slack and ntfy, and remote log sending. So is the stage after that: the AS
number and country of every client, looked up through GeoJS or in the IPinfo number and country of every client, looked up through GeoJS or in the IPinfo
Lite database file, the byte limits, the biased thresholds, lower limits for the Lite database file, the byte limits, the biased thresholds, lower limits for the
AS numbers and countries you list, and the anomaly thresholds, alerts for AS numbers and countries you list, and the anomaly thresholds, alerts for
unusual traffic that refuse nothing. So are the first two parts of the stage unusual traffic that refuse nothing. So are the first three parts of the stage
after that: the blocklists you name by URL, which it fetches and keeps, with a after that: the blocklists you name by URL, which it fetches and keeps, with a
file of AS numbers' percentages fetched the same way, and the DNS blocklists file of AS numbers' percentages fetched the same way, the DNS blocklists (DNSBL
(DNSBL zones), which it asks about each client in the background. `smallwebwaf` zones), which it asks about each client in the background, and AbuseIPDB, which
passes each request to the app and the app's answer back, unchanged, within its it asks in the background about each client that has committed an offence.
timeouts and size limits, works out each client's address, looks up its AS `smallwebwaf` passes each request to the app and the app's answer back,
number and country unless you switch that off, bans a client that sends too many unchanged, within its timeouts and size limits, works out each client's address,
requests or too many bytes, not counting those for the paths you choose, with looks up its AS number and country unless you switch that off, bans a client
lower limits for the clients of the AS numbers and countries you list, refuses a that sends too many requests or too many bytes, not counting those for the paths
client that comes from a country you refuse or from a network you refuse, you choose, with lower limits for the clients of the AS numbers and countries
refuses, limits or only notes a client a blocklist or a DNSBL zone you name you list, refuses a client that comes from a country you refuse or from a
lists, lets the networks you choose through, checks each request against the network you refuse, refuses, limits or only notes a client a blocklist or a
rule files and bans a client whose request is a clear sign of attack, keeps its DNSBL zone you name lists, or AbuseIPDB scores at or over the score you set,
bans, each client's counters and history, GeoJS's answers, the last good copy of lets the networks you choose through, checks each request against the rule files
each list it fetches and the DNSBL zones' verdicts in JSON files across and bans a client whose request is a clear sign of attack, keeps its bans, each
restarts, takes in your edits of those files, such as a ban you make, keep or client's counters and history, GeoJS's answers, the last good copy of each list
lift, and of the rule files while it runs, writes a JSON log line for every it fetches, the DNSBL zones' verdicts, AbuseIPDB's scores and the AbuseIPDB
request, sends its log lines to a syslog server too if you name one, sends an checks spent today in JSON files across restarts, takes in your edits of those
alert to a webhook, to Slack and to ntfy, each if you name one, for each ban it files, such as a ban you make, keep or lift, and of the rule files while it
makes or makes permanent, for traffic over an anomaly threshold you set, for a runs, writes a JSON log line for every request, sends its log lines to a syslog
client a blocklist or a DNSBL zone lists, for GeoJS failing, a list it cannot server too if you name one, sends an alert to a webhook, to Slack and to ntfy,
fetch or a DNSBL zone that fails or refuses a query, for a rule file or state each if you name one, for each ban it makes or makes permanent, for traffic over
file with an error and for a replacement of the lookup database it cannot read, an anomaly threshold you set, for a client a blocklist, a DNSBL zone or
serves Prometheus metrics to a scraper that holds the metrics token, lets an AbuseIPDB lists, for GeoJS failing, a list it cannot fetch, a DNSBL zone or
admin who holds the admin token list, add and lift bans and ask what it knows of AbuseIPDB that fails or refuses a query and the day's AbuseIPDB checks used up,
a client, and in `observe` mode passes on the requests it would refuse, logging for a rule file or state file with an error and for a replacement of the lookup
what it would have done with them. It comes as the image the app's own image is database it cannot read, serves Prometheus metrics to a scraper that holds the
built on. The rest of the design comes after that, in the order of the build metrics token, lets an admin who holds the admin token list, add and lift bans
order in [`SPEC.md`](SPEC.md). The survey of existing tools that led to the and ask what it knows of a client, and in `observe` mode passes on the requests
design is in [`EVALUATION.md`](EVALUATION.md). it would refuse, logging what it would have done with them. It comes as the
image the app's own image is built on. The rest of the design comes after that,
in the order of the build order in [`SPEC.md`](SPEC.md). The survey of existing
tools that led to the design is in [`EVALUATION.md`](EVALUATION.md).
## Getting started ## Getting started
@@ -222,35 +225,46 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
make no ban. With `log`, nothing more is done. Whatever the action, the make no ban. With `log`, nothing more is done. Whatever the action, the
request's log line names the zones, and each raises an alert. A client a request's log line names the zones, and each raises an alert. A client a
blocklist refuses is not checked. blocklist refuses is not checked.
- Checks the client with AbuseIPDB while `SWWAF_ABUSEIPDB_KEY` is set, after the
DNSBL zones and before the rate limits (see "AbuseIPDB" below), by the scores
it keeps. Only a client whose history counts an offence is checked, so far one
that has broken a rate limit or a byte limit, matched a ban rule, or had a
request refused by a block rule, and only in the background, so that no
request waits for AbuseIPDB. A score at or over `SWWAF_ABUSEIPDB_MIN_SCORE` is
a hit, and `SWWAF_REPUTATION_ACTION` does with its client what it does with
one a DNSBL zone's verdict lists. The request's log line names AbuseIPDB, and
it raises an alert. A client a blocklist or a DNSBL zone refuses is not
checked.
- Checks the client's own address against the static lists, the three netblock - Checks the client's own address against the static lists, the three netblock
settings below, before anything else, its lookup included. A client in settings below, before anything else, its lookup included. A client in
`SWWAF_ALLOW_NETS` skips bans, the country lists, the blocklists, the DNSBL `SWWAF_ALLOW_NETS` skips bans, the country lists, the blocklists, the DNSBL
zones, the rate limits, the byte limits and the rule files, and is not looked zones, AbuseIPDB, the rate limits, the byte limits and the rule files, and is
up; the timeouts and size limits still apply. A client in `SWWAF_DENY_NETS` is not looked up; the timeouts and size limits still apply. A client in
refused with `SWWAF_BAN_RESPONSE` before its body is read, and the request is `SWWAF_DENY_NETS` is refused with `SWWAF_BAN_RESPONSE` before its body is
not counted for the rate limits; an address in `SWWAF_ALLOW_NETS` too is let read, and the request is not counted for the rate limits; an address in
through. A client in `SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor `SWWAF_ALLOW_NETS` too is let through. A client in
refused by the rate limits, and has no bytes counted by the byte limits; the `SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor refused by the rate
country lists, the rule files and bans still apply to it. limits, and has no bytes counted by the byte limits; the country lists, the
rule files and bans still apply to it.
- In `observe` mode, with `SWWAF_MODE=observe`, refuses none of the requests - In `observe` mode, with `SWWAF_MODE=observe`, refuses none of the requests
that `SWWAF_DENY_NETS`, a ban, the country lists, a blocklist, a DNSBL zone's that `SWWAF_DENY_NETS`, a ban, the country lists, a blocklist, a DNSBL zone's
verdict, a rate limit or a rule would refuse: it passes them to the app, and verdict, AbuseIPDB's score, a rate limit or a rule would refuse: it passes
their log lines name what `enforce` mode would have done (see `would_action` them to the app, and their log lines name what `enforce` mode would have done
in "Request log" below). The checks run, and requests and bytes are counted, (see `would_action` in "Request log" below). The checks run, and requests and
as in `enforce` mode, with three differences: neither a broken rate limit or bytes are counted, as in `enforce` mode, with three differences: neither a
byte limit nor a `ban` rule makes a ban; a broken limit does not set the broken rate limit or byte limit nor a `ban` rule makes a ban; a broken limit
client's counters back to zero, so each request over a rate limit is logged as does not set the client's counters back to zero, so each request over a rate
one that would be refused, and each whose bytes keep the client over a byte limit is logged as one that would be refused, and each whose bytes keep the
limit as breaking it; and a request under a ban does not make it permanent. As client over a byte limit as breaking it; and a request under a ban does not
in `enforce` mode, the bytes counted are only those of the requests `enforce` make it permanent. As in `enforce` mode, the bytes counted are only those of
mode would have passed to the app. A ban it would have made, or made the requests `enforce` mode would have passed to the app. A ban it would have
permanent, raises the alert `enforce` mode would have raised, marked as what made, or made permanent, raises the alert `enforce` mode would have raised,
would have happened (see "Alerts" below). The bans in `bans.json` are kept, marked as what would have happened (see "Alerts" below). The bans in
and refuse requests again when `smallwebwaf` next runs in `enforce` mode, as `bans.json` are kept, and refuse requests again when `smallwebwaf` next runs
long as they last. The timeouts and size limits still apply, since they in `enforce` mode, as long as they last. The timeouts and size limits still
protect `smallwebwaf` and the app themselves, and a request for one of apply, since they protect `smallwebwaf` and the app themselves, and a request
`smallwebwaf`'s own endpoints without its token is still answered `401`. It is for one of `smallwebwaf`'s own endpoints without its token is still answered
for trying a configuration before enforcing it. `401`. It is for trying a configuration before enforcing it.
- Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any - Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any
check and without asking the app, for the image's health check. check and without asking the app, for the image's health check.
- Answers `GET /_smallwebwaf/metrics` with its metrics (see "Metrics" below) for - Answers `GET /_smallwebwaf/metrics` with its metrics (see "Metrics" below) for
@@ -270,14 +284,15 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
`SWWAF_LOG_REMOTE_URL` names one (see "Sending the log to a syslog server" `SWWAF_LOG_REMOTE_URL` names one (see "Sending the log to a syslog server"
below). below).
- Sends an alert for each ban it makes or makes permanent, for a count over an - Sends an alert for each ban it makes or makes permanent, for a count over an
anomaly threshold, for a client a blocklist or a DNSBL zone lists, for GeoJS anomaly threshold, for a client a blocklist, a DNSBL zone or AbuseIPDB lists,
failing, a list it cannot fetch or a DNSBL zone that fails or refuses a query, for GeoJS failing, a list it cannot fetch, a DNSBL zone or AbuseIPDB that
for a rule file or state file with an error, and for a replacement of the fails or refuses a query, and the day's AbuseIPDB checks used up, for a rule
lookup database it cannot read, holding back repeats and, past an hourly file or state file with an error, and for a replacement of the lookup database
limit, rolling the rest into one summary, to each destination you name: as a it cannot read, holding back repeats and, past an hourly limit, rolling the
JSON object to the webhook `SWWAF_ALERT_WEBHOOK_URL` names, as a message to rest into one summary, to each destination you name: as a JSON object to the
the Slack incoming webhook `SWWAF_ALERT_SLACK_WEBHOOK_URL` names, and as a webhook `SWWAF_ALERT_WEBHOOK_URL` names, as a message to the Slack incoming
message to the ntfy topic `SWWAF_ALERT_NTFY_URL` names (see "Alerts" below). webhook `SWWAF_ALERT_SLACK_WEBHOOK_URL` names, and as a message to the ntfy
topic `SWWAF_ALERT_NTFY_URL` names (see "Alerts" below).
- Counts requests and their bytes over a minute and an hour, per client, per - Counts requests and their bytes over a minute and an hour, per client, per
netblock around a client, per AS number, for the whole service and per named netblock around a client, per AS number, for the whole service and per named
netblock, and sends an `anomaly` alert for a count over the anomaly threshold netblock, and sends an `anomaly` alert for a count over the anomaly threshold
@@ -340,8 +355,9 @@ effective settings are logged at start.
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body. - `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body.
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body. - `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body.
- `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip bans, the - `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip bans, the
country lists, the blocklists, the DNSBL zones, the rate limits, the byte country lists, the blocklists, the DNSBL zones, AbuseIPDB, the rate limits,
limits and the rule files, such as your monitoring or your own networks. the byte limits and the rule files, such as your monitoring or your own
networks.
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients the - `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients the
rate limits and the byte limits do not apply to, such as a machine that talks rate limits and the byte limits do not apply to, such as a machine that talks
to the app all day. to the app all day.
@@ -457,21 +473,28 @@ effective settings are logged at start.
`192.0.2.53` or `[2001:db8::53]:5353`. Unset, it is the host's, as `192.0.2.53` or `[2001:db8::53]:5353`. Unset, it is the host's, as
`/etc/resolv.conf` names it. Several zones refuse queries that come through a `/etc/resolv.conf` names it. Several zones refuse queries that come through a
public resolver. public resolver.
- `SWWAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account,
which clients are checked with (see "AbuseIPDB" below). While it is unset, no
client is checked. The settings logged at start show `********` in its place.
- `SWWAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
from 0 to 100, that is a hit.
- `SWWAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day,
in UTC, a whole number above zero. AbuseIPDB's free accounts may make 1,000.
- `SWWAF_REPUTATION_ACTION` (default `limit:25`): what is done with a client a - `SWWAF_REPUTATION_ACTION` (default `limit:25`): what is done with a client a
zone's verdict lists, as `SWWAF_BLOCKLIST_ACTION` is for a blocklist: `deny`, zone's verdict lists, or whose AbuseIPDB score is a hit, as
`limit:<percent>` or `log`. A zone's verdict is less certain than a list such `SWWAF_BLOCKLIST_ACTION` is for a blocklist: `deny`, `limit:<percent>` or
as DROP, so by default a client it lists gets a quarter of every rate limit `log`. Such a verdict is less certain than a list such as DROP, so by default
and byte limit. its client gets a quarter of every rate limit and byte limit.
- `SWWAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict on a - `SWWAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict on a
client is used after the zone gave it. client, or AbuseIPDB's score of it, is used after it was given.
- `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone may take - `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone, or a
before it fails. check with AbuseIPDB, may take before it fails.
- `SWWAF_BAN_RESPONSE` (default `403`): how a refused client is answered, one - `SWWAF_BAN_RESPONSE` (default `403`): how a refused client is answered, one
that is banned, breaks a rate limit, matches a `ban` rule, is in that is banned, breaks a rate limit, matches a `ban` rule, is in
`SWWAF_DENY_NETS`, comes from a refused country, is in a blocklist while `SWWAF_DENY_NETS`, comes from a refused country, is in a blocklist while
`SWWAF_BLOCKLIST_ACTION` is `deny` or is listed by a DNSBL zone while `SWWAF_BLOCKLIST_ACTION` is `deny` or is listed by a DNSBL zone or AbuseIPDB
`SWWAF_REPUTATION_ACTION` is `deny`: `403`, `429`, or `close` to close the while `SWWAF_REPUTATION_ACTION` is `deny`: `403`, `429`, or `close` to close
connection without an answer. Behind traefik, `close` does not leave the the connection without an answer. Behind traefik, `close` does not leave the
client unanswered: traefik answers `502`, as it does whenever its backend client unanswered: traefik answers `502`, as it does whenever its backend
drops a connection. A `block` rule always answers `403`. drops a connection. A `block` rule always answers `403`.
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken rate - `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken rate
@@ -616,7 +639,8 @@ listed twice in one of them stops the start. `off` switches a timeout, a size
limit, a rate limit, a byte limit, an anomaly threshold, `SWWAF_ALERT_COOLDOWN` limit, a rate limit, a byte limit, an anomaly threshold, `SWWAF_ALERT_COOLDOWN`
or `SWWAF_ALERT_MAX_PER_HOUR` off; `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`, or `SWWAF_ALERT_MAX_PER_HOUR` off; `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`,
`SWWAF_LOOKUP_TIMEOUT`, `SWWAF_UNKNOWN_LIMIT_PERCENT`, `SWWAF_LOOKUP_TIMEOUT`, `SWWAF_UNKNOWN_LIMIT_PERCENT`,
`SWWAF_BLOCKLIST_REFRESH`, `SWWAF_REPUTATION_CACHE_TTL`, `SWWAF_BLOCKLIST_REFRESH`, `SWWAF_ABUSEIPDB_MIN_SCORE`,
`SWWAF_ABUSEIPDB_DAILY_BUDGET`, `SWWAF_REPUTATION_CACHE_TTL`,
`SWWAF_REPUTATION_TIMEOUT`, the ban settings, the state settings, `SWWAF_REPUTATION_TIMEOUT`, the ban settings, the state settings,
`SWWAF_METRICS_TOP_N`, `SWWAF_LOG_REMOTE_BUFFER`, `SWWAF_ANOMALY_NET_V4_PREFIX` `SWWAF_METRICS_TOP_N`, `SWWAF_LOG_REMOTE_BUFFER`, `SWWAF_ANOMALY_NET_V4_PREFIX`
and `SWWAF_ANOMALY_NET_V6_PREFIX` cannot be off. and `SWWAF_ANOMALY_NET_V6_PREFIX` cannot be off.
@@ -624,8 +648,9 @@ and `SWWAF_ANOMALY_NET_V6_PREFIX` cannot be off.
Several limits are fixed rather than settings. At most 20,000 clients are kept, Several limits are fixed rather than settings. At most 20,000 clients are kept,
with their counters and history, and an IPv6 client is counted by its /64. At with their counters and history, and an IPv6 client is counted by its /64. At
most 100,000 answers from GeoJS are kept, for 7 days each, at most 20,000 most 100,000 answers from GeoJS are kept, for 7 days each, at most 20,000
anomaly counters, and at most 100,000 verdicts of the DNSBL zones, with at most anomaly counters, at most 100,000 verdicts of the DNSBL zones, with at most
1,000 queries to them under way at once. 1,000 queries to them under way at once, and at most 100,000 scores of
AbuseIPDB.
### Settings given as files ### Settings given as files
@@ -709,30 +734,31 @@ which every line has.
- `request_bytes` and `response_bytes` count body bytes. - `request_bytes` and `response_bytes` count body bytes.
- `action` is `forward` for a request passed to the app, `denied` for one - `action` is `forward` for a request passed to the app, `denied` for one
refused because its client is in `SWWAF_DENY_NETS`, in a blocklist while refused because its client is in `SWWAF_DENY_NETS`, in a blocklist while
`SWWAF_BLOCKLIST_ACTION` is `deny`, or listed by a DNSBL zone while `SWWAF_BLOCKLIST_ACTION` is `deny`, or listed by a DNSBL zone or AbuseIPDB
`SWWAF_REPUTATION_ACTION` is `deny`, `banned` for one refused because a ban while `SWWAF_REPUTATION_ACTION` is `deny`, `banned` for one refused because a
covers its client or because it matched a `ban` rule, which bans its client, ban covers its client or because it matched a `ban` rule, which bans its
`country_denied` for one refused for its client's country, `rate_limited` for client, `country_denied` for one refused for its client's country,
one that broke a rate limit and banned its client, `rule_blocked` for one a `rate_limited` for one that broke a rate limit and banned its client,
`block` rule refused, `too_large` for a request or response over its size `rule_blocked` for one a `block` rule refused, `too_large` for a request or
limit, `timed_out` for one that ran out of time, `upstream_error` when the app response over its size limit, `timed_out` for one that ran out of time,
could not be reached or its answer broke off, and `admin` for one `upstream_error` when the app could not be reached or its answer broke off,
`smallwebwaf` answered at its own endpoint. and `admin` for one `smallwebwaf` answered at its own endpoint.
- `would_action` is there in `observe` mode for a request that - `would_action` is there in `observe` mode for a request that
`SWWAF_DENY_NETS`, a ban, the country lists, a blocklist, a DNSBL zone's `SWWAF_DENY_NETS`, a ban, the country lists, a blocklist, a DNSBL zone's
verdict, a rate limit or a rule would have refused in `enforce` mode, and verdict, AbuseIPDB's score, a rate limit or a rule would have refused in
names the action that refusal would have had: `denied`, `banned`, `enforce` mode, and names the action that refusal would have had: `denied`,
`country_denied`, `rate_limited` or `rule_blocked`. `action` then names what `banned`, `country_denied`, `rate_limited` or `rule_blocked`. `action` then
was done: `forward` for a request passed to the app, and another action, such names what was done: `forward` for a request passed to the app, and another
as `too_large`, for one a size or time limit refused. action, such as `too_large`, for one a size or time limit refused.
- `limit_percent` is there for a request the rate limits count whose client a - `limit_percent` is there for a request the rate limits count whose client a
biased threshold, `SWWAF_BLOCKLIST_ACTION` for a blocklist that lists it, or biased threshold, `SWWAF_BLOCKLIST_ACTION` for a blocklist that lists it, or
`SWWAF_REPUTATION_ACTION` for a DNSBL zone whose verdict lists it, gives less `SWWAF_REPUTATION_ACTION` for a DNSBL zone whose verdict lists it or an
than the whole of the rate limits, and gives the percentage it gets, with AbuseIPDB score that is a hit, gives less than the whole of the rate limits,
`limit_percent_setting` naming the setting that gave it, such as and gives the percentage it gets, with `limit_percent_setting` naming the
`SWWAF_ASN_LIMIT_PERCENT`, or `SWWAF_ASN_LIMIT_PERCENT_URL` for the file it setting that gave it, such as `SWWAF_ASN_LIMIT_PERCENT`, or
names. `bytes_percent` and `bytes_percent_setting` are the same for the byte `SWWAF_ASN_LIMIT_PERCENT_URL` for the file it names. `bytes_percent` and
limits. Each is left out when the client gets the whole of those limits. `bytes_percent_setting` are the same for the byte limits. Each is left out
when the client gets the whole of those limits.
- `counts` gives the client's requests in the minute, the hour and the day as - `counts` gives the client's requests in the minute, the hour and the day as
the rate limits count them, this request included: in each window, those in the rate limits count them, this request included: in each window, those in
the bucket under way and a share of those in the bucket before, so a count can the bucket under way and a share of those in the bucket before, so a count can
@@ -741,11 +767,11 @@ which every line has.
health check, one from a client in `SWWAF_ALLOW_NETS` or health check, one from a client in `SWWAF_ALLOW_NETS` or
`SWWAF_RATE_LIMIT_EXEMPT_NETS`, one for a path that `SWWAF_RATE_LIMIT_EXEMPT_NETS`, one for a path that
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` exempts, and one that `SWWAF_DENY_NETS`, a `SWWAF_RATE_LIMIT_EXEMPT_PATHS` exempts, and one that `SWWAF_DENY_NETS`, a
ban, the country lists, a blocklist or a DNSBL zone's verdict refuse, or would ban, the country lists, a blocklist, a DNSBL zone's verdict or AbuseIPDB's
refuse in `observe` mode. Its `minute_bytes`, `hour_bytes` and `day_bytes` score refuse, or would refuse in `observe` mode. Its `minute_bytes`,
give the client's bytes in each window as the byte limits count them, in the `hour_bytes` and `day_bytes` give the client's bytes in each window as the
same way: for a request whose bytes they count, with its own, once it has byte limits count them, in the same way: for a request whose bytes they count,
ended; for any other, those counted before it. with its own, once it has ended; for any other, those counted before it.
- `rule_ids` is there for a request that matched rules of the rule files, and - `rule_ids` is there for a request that matched rules of the rule files, and
lists their ids in the order they matched, up to the one that refused it. lists their ids in the order they matched, up to the one that refused it.
- `limit_hit` is there for a request that broke a rate limit, or whose bytes - `limit_hit` is there for a request that broke a rate limit, or whose bytes
@@ -756,15 +782,17 @@ which every line has.
is not refused: its `action` is what it would have been otherwise, such as is not refused: its `action` is what it would have been otherwise, such as
`forward`. `forward`.
- `reputation` is there for a request whose client a blocklist or a DNSBL zone's - `reputation` is there for a request whose client a blocklist or a DNSBL zone's
verdict lists, and gives the URLs of the blocklists that list it, in the order verdict lists, or whose AbuseIPDB score is a hit, and gives the URLs of the
`SWWAF_BLOCKLIST_URLS` names them, then the zones whose verdict lists it, in blocklists that list it, in the order `SWWAF_BLOCKLIST_URLS` names them, then
the order `SWWAF_DNSBL_ZONES` names them, whatever `SWWAF_BLOCKLIST_ACTION` the zones whose verdict lists it, in the order `SWWAF_DNSBL_ZONES` names them,
and `SWWAF_REPUTATION_ACTION` say. A zone whose verdict on the client has not then `abuseipdb`, whatever `SWWAF_BLOCKLIST_ACTION` and
come yet, or was given `SWWAF_REPUTATION_CACHE_TTL` ago or more, is not named. `SWWAF_REPUTATION_ACTION` say. A zone whose verdict on the client has not come
It is left out for a client the blocklists are not checked for: one in yet, or was given `SWWAF_REPUTATION_CACHE_TTL` ago or more, is not named, nor
`SWWAF_ALLOW_NETS`, and one `SWWAF_DENY_NETS`, a ban or the country lists is AbuseIPDB for such a score. It is left out for a client the blocklists are
refuse first, or would in `observe` mode. The zones are not checked either for not checked for: one in `SWWAF_ALLOW_NETS`, and one `SWWAF_DENY_NETS`, a ban
a client a blocklist refuses, or would in `observe` mode. or the country lists refuse first, or would in `observe` mode. The zones are
not checked either for a client a blocklist refuses, nor AbuseIPDB's score for
one a blocklist or a zone refuses, or would in `observe` mode.
- `ban_expires` is there for a request that made a ban or was refused under one, - `ban_expires` is there for a request that made a ban or was refused under one,
or in `observe` mode would have been refused under one, and gives when the ban or in `observe` mode would have been refused under one, and gives when the ban
ends, in the same form as `time`, or `permanent`. ends, in the same form as `time`, or `permanent`.
@@ -835,12 +863,14 @@ it, as below. An alert is for one of these events, and is sent when
each request that ends with the count over it, in `observe` mode as in each request that ends with the count over it, in `observe` mode as in
`enforce` mode. It refuses and bans nothing. `enforce` mode. It refuses and bans nothing.
- `reputation_hit`: a request whose client a blocklist or a DNSBL zone's verdict - `reputation_hit`: a request whose client a blocklist or a DNSBL zone's verdict
lists, one alert for each blocklist and each zone that lists it, whatever lists, or whose AbuseIPDB score is a hit, one alert for each blocklist and
each zone that lists it, and one for AbuseIPDB, whatever
`SWWAF_BLOCKLIST_ACTION` and `SWWAF_REPUTATION_ACTION` say, in `observe` mode `SWWAF_BLOCKLIST_ACTION` and `SWWAF_REPUTATION_ACTION` say, in `observe` mode
as in `enforce` mode. as in `enforce` mode.
- `source_failure`: GeoJS failing or refusing `smallwebwaf`, a fetch of a list - `source_failure`: GeoJS failing or refusing `smallwebwaf`, a fetch of a list
failing (see "Blocklists" below), or a query to a DNSBL zone failing or failing (see "Blocklists" below), a query to a DNSBL zone failing or refused
refused (see "DNS blocklists" below). (see "DNS blocklists" below), or a check with AbuseIPDB failing or refused, or
the check that uses up the day's AbuseIPDB checks (see "AbuseIPDB" below).
- `file_error`: a rule file edited while it runs that has an error, an edit of a - `file_error`: a rule file edited while it runs that has an error, an edit of a
state file set aside as `<name>.bad`, a state file it could not write while state file set aside as `<name>.bad`, a state file it could not write while
running, or a replacement of the lookup database it could not read, which it running, or a replacement of the lookup database it could not read, which it
@@ -913,7 +943,8 @@ is sent on one line:
- `reason` is a short sentence; for a ban, the ban's `reason` in `bans.json`; - `reason` is a short sentence; for a ban, the ban's `reason` in `bans.json`;
for an `anomaly`, what was counted over which threshold, such as for an `anomaly`, what was counted over which threshold, such as
`requests per minute of the netblock 203.0.113.0/24 over the threshold of 1000`; `requests per minute of the netblock 203.0.113.0/24 over the threshold of 1000`;
for a `reputation_hit`, `listed by a blocklist` or `listed by a DNSBL zone`. for a `reputation_hit`, `listed by a blocklist`, `listed by a DNSBL zone` or
`scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE`.
- `detail` is what is particular to the event: for a ban, its `cause`, when it - `detail` is what is particular to the event: for a ban, its `cause`, when it
ends as `ban_expires`, in the form the request log gives it, and its `notes`, ends as `ban_expires`, in the form the request log gives it, and its `notes`,
as `bans.json` gives them; for an `anomaly`, the `scope`, `client`, `net`, as `bans.json` gives them; for an `anomaly`, the `scope`, `client`, `net`,
@@ -921,11 +952,12 @@ is sent on one line:
`asn` and the `name` of the named netblock for `watch`, the `window`, `minute` `asn` and the `name` of the named netblock for `watch`, the `window`, `minute`
or `hour`, the `kind`, `requests` or `bytes`, the `count`, which is weighted or `hour`, the `kind`, `requests` or `bytes`, the `count`, which is weighted
as the rate limits weigh theirs, and the `threshold`; for a `reputation_hit`, as the rate limits weigh theirs, and the `threshold`; for a `reputation_hit`,
the `source`, the URL of the blocklist or the zone; for `source_failure`, the the `source`, the URL of the blocklist, the zone or `abuseipdb`, and for
`source`, `geojs`, the URL of the list or the zone, the `error`, and for AbuseIPDB the `score`; for `source_failure`, the `source`, `geojs`, the URL of
GeoJS, when it is asked again, `asking_again_in`; for `file_error`, the the list, the zone or `abuseipdb`, the `error`, and for GeoJS, when it is
`file`, which for an edit set aside is the file it was renamed to, and the asked again, `asking_again_in`; for `file_error`, the `file`, which for an
`error`, which for a file that does not parse names where in it the error is. edit set aside is the file it was renamed to, and the `error`, which for a
file that does not parse names where in it the error is.
- `suppressed_repeats` is how many repeats the cooldown held back before this - `suppressed_repeats` is how many repeats the cooldown held back before this
alert, and for a `summary`, those no other alert gives (see below). alert, and for a `summary`, those no other alert gives (see below).
@@ -971,14 +1003,14 @@ and Slack this JSON object, shown indented; it is sent on one line:
``` ```
An alert for the same event as the last one sent, on the same netblock, and for An alert for the same event as the last one sent, on the same netblock, and for
a `reputation_hit` about the same blocklist or zone, or for a `file_error` about a `reputation_hit` about the same blocklist, zone or AbuseIPDB, or for a
the same file, or for a `source_failure` about the same source, or for an `file_error` about the same file, or for a `source_failure` about the same
`anomaly` in the same scope, with the same netblock, AS number or name, whatever source, or for an `anomaly` in the same scope, with the same netblock, AS number
its window and kind, less than `SWWAF_ALERT_COOLDOWN` after it, is a repeat: it or name, whatever its window and kind, less than `SWWAF_ALERT_COOLDOWN` after
is held back and counted, and the next alert sent for them gives that count as it, is a repeat: it is held back and counted, and the next alert sent for them
`suppressed_repeats`. As each hour of the clock, in UTC, ends, the cooldowns gives that count as `suppressed_repeats`. As each hour of the clock, in UTC,
that have run out are dropped, and the repeats they held back, which no alert ends, the cooldowns that have run out are dropped, and the repeats they held
sent since has given, go in that hour's summary. back, which no alert sent since has given, go in that hour's summary.
Past `SWWAF_ALERT_MAX_PER_HOUR` alerts in an hour, the hour's other alerts are Past `SWWAF_ALERT_MAX_PER_HOUR` alerts in an hour, the hour's other alerts are
held back and counted by event. An alert held back this way starts no cooldown. held back and counted by event. An alert held back this way starts no cooldown.
@@ -1043,8 +1075,12 @@ with times in UTC.
left out while no fetch of it has succeeded; and under `verdicts`, each left out while no fetch of it has succeeded; and under `verdicts`, each
verdict of a DNSBL zone still in use (see "DNS blocklists" below): its `zone`, verdict of a DNSBL zone still in use (see "DNS blocklists" below): its `zone`,
the `client`'s address, whether the zone `listed` the client, and when the the `client`'s address, whether the zone `listed` the client, and when the
zone gave it, `fetched`. As the file is read, the lists the settings no longer zone gave it, `fetched`; and under `abuseipdb` (see "AbuseIPDB" below), the
name, and the verdicts of the zones they no longer name, are dropped. `day`, in UTC, whose checks it counts, left out before the first, the checks
`spent` that day, and under `scores`, each score of AbuseIPDB still in use:
the `client`, its IPv4 address as a /32 or its IPv6 /64, its `score`, and when
AbuseIPDB gave it, `fetched`. As the file is read, the lists the settings no
longer name, and the verdicts of the zones they no longer name, are dropped.
- `alerts.json`: the state of the alerts (see "Alerts" above), indented to be - `alerts.json`: the state of the alerts (see "Alerts" above), indented to be
read: under `cooldowns`, for each event and netblock, with the `source` too read: under `cooldowns`, for each event and netblock, with the `source` too
for a `reputation_hit`, or event and `file` or `source`, or for an `anomaly`, for a `reputation_hit`, or event and `file` or `source`, or for an `anomaly`,
@@ -1298,6 +1334,13 @@ scraped, and keeps this one as `exported_instance` unless the scrape sets
verdict lists, a series that comes with the first; verdict lists, a series that comes with the first;
`smallwebwaf_reputation_queries_total`: the queries made to the zone; and `smallwebwaf_reputation_queries_total`: the queries made to the zone; and
`smallwebwaf_reputation_failures_total`: those that failed. `smallwebwaf_reputation_failures_total`: those that failed.
- While `SWWAF_ABUSEIPDB_KEY` is set, by `source`, `abuseipdb`:
`smallwebwaf_reputation_hits_total`: the requests whose client's AbuseIPDB
score is a hit, a series that comes with the first;
`smallwebwaf_reputation_queries_total`: the checks made, each of which spends
one of the day's budget; `smallwebwaf_reputation_failures_total`: those that
failed; and `smallwebwaf_reputation_daily_budget_remaining`: the checks the
day's `SWWAF_ABUSEIPDB_DAILY_BUDGET` has left.
- `smallwebwaf_tracked_clients`: the clients in the table of clients. - `smallwebwaf_tracked_clients`: the clients in the table of clients.
- `smallwebwaf_state_file_writes_total`, - `smallwebwaf_state_file_writes_total`,
`smallwebwaf_state_file_write_failures_total`, `smallwebwaf_state_file_write_failures_total`,
@@ -1490,8 +1533,9 @@ goes through the candidates one by one.
nothing. Edit a file, or add a rule file, and the running `smallwebwaf` picks nothing. Edit a file, or add a rule file, and the running `smallwebwaf` picks
up the change. Nothing is read from disk while serving a request. The files up the change. Nothing is read from disk while serving a request. The files
for the bans, the clients, the GeoJS answers, the copies of the lists, the for the bans, the clients, the GeoJS answers, the copies of the lists, the
verdicts of the DNSBL zones and the alerts are built, with an edit taken in verdicts of the DNSBL zones, AbuseIPDB's scores and the alerts are built, with
while running (see "State files" above); the rest comes with its features. an edit taken in while running (see "State files" above); the rest comes with
its features.
- Health checks, the metrics, and listing, adding and lifting bans or asking why - Health checks, the metrics, and listing, adding and lifting bans or asking why
a given address was refused, all on the one port every request uses: under a given address was refused, all on the one port every request uses: under
`/_smallwebwaf/` on the app's own address, through traefik like any other `/_smallwebwaf/` on the app's own address, through traefik like any other
@@ -1771,6 +1815,57 @@ with its key. A key in the name of any other zone is shown as given. Several
zones refuse queries that come through a public resolver; `SWWAF_DNSBL_RESOLVER` zones refuse queries that come through a public resolver; `SWWAF_DNSBL_RESOLVER`
names another resolver to ask through. names another resolver to ask through.
## AbuseIPDB
While `SWWAF_ABUSEIPDB_KEY` holds the key of an AbuseIPDB account, `smallwebwaf`
asks AbuseIPDB's check endpoint, `https://api.abuseipdb.com/api/v2/check`, for
the abuse confidence score of a client's own address, from 0 to 100. It is unset
by default, for the reason no blocklist is named, and since AbuseIPDB needs an
account. An IPv6 client, a /64, is checked by the address of the request that
has it checked, and its score is used for the whole /64, whichever of its
addresses sends, so that one client costs at most one check every
`SWWAF_REPUTATION_CACHE_TTL`.
Only a client whose history counts an offence is checked, so that the checks are
spent on suspects: so far, one that has broken a rate limit or a byte limit,
matched a ban rule, or had a request refused by a block rule. A client dropped
from the table of clients loses its history, and with it its offences. A client
is checked in the background, at its first request after its offence that
reaches the check: no request waits, a request refused under its ban is not
checked, and the request that has it checked, and any other from it before the
answer comes, goes on as from a client without a score.
A score at or over `SWWAF_ABUSEIPDB_MIN_SCORE`, 75 by default, is a hit, and
`SWWAF_REPUTATION_ACTION` says what is done with its client, as for a DNSBL
zone's verdict (see "What it does so far" above). Each score, a hit or not, is
kept, in memory and in `reputation.json` (see "State files" above), and used for
`SWWAF_REPUTATION_CACHE_TTL` after AbuseIPDB gave it, 24 hours by default. The
client's first request after that has it checked again, if its history still
counts an offence. At most 100,000 scores are kept, the one fetched longest ago
dropped first.
At most `SWWAF_ABUSEIPDB_DAILY_BUDGET` checks are made in a day, 900 by default,
below the 1,000 of AbuseIPDB's free accounts. The day is counted in UTC, from
00:00. Each check sent spends one of them, whatever AbuseIPDB answers, and the
checks spent today are kept in `reputation.json`, so that a restart does not
make the budget whole again. The check that spends the last of the day's budget
is logged and raised as a `source_failure` alert; from then until the day ends,
no client is checked, and a client without a score counts as one AbuseIPDB does
not list.
A check fails when AbuseIPDB answers other than `200`, such as `429` past its
own daily limit or `401` for a wrong key, when its answer gives no
`abuseConfidenceScore`, and when it does not answer within
`SWWAF_REPUTATION_TIMEOUT`. A failure gives no score, and is counted, logged and
raised as a `source_failure` alert, held back as a repeat within
`SWWAF_ALERT_COOLDOWN`, and no client is checked for a minute after it.
The key is sent to AbuseIPDB in the `Key` header, and nowhere else. The settings
logged at start show `********` in its place, and neither the log, the alerts,
the metrics nor `reputation.json` hold it. Given as a file, with
`SWWAF_ABUSEIPDB_KEY_FILE`, it can be kept out of the app's reach (see "Settings
given as files" above).
## How the code is laid out ## How the code is laid out
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`. - `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
@@ -1785,14 +1880,14 @@ names another resolver to ask through.
limits, and writes the request's log line. Its `check` method is where a limits, and writes the request's log line. Its `check` method is where a
request is refused before anything reaches the app: for `SWWAF_DENY_NETS`, for request is refused before anything reaches the app: for `SWWAF_DENY_NETS`, for
a ban, for the country lists, for a blocklist, for a DNSBL zone's verdict, for a ban, for the country lists, for a blocklist, for a DNSBL zone's verdict, for
a rate limit, which bans the client, for a `block` or `ban` rule, the latter AbuseIPDB's score, for a rate limit, which bans the client, for a `block` or
banning the client, and for an announced body over the size limit; in `ban` rule, the latter banning the client, and for an announced body over the
`observe` mode, only for the size limit, with what it would have refused for size limit; in `observe` mode, only for the size limit, with what it would
noted in the log line. A request under `/_smallwebwaf/` that `check` lets have refused for noted in the log line. A request under `/_smallwebwaf/` that
through is answered by `answerAdmin` instead of reaching the app. Once the `check` lets through is answered by `answerAdmin` instead of reaching the app.
answer to a request passed to the app has ended, `countBytes` counts its bytes Once the answer to a request passed to the app has ended, `countBytes` counts
for the byte limits, and once any request but the health check has ended, its bytes for the byte limits, and once any request but the health check has
`countAnomalies` counts it for the anomaly thresholds. ended, `countAnomalies` counts it for the anomaly thresholds.
- `internal/metrics`: the metrics, counted as the other parts tell it what - `internal/metrics`: the metrics, counted as the other parts tell it what
happened, and served in the Prometheus text format. happened, and served in the Prometheus text format.
- `internal/bans`: the ban ledger: each netblock's bans with their notes, how - `internal/bans`: the ban ledger: each netblock's bans with their notes, how
@@ -1808,9 +1903,11 @@ names another resolver to ask through.
- `internal/reputation`: fetches the blocklists and the file - `internal/reputation`: fetches the blocklists and the file
`SWWAF_ASN_LIMIT_PERCENT_URL` names as they are due, keeps the last good copy `SWWAF_ASN_LIMIT_PERCENT_URL` names as they are due, keeps the last good copy
of each, and tells which blocklists list an address and what percentage the of each, and tells which blocklists list an address and what percentage the
file gives an AS number; and asks the DNSBL zones about clients in the file gives an AS number; asks the DNSBL zones about clients in the background,
background, through the standard library's resolver, keeps their verdicts, and through the standard library's resolver, keeps their verdicts, and tells which
tells which zones' verdicts list an address. zones' verdicts list an address; and checks clients with AbuseIPDB in the
background, keeps their scores and the checks spent today, and tells whether a
client's score is a hit.
- `internal/ratelimit`: the table of clients: counts each client's requests and - `internal/ratelimit`: the table of clients: counts each client's requests and
bytes, tells when they take it over a rate limit or a byte limit, and keeps bytes, tells when they take it over a rate limit or a byte limit, and keeps
each client's history. each client's history.
@@ -1838,11 +1935,11 @@ names another resolver to ask through.
Besides the Go standard library, `github.com/hashicorp/golang-lru/v2` keeps the Besides the Go standard library, `github.com/hashicorp/golang-lru/v2` keeps the
table of clients to 20,000 and the GeoJS answers to 100,000, dropping the least table of clients to 20,000 and the GeoJS answers to 100,000, dropping the least
recently seen, the DNSBL zones' verdicts to 100,000, dropping the one fetched recently seen, the DNSBL zones' verdicts and AbuseIPDB's scores to 100,000 each,
longest ago, the anomaly counters to 20,000, dropping the one counted least dropping the one fetched longest ago, the anomaly counters to 20,000, dropping
recently, and the banned netblocks in the order they were last seen, from which the one counted least recently, and the banned netblocks in the order they were
the ledger picks the ban to drop past `SWWAF_MAX_BANS`, and last seen, from which the ledger picks the ban to drop past `SWWAF_MAX_BANS`,
`github.com/prometheus/client_golang` keeps the metrics and serves them, and and `github.com/prometheus/client_golang` keeps the metrics and serves them, and
`github.com/fsnotify/fsnotify` tells `smallwebwaf` when a state file or a rule `github.com/fsnotify/fsnotify` tells `smallwebwaf` when a state file or a rule
file is saved, or the lookup database replaced, and file is saved, or the lookup database replaced, and
`github.com/oschwald/maxminddb-golang/v2` reads the lookup database, which the `github.com/oschwald/maxminddb-golang/v2` reads the lookup database, which the
+3 -2
View File
@@ -47,10 +47,11 @@ const (
// EventWAFBlock comes with the Core Rule Set; nothing raises it yet. // EventWAFBlock comes with the Core Rule Set; nothing raises it yet.
EventWAFBlock = "waf_block" EventWAFBlock = "waf_block"
// EventReputationHit is a request whose client a blocklist or a DNSBL // EventReputationHit is a request whose client a blocklist or a DNSBL
// zone lists. // zone lists, or whose AbuseIPDB score is a hit.
EventReputationHit = "reputation_hit" EventReputationHit = "reputation_hit"
// EventSourceFailure is GeoJS failing or refusing smallwebwaf, a fetch // EventSourceFailure is GeoJS failing or refusing smallwebwaf, a fetch
// of a list failing, or a query to a DNSBL zone failing or refused. // of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
// failing or refused, or the day's AbuseIPDB checks used up.
EventSourceFailure = "source_failure" EventSourceFailure = "source_failure"
// EventFileError is a rule file or state file edited while smallwebwaf // EventFileError is a rule file or state file edited while smallwebwaf
// runs that does not parse, a replacement of the lookup database that // runs that does not parse, a replacement of the lookup database that
+21 -10
View File
@@ -155,14 +155,22 @@ type Config struct {
// DNSBLZones are the DNSBL zones clients are asked about // DNSBLZones are the DNSBL zones clients are asked about
// (SWWAF_DNSBL_ZONES), through DNSBLResolver (SWWAF_DNSBL_RESOLVER), or // (SWWAF_DNSBL_ZONES), through DNSBLResolver (SWWAF_DNSBL_RESOLVER), or
// the host's resolver while that is the zero AddrPort. // the host's resolver while that is the zero AddrPort.
// ReputationAction is what is done with a client a zone's verdict lists // AbuseIPDBKey is the key of the AbuseIPDB account clients are checked
// (SWWAF_REPUTATION_ACTION): deny, limit or log; for limit, // with (SWWAF_ABUSEIPDB_KEY), "" while it is unset and none is. A score
// ReputationLimitPercent is the percentage of every limit it gets. A // of AbuseIPDBMinScore or more is a hit (SWWAF_ABUSEIPDB_MIN_SCORE), and
// verdict is used for ReputationCacheTTL after it was fetched // at most AbuseIPDBDailyBudget checks are made a day
// (SWWAF_REPUTATION_CACHE_TTL), and a query may take ReputationTimeout // (SWWAF_ABUSEIPDB_DAILY_BUDGET).
// (SWWAF_REPUTATION_TIMEOUT). Neither can be off. // ReputationAction is what is done with a client a zone's verdict lists,
// or whose score is a hit (SWWAF_REPUTATION_ACTION): deny, limit or log;
// for limit, ReputationLimitPercent is the percentage of every limit it
// gets. A verdict or a score is used for ReputationCacheTTL after it was
// fetched (SWWAF_REPUTATION_CACHE_TTL), and a query or a check may take
// ReputationTimeout (SWWAF_REPUTATION_TIMEOUT). Neither can be off.
DNSBLZones []string DNSBLZones []string
DNSBLResolver netip.AddrPort DNSBLResolver netip.AddrPort
AbuseIPDBKey string
AbuseIPDBMinScore int64
AbuseIPDBDailyBudget int
ReputationAction string ReputationAction string
ReputationLimitPercent int64 ReputationLimitPercent int64
ReputationCacheTTL time.Duration ReputationCacheTTL time.Duration
@@ -440,6 +448,9 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"), BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"),
DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"), DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"),
DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"), DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"),
AbuseIPDBKey: env.secret("SWWAF_ABUSEIPDB_KEY"),
AbuseIPDBMinScore: env.percent("SWWAF_ABUSEIPDB_MIN_SCORE", "75"),
AbuseIPDBDailyBudget: env.numberNotOff("SWWAF_ABUSEIPDB_DAILY_BUDGET", "900"),
ReputationCacheTTL: env.durationNotOff("SWWAF_REPUTATION_CACHE_TTL", "24h"), ReputationCacheTTL: env.durationNotOff("SWWAF_REPUTATION_CACHE_TTL", "24h"),
ReputationTimeout: env.durationNotOff("SWWAF_REPUTATION_TIMEOUT", "2s"), ReputationTimeout: env.durationNotOff("SWWAF_REPUTATION_TIMEOUT", "2s"),
BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"), BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"),
@@ -1110,10 +1121,10 @@ func (e *environment) webhookHeaders(name string) http.Header {
} }
// secret reads a setting that is a secret another service gave, such as // secret reads a setting that is a secret another service gave, such as
// an ntfy token, "" while it is unset. It is sent in a header, which // an ntfy token or an AbuseIPDB key, "" while it is unset. It is sent in
// cannot hold a control character, so one in it is an error. The log // a header, which cannot hold a control character, so one in it is an
// shows ******** in place of a value that is not empty, and an error // error. The log shows ******** in place of a value that is not empty, and
// shows none of it. // an error shows none of it.
func (e *environment) secret(name string) string { func (e *environment) secret(name string) string {
value, _ := e.lookup(name) value, _ := e.lookup(name)
+76
View File
@@ -63,6 +63,9 @@ const (
blocklistAction = "SWWAF_BLOCKLIST_ACTION" blocklistAction = "SWWAF_BLOCKLIST_ACTION"
dnsblZones = "SWWAF_DNSBL_ZONES" dnsblZones = "SWWAF_DNSBL_ZONES"
dnsblResolver = "SWWAF_DNSBL_RESOLVER" dnsblResolver = "SWWAF_DNSBL_RESOLVER"
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE"
abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET"
reputationAction = "SWWAF_REPUTATION_ACTION" reputationAction = "SWWAF_REPUTATION_ACTION"
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL" reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL"
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT" reputationTimeout = "SWWAF_REPUTATION_TIMEOUT"
@@ -1526,6 +1529,76 @@ func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
} }
} }
func TestAbuseIPDBSettingsAsSet(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{})
if cfg.AbuseIPDBKey != "" || cfg.AbuseIPDBMinScore != 75 ||
cfg.AbuseIPDBDailyBudget != 900 {
t.Errorf("by default, the key %q, the minimum score %d and the daily budget %d, "+
"want none, 75 and 900", cfg.AbuseIPDBKey, cfg.AbuseIPDBMinScore,
cfg.AbuseIPDBDailyBudget)
}
cfg = fromEnvironment(t, environment{
abuseIPDBKey: token, abuseIPDBMinScore: "0", abuseIPDBDailyBudget: "1",
})
if cfg.AbuseIPDBKey != token || cfg.AbuseIPDBMinScore != 0 ||
cfg.AbuseIPDBDailyBudget != 1 {
t.Errorf("set, the key %q, the minimum score %d and the daily budget %d, "+
"want %s, 0 and 1", cfg.AbuseIPDBKey, cfg.AbuseIPDBMinScore,
cfg.AbuseIPDBDailyBudget, token)
}
}
func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Parallel()
const (
notScore = " is not a percentage, a whole number from 0 to 100"
notBudget = " is not a whole number above zero, such as 5000"
)
for _, tc := range []struct{ name, value, want string }{
{abuseIPDBMinScore, "101", `"101"` + notScore},
{abuseIPDBMinScore, off, `"off"` + notScore},
{abuseIPDBDailyBudget, "0", `"0"` + notBudget},
{abuseIPDBDailyBudget, off, `"off"` + notBudget},
// The key itself is never shown.
{
abuseIPDBKey, token + "\r",
"holds a control character, such as the carriage return of a Windows line end",
},
} {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
}
}
func TestAbuseIPDBKeyIsLoggedMasked(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{abuseIPDBKey: token})
var out bytes.Buffer
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
logged := out.String()
if strings.Contains(logged, token) ||
!strings.Contains(logged, `"`+abuseIPDBKey+`":"********"`) {
t.Errorf("the key is not logged masked: %s", logged)
}
}
func TestSizesAndOff(t *testing.T) { func TestSizesAndOff(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1956,6 +2029,9 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
blocklistAction: actionDeny, blocklistAction: actionDeny,
dnsblZones: "", dnsblZones: "",
dnsblResolver: "", dnsblResolver: "",
abuseIPDBKey: "",
abuseIPDBMinScore: "75",
abuseIPDBDailyBudget: "900",
reputationAction: "limit:25", reputationAction: "limit:25",
reputationCacheTTL: defaultReputationCacheTTL, reputationCacheTTL: defaultReputationCacheTTL,
reputationTimeout: "2s", reputationTimeout: "2s",
+62 -36
View File
@@ -257,57 +257,37 @@ func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() i
) )
} }
// sourceLabel is the label of the reputation metrics: a list's URL, a
// DNSBL zone, its key masked, or abuseipdb.
const sourceLabel = "source"
// AddReputation adds the metrics of the lists fetched from URLs and of the // AddReputation adds the metrics of the lists fetched from URLs and of the
// DNSBL zones, by source, each list's URL or each zone, its key masked as // DNSBL zones, by source, each list's URL or each zone, its key masked as
// config.MaskZoneKey masks it: the requests whose client a blocklist or a // config.MaskZoneKey masks it: the requests whose client a blocklist, a
// zone's verdict lists, which ReputationHit counts, and, read from lists // zone's verdict or AbuseIPDB's score lists, which ReputationHit counts,
// and dnsbl as the metrics are asked for, for a list, the fetches that // and, read from lists and dnsbl as the metrics are asked for, for a list,
// failed and when the copy in use was fetched, and for a zone, the queries // the fetches that failed and when the copy in use was fetched, and for a
// made and those that failed. It is called once, before ReputationHit. // zone, the queries made and those that failed. It is called once, before
// ReputationHit.
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) { func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
const (
sourceLabel = "source"
failuresHelp = "Fetches of the list, or queries to the DNSBL zone, that failed."
)
m.reputationHits = counterVec("smallwebwaf_reputation_hits_total", m.reputationHits = counterVec("smallwebwaf_reputation_hits_total",
"Requests whose client a blocklist or a DNSBL zone lists, by the "+ "Requests whose client a blocklist, a DNSBL zone or AbuseIPDB lists, by "+
"blocklist's URL or the zone.", "the blocklist's URL, the zone, or abuseipdb.",
[]string{sourceLabel}) []string{sourceLabel})
m.registry.MustRegister(m.reputationHits) m.registry.MustRegister(m.reputationHits)
for _, zone := range dnsbl.Zones() { for _, zone := range dnsbl.Zones() {
source := prometheus.Labels{sourceLabel: config.MaskZoneKey(zone)} source := prometheus.Labels{sourceLabel: config.MaskZoneKey(zone)}
m.registry.MustRegister( m.addReputationQueries(source, func() int { return dnsbl.Queries(zone) })
prometheus.NewCounterFunc(prometheus.CounterOpts{ m.addReputationFailures(source, func() int { return dnsbl.Failures(zone) })
Name: "smallwebwaf_reputation_queries_total",
Help: "Queries to the DNSBL zone.",
ConstLabels: source,
}, func() float64 {
return float64(dnsbl.Queries(zone))
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_reputation_failures_total",
Help: failuresHelp,
ConstLabels: source,
}, func() float64 {
return float64(dnsbl.Failures(zone))
}),
)
} }
for _, listURL := range lists.URLs() { for _, listURL := range lists.URLs() {
source := prometheus.Labels{sourceLabel: listURL} source := prometheus.Labels{sourceLabel: listURL}
m.addReputationFailures(source, func() int { return lists.Failures(listURL) })
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_reputation_failures_total",
Help: failuresHelp,
ConstLabels: source,
}, func() float64 {
return float64(lists.Failures(listURL))
}),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_reputation_last_fetch_timestamp_seconds", Name: "smallwebwaf_reputation_last_fetch_timestamp_seconds",
Help: "When the copy of the list in use was fetched, in seconds since " + Help: "When the copy of the list in use was fetched, in seconds since " +
@@ -325,8 +305,28 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
} }
} }
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
// read from abuseIPDB as the metrics are asked for: the checks made, those
// that failed, and how many checks the day's budget has left. It is
// called once, after AddReputation, while SWWAF_ABUSEIPDB_KEY is set.
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
m.addReputationQueries(source, abuseIPDB.Checked)
m.addReputationFailures(source, abuseIPDB.Failures)
m.registry.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_reputation_daily_budget_remaining",
Help: "Checks of the day's SWWAF_ABUSEIPDB_DAILY_BUDGET not yet spent.",
ConstLabels: source,
}, func() float64 {
return float64(abuseIPDB.BudgetLeft())
}),
)
}
// ReputationHit counts a request whose client source lists: a blocklist, // ReputationHit counts a request whose client source lists: a blocklist,
// by its URL, or a DNSBL zone, its key masked. // by its URL, a DNSBL zone, its key masked, or AbuseIPDB, abuseipdb.
func (m *Metrics) ReputationHit(source string) { func (m *Metrics) ReputationHit(source string) {
m.reputationHits.WithLabelValues(source).Inc() m.reputationHits.WithLabelValues(source).Inc()
} }
@@ -470,6 +470,32 @@ func (m *Metrics) StateFileEditSetAside(name string) {
m.stateFileEditsSetAside.WithLabelValues(name).Inc() m.stateFileEditsSetAside.WithLabelValues(name).Inc()
} }
// addReputationQueries adds the counter of the queries to source, a DNSBL
// zone, or of the checks of clients with AbuseIPDB, which count tells.
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_reputation_queries_total",
Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
ConstLabels: source,
}, func() float64 {
return float64(count())
}))
}
// addReputationFailures adds the counter of the fetches of source, a
// list, the queries to it, a DNSBL zone, or the checks with it, AbuseIPDB,
// that failed, which count tells.
func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) {
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_reputation_failures_total",
Help: "Fetches of the list, queries to the DNSBL zone, or checks with " +
"AbuseIPDB, that failed.",
ConstLabels: source,
}, func() float64 {
return float64(count())
}))
}
// statusClass returns the class of status, such as 2xx, or none when no // statusClass returns the class of status, such as 2xx, or none when no
// status was sent. // status was sent.
func statusClass(status int) string { func statusClass(status int) string {
+14 -13
View File
@@ -28,18 +28,19 @@ func biasedThresholdsSet(cfg *config.Config) bool {
// limitPercentages returns the client's limit percentages, for the rate // limitPercentages returns the client's limit percentages, for the rate
// limits and for the byte limits, by its AS number and country as looked // limits and for the byte limits, by its AS number and country as looked
// up, each "" when unknown, and the blocklists and DNSBL zones that list // up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
// it. Each is the lowest of those the settings give it, the first of them // that list it. Each is the lowest of those the settings give it, the
// in the order below when several are lowest: the percentage // first of them in the order below when several are lowest: the
// SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file // percentage SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file
// SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one // SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one
// SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a // SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
// country, SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, // country, SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists,
// the percentage of SWWAF_BLOCKLIST_ACTION while it is limit, and for a // the percentage of SWWAF_BLOCKLIST_ACTION while it is limit, and for a
// client a DNSBL zone's verdict lists, the percentage of // client a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit,
// SWWAF_REPUTATION_ACTION while it is limit. For the byte limits, // the percentage of SWWAF_REPUTATION_ACTION while it is limit. For the
// SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT take the place // byte limits, SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT
// of the first three for an AS number or a country they list. // take the place of the first three for an AS number or a country they
// list.
func (rq *request) limitPercentages() (percentage, percentage) { func (rq *request) limitPercentages() (percentage, percentage) {
cfg := rq.h.config cfg := rq.h.config
asn, country := rq.line.ASN, rq.line.Country asn, country := rq.line.ASN, rq.line.Country
@@ -59,9 +60,9 @@ func (rq *request) limitPercentages() (percentage, percentage) {
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWWAF_BLOCKLIST_ACTION"} blocklisted = percentage{cfg.BlocklistLimitPercent, "SWWAF_BLOCKLIST_ACTION"}
} }
dnsblListed := percentage{percent: whole} reputationListed := percentage{percent: whole}
if rq.dnsblListed && cfg.ReputationAction == "limit" { if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
dnsblListed = percentage{cfg.ReputationLimitPercent, "SWWAF_REPUTATION_ACTION"} reputationListed = percentage{cfg.ReputationLimitPercent, "SWWAF_REPUTATION_ACTION"}
} }
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWWAF_ASN_LIMIT_PERCENT"), asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWWAF_ASN_LIMIT_PERCENT"),
@@ -78,8 +79,8 @@ func (rq *request) limitPercentages() (percentage, percentage) {
countryBytes = given(cfg.CountryBytesPercent, country, "SWWAF_COUNTRY_BYTES_PERCENT") countryBytes = given(cfg.CountryBytesPercent, country, "SWWAF_COUNTRY_BYTES_PERCENT")
} }
return lowest(asnRequests, countryRequests, unknown, blocklisted, dnsblListed), return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
lowest(asnBytes, countryBytes, unknown, blocklisted, dnsblListed) lowest(asnBytes, countryBytes, unknown, blocklisted, reputationListed)
} }
// given returns the percentage percents, the setting named setting, gives // given returns the percentage percents, the setting named setting, gives
+1
View File
@@ -349,6 +349,7 @@ const unansweredGeoJSURL = "unanswered://geojs/v1/ip/geo.json"
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
transport, _ := http.DefaultTransport.(*http.Transport) transport, _ := http.DefaultTransport.(*http.Transport)
transport.RegisterProtocol("unanswered", unansweredGeoJS{}) transport.RegisterProtocol("unanswered", unansweredGeoJS{})
transport.RegisterProtocol("abuseipdb", abuseIPDBStandIn{})
m.Run() m.Run()
} }
+33 -10
View File
@@ -59,6 +59,9 @@ type Params struct {
// GeoJSURL is where clients' AS numbers and countries are looked up // GeoJSURL is where clients' AS numbers and countries are looked up
// while SWWAF_LOOKUP_SOURCE is geojs, normally lookup.URL. // while SWWAF_LOOKUP_SOURCE is geojs, normally lookup.URL.
GeoJSURL string GeoJSURL string
// AbuseIPDBURL is where clients are checked with AbuseIPDB while
// SWWAF_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL.
AbuseIPDBURL string
// LookupFile is the lookup database they are looked up in while // LookupFile is the lookup database they are looked up in while
// SWWAF_LOOKUP_SOURCE is file, and nil otherwise. // SWWAF_LOOKUP_SOURCE is file, and nil otherwise.
LookupFile *lookup.File LookupFile *lookup.File
@@ -71,15 +74,17 @@ type Params struct {
Rules *rules.Files Rules *rules.Files
// Alerts receive the alert for each ban the proxy makes or makes // Alerts receive the alert for each ban the proxy makes or makes
// permanent, for each count over an anomaly threshold, for each request // permanent, for each count over an anomaly threshold, for each request
// whose client a blocklist or a DNSBL zone lists, and for GeoJS failing, // whose client a blocklist, a DNSBL zone or AbuseIPDB lists, and for
// a fetch of a list failing or a query to a DNSBL zone failing. // GeoJS failing, a fetch of a list failing, a query to a DNSBL zone or
// a check with AbuseIPDB failing, or the day's AbuseIPDB checks used up.
Alerts *alerts.Queue Alerts *alerts.Queue
} }
// Server is the server smallwebwaf runs, with the parts of the proxy // Server is the server smallwebwaf runs, with the parts of the proxy
// whose state the state files keep, the lookup database, nil unless // whose state the state files keep, the lookup database, nil unless
// SWWAF_LOOKUP_SOURCE is file, the lists fetched from URLs, which its Run // SWWAF_LOOKUP_SOURCE is file, the lists fetched from URLs, which its Run
// fetches, the DNSBL zones' verdicts, and the metrics. // fetches, the DNSBL zones' verdicts, AbuseIPDB's scores and checks
// spent, and the metrics.
type Server struct { type Server struct {
*http.Server *http.Server
@@ -90,6 +95,7 @@ type Server struct {
LookupFile *lookup.File LookupFile *lookup.File
Lists *reputation.Lists Lists *reputation.Lists
DNSBL *reputation.DNSBL DNSBL *reputation.DNSBL
AbuseIPDB *reputation.AbuseIPDB
Metrics *metrics.Metrics Metrics *metrics.Metrics
} }
@@ -102,7 +108,7 @@ type Server struct {
func New(params Params) *Server { func New(params Params) *Server {
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn) errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName) m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
lists, dnsbl := newReputation(params) lists, dnsbl, abuseIPDB := newReputation(params, m)
h := &handler{ h := &handler{
config: params.Config, config: params.Config,
requestLog: params.RequestLog, requestLog: params.RequestLog,
@@ -140,6 +146,7 @@ func New(params Params) *Server {
lookupFile: params.LookupFile, lookupFile: params.LookupFile,
lists: lists, lists: lists,
dnsbl: dnsbl, dnsbl: dnsbl,
abuseIPDB: abuseIPDB,
rules: params.Rules, rules: params.Rules,
alerts: params.Alerts, alerts: params.Alerts,
} }
@@ -159,7 +166,6 @@ func New(params Params) *Server {
}) })
m.AddBansAndClients(h.ledger, h.limiter, params.Now) m.AddBansAndClients(h.ledger, h.limiter, params.Now)
m.AddRules(params.Rules) m.AddRules(params.Rules)
m.AddReputation(h.lists, h.dnsbl)
return &Server{ return &Server{
Server: &http.Server{ Server: &http.Server{
@@ -181,14 +187,18 @@ func New(params Params) *Server {
LookupFile: h.lookupFile, LookupFile: h.lookupFile,
Lists: h.lists, Lists: h.lists,
DNSBL: h.dnsbl, DNSBL: h.dnsbl,
AbuseIPDB: h.abuseIPDB,
Metrics: m, Metrics: m,
} }
} }
// newReputation returns the lists fetched from URLs and the DNSBL zones' // newReputation returns the lists fetched from URLs, the DNSBL zones'
// verdicts, as the settings in params name them, with none fetched or // verdicts and AbuseIPDB's scores, as the settings in params name them,
// asked for yet. // with none fetched, asked for or checked yet, and adds their metrics to
func newReputation(params Params) (*reputation.Lists, *reputation.DNSBL) { // m, AbuseIPDB's while SWWAF_ABUSEIPDB_KEY is set.
func newReputation(
params Params, m *metrics.Metrics,
) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) {
cfg := params.Config cfg := params.Config
lists := reputation.New(reputation.Params{ lists := reputation.New(reputation.Params{
BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh, BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh,
@@ -200,8 +210,20 @@ func newReputation(params Params) (*reputation.Lists, *reputation.DNSBL) {
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog, Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
Alerts: params.Alerts, Alerts: params.Alerts,
}) })
abuseIPDB := reputation.NewAbuseIPDB(reputation.AbuseIPDBParams{
URL: params.AbuseIPDBURL, Key: cfg.AbuseIPDBKey, MinScore: cfg.AbuseIPDBMinScore,
DailyBudget: cfg.AbuseIPDBDailyBudget, CacheTTL: cfg.ReputationCacheTTL,
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
Alerts: params.Alerts,
})
return lists, dnsbl m.AddReputation(lists, dnsbl)
if cfg.AbuseIPDBKey != "" {
m.AddAbuseIPDB(abuseIPDB)
}
return lists, dnsbl, abuseIPDB
} }
// handler is the proxy. It holds what every request shares; what belongs // handler is the proxy. It holds what every request shares; what belongs
@@ -221,6 +243,7 @@ type handler struct {
lookupFile *lookup.File lookupFile *lookup.File
lists *reputation.Lists lists *reputation.Lists
dnsbl *reputation.DNSBL dnsbl *reputation.DNSBL
abuseIPDB *reputation.AbuseIPDB
rules *rules.Files rules *rules.Files
alerts *alerts.Queue alerts *alerts.Queue
} }
+11 -9
View File
@@ -268,7 +268,8 @@ func startProxyWithAlerts(
// queue: they wait in it, for the test to look at. With no geojsURL, there // queue: they wait in it, for the test to look at. With no geojsURL, there
// is no stand-in for GeoJS to look clients up at, and SWWAF_LOOKUP_SOURCE // is no stand-in for GeoJS to look clients up at, and SWWAF_LOOKUP_SOURCE
// is off unless env sets it. While it is file, the lookup database // is off unless env sets it. While it is file, the lookup database
// SWWAF_LOOKUP_DB_PATH names is read. // SWWAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY.
func newProxy( func newProxy(
t *testing.T, appURL, geojsURL string, now func() time.Time, t *testing.T, appURL, geojsURL string, now func() time.Time,
env map[string]string, env map[string]string,
@@ -325,14 +326,15 @@ func newProxy(
} }
server := proxy.New(proxy.Params{ server := proxy.New(proxy.Params{
Config: cfg, Config: cfg,
RequestLog: out, RequestLog: out,
ProcessLog: processLog, ProcessLog: processLog,
GeoJSURL: geojsURL, GeoJSURL: geojsURL,
LookupFile: lookupFile, AbuseIPDBURL: abuseIPDBURL,
Now: now, LookupFile: lookupFile,
Rules: ruleFiles, Now: now,
Alerts: alertQueue, Rules: ruleFiles,
Alerts: alertQueue,
}) })
return server, out, alertQueue return server, out, alertQueue
+61 -19
View File
@@ -4,8 +4,14 @@ import (
"context" "context"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
) )
// deny is the SWWAF_BLOCKLIST_ACTION and the SWWAF_REPUTATION_ACTION that
// refuses the requests of a client a source lists.
const deny = "deny"
// blocklistDenied notes the blocklists that list the client, as // blocklistDenied notes the blocklists that list the client, as
// noteListed does, and reports whether SWWAF_BLOCKLIST_ACTION, being deny, // noteListed does, and reports whether SWWAF_BLOCKLIST_ACTION, being deny,
// refuses the request. Being limit, it lowers the client's limits instead // refuses the request. Being limit, it lowers the client's limits instead
@@ -15,7 +21,7 @@ func (rq *request) blocklistDenied() bool {
rq.blocklisted = len(listedBy) > 0 rq.blocklisted = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a blocklist") rq.noteListed(listedBy, "listed by a blocklist")
return rq.blocklisted && rq.h.config.BlocklistAction == "deny" return rq.blocklisted && rq.h.config.BlocklistAction == deny
} }
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as // dnsblDenied notes the DNSBL zones whose verdict lists the client, as
@@ -30,27 +36,63 @@ func (rq *request) dnsblDenied(ctx context.Context) bool {
rq.dnsblListed = len(listedBy) > 0 rq.dnsblListed = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a DNSBL zone") rq.noteListed(listedBy, "listed by a DNSBL zone")
return rq.dnsblListed && rq.h.config.ReputationAction == "deny" return rq.dnsblListed && rq.h.config.ReputationAction == deny
} }
// noteListed adds sources, the URLs of the blocklists or the DNSBL zones, // abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
// their keys masked, that list the client, to the log line's reputation, // its score of the client is a hit, and reports whether
// counts each of them in the metrics, and raises a reputation_hit alert, // SWWAF_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
// with reason, for each. // does for a zone. While SWWAF_ABUSEIPDB_KEY is unset it does nothing. A
func (rq *request) noteListed(sources []string, reason string) { // client without a score is checked in the background, by the request's
rq.line.Reputation = append(rq.line.Reputation, sources...) // address, if its history counts an offence, and the request does not
// wait for the answer. The score is then used for each address of the
// client. ctx is the request's own context.
func (rq *request) abuseIPDBDenied(ctx context.Context) bool {
if rq.h.config.AbuseIPDBKey == "" {
return false
}
client := clientGroup(rq.client)
held, _ := rq.h.limiter.Client(client)
offender := held.History.Offences != ratelimit.Offences{}
score, hit := rq.h.abuseIPDB.Hit(ctx, client, rq.client, offender)
if !hit {
return false
}
rq.abuseIPDBHit = true
rq.noteHit(reputation.AbuseIPDBSource, "scored by AbuseIPDB at or over "+
"SWWAF_ABUSEIPDB_MIN_SCORE", map[string]any{
"source": reputation.AbuseIPDBSource, "score": score,
})
return rq.h.config.ReputationAction == deny
}
// noteListed notes each of sources, the URLs of the blocklists or the
// DNSBL zones, their keys masked, that list the client, as noteHit does,
// with reason, and the source in the alert's detail.
func (rq *request) noteListed(sources []string, reason string) {
for _, source := range sources { for _, source := range sources {
rq.h.metrics.ReputationHit(source) rq.noteHit(source, reason, map[string]any{"source": source})
rq.h.alerts.Raise(alerts.Alert{
Event: alerts.EventReputationHit,
Client: rq.client,
Netblock: clientGroup(rq.client),
ASN: rq.line.ASN,
ASName: rq.line.ASName,
Country: rq.line.Country,
Reason: reason,
Detail: map[string]any{"source": source},
})
} }
} }
// noteHit adds source, which lists the client, to the log line's
// reputation, counts it in the metrics, and raises a reputation_hit alert
// with reason and detail.
func (rq *request) noteHit(source, reason string, detail map[string]any) {
rq.line.Reputation = append(rq.line.Reputation, source)
rq.h.metrics.ReputationHit(source)
rq.h.alerts.Raise(alerts.Alert{
Event: alerts.EventReputationHit,
Client: rq.client,
Netblock: clientGroup(rq.client),
ASN: rq.line.ASN,
ASName: rq.line.ASName,
Country: rq.line.Country,
Reason: reason,
Detail: detail,
})
}
+300
View File
@@ -1,16 +1,20 @@
package proxy_test package proxy_test
import ( import (
"fmt"
"io"
"maps" "maps"
"net/http" "net/http"
"net/netip" "net/netip"
"slices" "slices"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/proxy" "sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
@@ -599,6 +603,277 @@ func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) {
} }
} }
// The AbuseIPDB settings, and accountKey, the key the tests set.
const (
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
accountKey = "abuseipdb-key-0123456789abcdef"
)
// abuseipdb is how the request log, the alerts and the metrics name
// AbuseIPDB.
const abuseipdb = reputation.AbuseIPDBSource
// abuseIPDBURL is where newProxy has clients checked with AbuseIPDB: at
// abuseIPDBStandIn, which TestMain registers with Go's default transport,
// through which AbuseIPDB is asked.
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
// abuseIPDBStandIn is a stand-in for AbuseIPDB that gives every client the
// score 100, at once and without the network.
type abuseIPDBStandIn struct{}
// RoundTrip answers req with the score 100.
func (abuseIPDBStandIn) RoundTrip(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: http.Header{},
Body: io.NopCloser(strings.NewReader(`{"data":{"abuseConfidenceScore":100}}`)),
Request: req,
}, nil
}
func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(t *testing.T) {
t.Parallel()
forward := requestlog.ActionForward
s, clk, server, _ := startWithLookupsAndClock(t, map[string]string{
abuseIPDBKey: accountKey, rateLimitPerMinute: "2", reputationAction: actionLog,
})
// Neither fromDE, until it breaks a rate limit, nor fromKP, which never
// does, is checked, nor fromDE under the ban that makes.
s.get(fromDE, http.StatusOK, forward)
s.get(fromDE, http.StatusOK, forward)
s.get(fromKP, http.StatusOK, forward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
wantAbuseIPDBChecks(t, server, 0)
// Once the ban has ended, fromDE's first request has it checked in the
// background, and goes on without its score, which its next request
// finds.
clk.advance(time.Hour)
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
wantAbuseIPDBChecks(t, server, 1)
waitUntil(func() bool { return len(server.AbuseIPDB.Snapshot().Scores) == 1 })
wantReputation(t, s.get(fromDE, http.StatusOK, forward), abuseipdb)
s.get(fromKP, http.StatusOK, forward)
wantAbuseIPDBChecks(t, server, 1)
}
func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing.T) {
t.Parallel()
forward := requestlog.ActionForward
// 15 addresses of 2001:db8:1:2::/64, one client, each in a part of it
// of its own.
var addresses []string
for i := 1; i < 16; i++ {
addresses = append(addresses, fmt.Sprintf("2001:db8:1:2:%x::9", i<<12))
}
s, clk, server := startWithClock(t, "", map[string]string{
abuseIPDBKey: accountKey, reputationAction: actionLog,
rateLimitPerMinute: strconv.Itoa(len(addresses)),
})
// The client breaks the rate limit from its first address, which bans
// it for an hour.
for range addresses {
s.get(addresses[0], http.StatusOK, forward)
}
s.get(addresses[0], http.StatusForbidden, requestlog.ActionRateLimited)
clk.advance(time.Hour)
// Once the ban has ended, which set its counters back to zero, a
// request from each of its addresses has it checked once.
for _, address := range addresses {
s.get(address, http.StatusOK, forward)
}
wantAbuseIPDBChecks(t, server, 1)
}
// probePath is the path the ban rule of testRules, probe, matches.
const probePath = "/.env"
func TestClientARuleRefusedIsCheckedWithAbuseIPDBAtItsNextRequest(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
// path is what the client asks for, status and action what that
// request is answered and logged with, and want the offences its
// history then counts.
path string
status int
action string
want ratelimit.Offences
}{
{
"a block rule", "/blocked", http.StatusForbidden, requestlog.ActionRuleBlocked,
ratelimit.Offences{RuleBlocked: 1},
},
{
"a ban rule", probePath, http.StatusForbidden, requestlog.ActionBanned,
ratelimit.Offences{Attack: 1},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, clk, server := startWithClock(t, "", map[string]string{
abuseIPDBKey: accountKey, reputationAction: actionLog,
rulesDir: writeRules(t, testRules), attackBanDuration: "1h",
})
s.request(client, tc.path, tc.status, tc.action)
wantAbuseIPDBChecks(t, server, 0)
if got := historyOf(t, server, client).Offences; got != tc.want {
t.Errorf("history counts the offences %+v, want %+v", got, tc.want)
}
// Its next request, once a ban rule's ban has ended, has it
// checked.
clk.advance(time.Hour)
s.get(client, http.StatusOK, requestlog.ActionForward)
wantAbuseIPDBChecks(t, server, 1)
})
}
}
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) {
t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionDenied
for _, tc := range []struct {
action string
// statuses and actions are those of fromDE's three requests, and
// percent their limit_percent, as percentText gives it.
statuses []int
actions []string
percent string
}{
{
actionDeny, []int{http.StatusForbidden, http.StatusForbidden, http.StatusForbidden},
[]string{denied, denied, denied}, none,
},
{
// Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionRateLimited},
"50 from " + reputationAction,
},
{
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
[]string{forward, forward, forward}, none,
},
} {
t.Run(tc.action, func(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
reputationAction: tc.action,
})
// At SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
for i := range 3 {
line := s.get(fromDE, tc.statuses[i], tc.actions[i])
wantReputation(t, line, abuseipdb)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
tc.percent)
// A request refused for the score is not counted.
counted := line.fields["counts"] != nil
if counted != (tc.actions[i] != denied) {
t.Errorf("request counted %t, logged %s", counted, tc.actions[i])
}
}
// fromKP's score is no hit, and it has the whole limit.
for range 3 {
line := s.get(fromKP, http.StatusOK, forward)
wantReputation(t, line)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
none)
}
// A refusal for the score makes no ban.
if held := server.Ledger.Snapshot(); tc.action == actionDeny && len(held) != 0 {
t.Errorf("bans %+v, want none", held)
}
})
}
}
func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
t *testing.T,
) {
t.Parallel()
s, server, queue := startWithLookups(t, map[string]string{
abuseIPDBKey: accountKey, reputationAction: actionLog, metricsToken: token,
})
loadScores(server, map[string]int64{fromDE: 90})
// The second request's alert is a repeat, which the cooldown holds back.
for range 2 {
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
abuseipdb)
}
// The alert is made as a DNSBL zone's is, with the score besides.
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
waiting[0].Reason != "scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE" ||
waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) ||
queue.Suppressed() != 1 {
t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
"with the score 90, and 1", waiting, queue.Suppressed())
}
// The hits, and the checks, none, since no client committed an
// offence, so that the whole budget is left.
metrics := s.scrape(unplaced)
labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}`
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2)
wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0)
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
wantMetric(t, metrics, "smallwebwaf_reputation_daily_budget_remaining"+labels, 900)
}
func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
t.Parallel()
forward := requestlog.ActionForward
s, clk, server, _ := startWithLookupsAndClock(t, map[string]string{
rateLimitPerMinute: "1", metricsToken: token,
})
loadScores(server, map[string]int64{fromDE: 100})
// fromDE's score is not used, and once it has committed an offence it
// is not checked either.
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
clk.advance(time.Hour)
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
wantAbuseIPDBChecks(t, server, 0)
wantNoSeries(t, s.scrape(unplaced), `smallwebwaf_reputation_daily_budget_remaining{`+
`instance="`+alertInstance+`",source="`+abuseipdb+`"}`)
}
// listsFetched is when loadLists has the copies fetched. // listsFetched is when loadLists has the copies fetched.
func listsFetched() time.Time { func listsFetched() time.Time {
return time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC) return time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
@@ -631,6 +906,31 @@ func loadVerdicts(server *proxy.Server, listedBy map[string][]string) {
server.DNSBL.Load(verdicts) server.DNSBL.Load(verdicts)
} }
// loadScores puts into server's AbuseIPDB the score scores gives each
// client, an IPv4 address, fetched at verdictsFetched, as reputation.json
// would at start.
func loadScores(server *proxy.Server, scores map[string]int64) {
kept := make([]reputation.Score, 0, len(scores))
for client, score := range scores {
kept = append(kept, reputation.Score{
Client: netip.MustParsePrefix(client + "/32"), Score: score,
Fetched: verdictsFetched(),
})
}
server.AbuseIPDB.Load(reputation.Checks{Scores: kept})
}
// wantAbuseIPDBChecks checks how many clients server has checked with
// AbuseIPDB.
func wantAbuseIPDBChecks(t *testing.T, server *proxy.Server, want int) {
t.Helper()
if got := server.AbuseIPDB.Checked(); got != want {
t.Errorf("%d clients checked with AbuseIPDB, want %d", got, want)
}
}
// loadLists puts copies of lists into server's lists, by URL, each with // loadLists puts copies of lists into server's lists, by URL, each with
// its lines, fetched at listsFetched, as reputation.json would at start. // its lines, fetched at listsFetched, as reputation.json would at start.
func loadLists(t *testing.T, server *proxy.Server, copies map[string][]string) { func loadLists(t *testing.T, server *proxy.Server, copies map[string][]string) {
+19 -11
View File
@@ -63,10 +63,15 @@ type request struct {
// limits and for the byte limits. // limits and for the byte limits.
counted bool counted bool
limitPercent, bytesPercent percentage limitPercent, bytesPercent percentage
// attack is true for a request that matched a ban rule, and
// ruleBlocked for one a block rule refused, each an offence its
// client's history counts.
attack, ruleBlocked bool
// blocklisted is true once a blocklist is found to list the client, // blocklisted is true once a blocklist is found to list the client,
// and dnsblListed once a DNSBL zone's verdict is. // dnsblListed once a DNSBL zone's verdict is, and abuseIPDBHit once
blocklisted, dnsblListed bool // AbuseIPDB's score of it is a hit.
start time.Time blocklisted, dnsblListed, abuseIPDBHit bool
start time.Time
// checked is when the checks were done, and upstreamStart when the // checked is when the checks were done, and upstreamStart when the
// request was handed to the app. // request was handed to the app.
checked time.Time checked time.Time
@@ -217,13 +222,14 @@ func (rq *request) check(ctx context.Context) *refusal {
// other client, SWWAF_DENY_NETS comes first, then a ban on its netblock, // other client, SWWAF_DENY_NETS comes first, then a ban on its netblock,
// so that a client either refuses is not looked up, then the lookup of // so that a client either refuses is not looked up, then the lookup of
// its AS number and country, then the country lists, then the blocklists, // its AS number and country, then the country lists, then the blocklists,
// and then the DNSBL zones' verdicts; a request any of them refuses is not // then the DNSBL zones' verdicts, and then AbuseIPDB's score; a request
// counted for the rate limits. Then come the rate limits, unless the // any of them refuses is not counted for the rate limits. Then come the
// client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the request's path is // rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the
// exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that every other request // request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
// is counted, each of them by the client's limit percentages, and last the // every other request is counted, each of them by the client's limit
// rule files. A request exempt from the rate limits is exempt from the // percentages, and last the rule files. A request exempt from the rate
// byte limits too. ctx is the request's own context. // limits is exempt from the byte limits too. ctx is the request's own
// context.
func (rq *request) checkClient(ctx context.Context) string { func (rq *request) checkClient(ctx context.Context) string {
cfg := rq.h.config cfg := rq.h.config
if isInside(rq.client, cfg.AllowNets) { if isInside(rq.client, cfg.AllowNets) {
@@ -250,7 +256,7 @@ func (rq *request) checkClient(ctx context.Context) string {
return requestlog.ActionDenied return requestlog.ActionDenied
} }
if rq.dnsblDenied(ctx) { if rq.dnsblDenied(ctx) || rq.abuseIPDBDenied(ctx) {
return requestlog.ActionDenied return requestlog.ActionDenied
} }
@@ -540,6 +546,8 @@ func (rq *request) addToHistory() {
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,
RuleBlocked: rq.ruleBlocked,
}) })
answer, found := rq.answerAtTheEnd() answer, found := rq.answerAtTheEnd()
+5 -1
View File
@@ -12,7 +12,8 @@ import (
// action of the rule that refuses it, ActionRuleBlocked for a block rule // action of the rule that refuses it, ActionRuleBlocked for a block rule
// and ActionBanned for a ban rule, or "" when none does. A ban rule bans // and ActionBanned for a ban rule, or "" when none does. A ban rule bans
// the client's netblock for a clear sign of attack, or in observe mode // the client's netblock for a clear sign of attack, or in observe mode
// raises the alert for the ban it would have made. // raises the alert for the ban it would have made. Either rule's match
// is noted as an offence, for the client's history.
func (rq *request) checkRules(now time.Time) string { func (rq *request) checkRules(now time.Time) string {
matched := rq.h.rules.Match(rq.in) matched := rq.h.rules.Match(rq.in)
@@ -28,8 +29,11 @@ func (rq *request) checkRules(now time.Time) string {
// Only the last rule matched can refuse the request. // Only the last rule matched can refuse the request.
switch last := matched[len(matched)-1]; last.Action { switch last := matched[len(matched)-1]; last.Action {
case rules.ActionBlock: case rules.ActionBlock:
rq.ruleBlocked = true
return requestlog.ActionRuleBlocked return requestlog.ActionRuleBlocked
case rules.ActionBan: case rules.ActionBan:
rq.attack = true
rq.banForAttack(now, last) rq.banForAttack(now, last)
return requestlog.ActionBanned return requestlog.ActionBanned
+21 -4
View File
@@ -117,9 +117,15 @@ type Responses struct {
} }
// Offences are a client's offences, by kind. // Offences are a client's offences, by kind.
//
//nolint:tagliatelle // the state files use snake_case, as the request log does
type Offences struct { type Offences struct {
// Limit is its requests that broke a rate limit or a byte limit. // Limit is its requests that broke a rate limit or a byte limit,
Limit int64 `json:"limit"` // Attack those that matched a ban rule, a clear sign of attack, and
// RuleBlocked those a block rule refused.
Limit int64 `json:"limit"`
Attack int64 `json:"attack"`
RuleBlocked int64 `json:"rule_blocked"`
} }
// Request is what a client's history keeps of one of its requests. // Request is what a client's history keeps of one of its requests.
@@ -137,8 +143,11 @@ type Request struct {
RequestBytes int64 RequestBytes int64
ResponseBytes int64 ResponseBytes int64
// BrokeLimit is true for a request that broke a rate limit or a byte // BrokeLimit is true for a request that broke a rate limit or a byte
// limit. // limit, Attack for one that matched a ban rule, and RuleBlocked for
BrokeLimit bool // one a block rule refused.
BrokeLimit bool
Attack bool
RuleBlocked bool
} }
// New returns a Limiter for limits, with no client counted yet. // New returns a Limiter for limits, with no client counted yet.
@@ -258,6 +267,14 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
if r.BrokeLimit { if r.BrokeLimit {
h.Offences.Limit++ h.Offences.Limit++
} }
if r.Attack {
h.Offences.Attack++
}
if r.RuleBlocked {
h.Offences.RuleBlocked++
}
} }
// AddLookup gives client's history its AS number, AS name and country, as // AddLookup gives client's history its AS number, AS name and country, as
+328
View File
@@ -0,0 +1,328 @@
package reputation
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/netip"
"net/url"
"slices"
"sync"
"time"
"github.com/hashicorp/golang-lru/v2/simplelru"
"sneak.berlin/go/smallwebwaf/internal/alerts"
)
const (
// AbuseIPDBURL is where clients are checked: the check endpoint of
// AbuseIPDB's API.
AbuseIPDBURL = "https://api.abuseipdb.com/api/v2/check"
// AbuseIPDBSource is how the request log, the alerts and the metrics
// name AbuseIPDB.
AbuseIPDBSource = "abuseipdb"
// maxAnswerBytes is the most of an answer of AbuseIPDB that is read.
maxAnswerBytes = 64 << 10
// day is the length of the day the checks are counted in, in UTC.
day = 24 * time.Hour
)
var (
errNoScore = errors.New("the answer gives no abuseConfidenceScore")
errBudgetUsedUp = errors.New(
"checks spent; none is made until the day ends at 00:00 UTC")
)
// Score is what AbuseIPDB said about a client, as reputation.json holds
// it: the client, an IPv4 address or an IPv6 group, its abuse confidence
// score, from 0 to 100, and when AbuseIPDB answered.
type Score struct {
Client netip.Prefix `json:"client"`
Score int64 `json:"score"`
Fetched time.Time `json:"fetched"`
}
// Checks are what reputation.json keeps of the checks of clients with
// AbuseIPDB: the day, in UTC, of the checks Spent counts, zero before the
// first, and the scores still in use.
type Checks struct {
Day time.Time `json:"day,omitzero"`
Spent int `json:"spent"`
Scores []Score `json:"scores"`
}
// AbuseIPDBParams are what NewAbuseIPDB needs.
type AbuseIPDBParams struct {
// URL is where clients are checked, normally AbuseIPDBURL, with Key,
// the account's key (SWWAF_ABUSEIPDB_KEY).
URL string
Key string
// MinScore is the least score that is a hit (SWWAF_ABUSEIPDB_MIN_SCORE),
// and DailyBudget the most checks made in a day, in UTC
// (SWWAF_ABUSEIPDB_DAILY_BUDGET).
MinScore int64
DailyBudget int
// CacheTTL is how long a score is used after it was fetched
// (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take
// (SWWAF_REPUTATION_TIMEOUT).
CacheTTL time.Duration
Timeout time.Duration
// Now tells the time, normally time.Now in UTC.
Now func() time.Time
// ProcessLog receives each check that fails, and why, and the day's
// budget used up.
ProcessLog *slog.Logger
// Alerts receive a source_failure alert for each.
Alerts *alerts.Queue
}
// AbuseIPDB checks clients with AbuseIPDB, in the background, and keeps
// their scores. It is safe for concurrent use.
type AbuseIPDB struct {
params AbuseIPDBParams
httpClient *http.Client
mu sync.Mutex
// scores are by client. Each is added as it is fetched and never moved
// up, so that the one fetched longest ago is the first dropped.
scores *simplelru.LRU[netip.Prefix, Score]
// checking are the clients whose check is under way.
checking map[netip.Prefix]bool
// day is the day, in UTC, of the checks spent counts.
day time.Time
spent int
// checks and failures count the checks made and those that failed,
// and retryAt is when a client may be checked again after the last
// check failed.
checks int
failures int
retryAt time.Time
}
// NewAbuseIPDB returns an AbuseIPDB with no score yet, and no check spent.
func NewAbuseIPDB(params AbuseIPDBParams) *AbuseIPDB {
scores, err := simplelru.NewLRU[netip.Prefix, Score](maxVerdicts, nil)
if err != nil {
panic(err) // NewLRU fails only for a size below one
}
return &AbuseIPDB{
params: params,
httpClient: &http.Client{},
scores: scores,
checking: map[netip.Prefix]bool{},
}
}
// Hit returns AbuseIPDB's score of client, an IPv4 address or an IPv6
// group, and whether it is a hit: MinScore or more. A score is used until
// CacheTTL has passed since it was fetched, whichever of the client's
// addresses its request comes from. A client without one is checked in
// the background, by addr, the address its request came from, if
// offender, if it has committed an offence, unless its check is under
// way, a check failed less than failureDelay ago, or the day's checks
// have used up DailyBudget; Hit never waits for a check. The check that
// uses the budget up is logged and raised as a source_failure alert. ctx
// is the context of the client's request, and a check goes on after the
// request ends.
func (a *AbuseIPDB) Hit(
ctx context.Context, client netip.Prefix, addr netip.Addr, offender bool,
) (int64, bool) {
a.mu.Lock()
now := a.params.Now()
kept, found := a.scores.Peek(client)
if found && now.Sub(kept.Fetched) < a.params.CacheTTL {
a.mu.Unlock()
return kept.Score, kept.Score >= a.params.MinScore
}
if today := now.Truncate(day); !a.day.Equal(today) {
a.day, a.spent = today, 0
}
check := offender && !a.checking[client] && !now.Before(a.retryAt) &&
a.spent < a.params.DailyBudget
if check {
a.checking[client] = true
a.checks++
a.spent++
go a.check(context.WithoutCancel(ctx), client, addr)
}
usedUp := check && a.spent == a.params.DailyBudget
a.mu.Unlock()
if usedUp {
a.alert("the daily budget of AbuseIPDB checks is used up",
fmt.Errorf("%d %w", a.params.DailyBudget, errBudgetUsedUp))
}
return 0, false
}
// Checked returns how many checks were made.
func (a *AbuseIPDB) Checked() int {
a.mu.Lock()
defer a.mu.Unlock()
return a.checks
}
// Failures returns how many checks failed.
func (a *AbuseIPDB) Failures() int {
a.mu.Lock()
defer a.mu.Unlock()
return a.failures
}
// BudgetLeft returns how many checks the day's budget has left.
func (a *AbuseIPDB) BudgetLeft() int {
a.mu.Lock()
defer a.mu.Unlock()
if !a.day.Equal(a.params.Now().Truncate(day)) {
return a.params.DailyBudget
}
return max(a.params.DailyBudget-a.spent, 0)
}
// Snapshot returns the checks spent and every score still in use, sorted
// by client, as reputation.json keeps them.
func (a *AbuseIPDB) Snapshot() Checks {
a.mu.Lock()
now := a.params.Now()
checks := Checks{Day: a.day, Spent: a.spent, Scores: make([]Score, 0, a.scores.Len())}
for _, kept := range a.scores.Values() {
if now.Sub(kept.Fetched) < a.params.CacheTTL {
checks.Scores = append(checks.Scores, kept)
}
}
a.mu.Unlock()
slices.SortFunc(checks.Scores, func(x, y Score) int {
return x.Client.Compare(y.Client)
})
return checks
}
// Load keeps checks, read from reputation.json, in place of those it
// keeps, but for the scores past maxVerdicts, those fetched longest ago.
// One fetched CacheTTL ago or more is neither used nor written, as for any
// score.
func (a *AbuseIPDB) Load(checks Checks) {
scores := slices.Clone(checks.Scores)
slices.SortStableFunc(scores, func(x, y Score) int {
return x.Fetched.Compare(y.Fetched)
})
a.mu.Lock()
defer a.mu.Unlock()
a.day, a.spent = checks.Day, checks.Spent
a.scores.Purge()
for _, kept := range scores {
a.scores.Add(kept.Client, kept)
}
}
// check checks client with AbuseIPDB by addr, one of its addresses, keeps
// the score as client's, and notes the check as no longer under way. A
// check that fails gives no score: it is counted, logged and raised as a
// source_failure alert, and no client is checked for failureDelay.
func (a *AbuseIPDB) check(ctx context.Context, client netip.Prefix, addr netip.Addr) {
score, err := a.ask(ctx, addr)
now := a.params.Now()
a.mu.Lock()
delete(a.checking, client)
if err == nil {
a.scores.Add(client, Score{Client: client, Score: score, Fetched: now})
} else {
a.failures++
a.retryAt = now.Add(failureDelay)
}
a.mu.Unlock()
if err != nil {
a.alert("checking a client with AbuseIPDB failed", err)
}
}
// ask asks AbuseIPDB for addr's abuse confidence score, sending the key
// in the header Key. An answer other than 200, one that gives no score,
// and none within Timeout, fail.
func (a *AbuseIPDB) ask(ctx context.Context, addr netip.Addr) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, a.params.Timeout)
defer cancel()
query := url.Values{"ipAddress": {addr.String()}}
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
a.params.URL+"?"+query.Encode(), http.NoBody)
if err != nil {
return 0, fmt.Errorf("make the request: %w", err)
}
req.Header.Set("Key", a.params.Key)
req.Header.Set("Accept", "application/json")
res, err := a.httpClient.Do(req)
if err != nil {
// Do's error names the URL, which holds the client's address, which
// is not to be logged: only what went wrong is kept.
return 0, fmt.Errorf("check the client: %w", errors.Unwrap(err))
}
defer func() {
_ = res.Body.Close()
}()
if res.StatusCode != http.StatusOK {
return 0, fmt.Errorf("%w %s", errStatus, res.Status)
}
var answer struct {
Data struct {
AbuseConfidenceScore *int64 `json:"abuseConfidenceScore"`
} `json:"data"`
}
err = json.NewDecoder(io.LimitReader(res.Body, maxAnswerBytes)).Decode(&answer)
if err != nil {
return 0, fmt.Errorf("read the answer: %w", err)
}
if answer.Data.AbuseConfidenceScore == nil {
return 0, errNoScore
}
return *answer.Data.AbuseConfidenceScore, nil
}
// alert raises a source_failure alert from AbuseIPDB with reason and err,
// and logs them.
func (a *AbuseIPDB) alert(reason string, err error) {
// Raised before it is logged, so that the alert is there once the log
// line is.
raiseFailure(a.params.Alerts, reason, AbuseIPDBSource, err)
a.params.ProcessLog.Warn(reason, "source", AbuseIPDBSource, "error", err.Error())
}
+663
View File
@@ -0,0 +1,663 @@
package reputation_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"reflect"
"slices"
"strings"
"sync"
"testing"
"testing/synctest"
"time"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/metrics"
"sneak.berlin/go/smallwebwaf/internal/reputation"
)
// The tests of AbuseIPDB run in synctest bubbles, as those of the lists
// do, and AbuseIPDB is a stand-in reached without the network, for the
// same reason. A bubble's clock starts at midnight UTC, as a day the
// checks are counted in starts.
const (
// key is the account's key the tests give, the only one the stand-in
// takes.
key = "abuseipdb-key-0123456789abcdef"
// suspect and other are clients that have committed an offence.
suspect = "203.0.113.9"
other = "2001:db8::9"
)
func TestOnlyAnOffenderWithoutAScoreIsChecked(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
checker := newAbuseIPDB(abuseIPDB, abuseIPDBParams())
// A client that has committed no offence is not checked.
wantScore(t, checker, suspect, false, 0, false)
synctest.Wait()
wantChecked(t, abuseIPDB)
// An offender is, and from then on its score is used, whether or not
// it is an offender.
wantScore(t, checker, suspect, true, 0, false)
synctest.Wait()
wantScore(t, checker, suspect, true, 100, true)
wantScore(t, checker, suspect, false, 100, true)
synctest.Wait()
wantChecked(t, abuseIPDB, suspect)
})
}
func TestIPv6ClientIsCheckedOnceAndItsScoreUsedForEachOfItsAddresses(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
// 15 addresses of 2001:db8:1:2::/64, one client, each in a part of
// it of its own.
var addresses []string
for i := 1; i < 16; i++ {
addresses = append(addresses, fmt.Sprintf("2001:db8:1:2:%x::9", i<<12))
}
abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{addresses[0]: 100}}
checker := newAbuseIPDB(abuseIPDB, abuseIPDBParams())
// A request from each has the client checked once, by the first.
for _, address := range addresses {
hitFrom(t, checker, address, true)
}
synctest.Wait()
wantChecked(t, abuseIPDB, addresses[0])
// Its score is the whole client's.
for _, address := range addresses {
wantScore(t, checker, address, true, 100, true)
}
synctest.Wait()
wantChecked(t, abuseIPDB, addresses[0])
})
}
func TestScoreAtOrOverTheMinimumIsAHit(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
scores := map[string]int64{"192.0.2.74": 74, "192.0.2.75": 75, "192.0.2.100": 100}
p := abuseIPDBParams()
p.MinScore = 75
checker := newAbuseIPDB(&abuseIPDBStandIn{scores: scores}, p)
for client := range scores {
hitFrom(t, checker, client, true)
}
synctest.Wait()
for client, score := range scores {
wantScore(t, checker, client, true, score, score >= 75)
}
})
}
func TestScoreUsedUntilTheCacheTTLHasPassedSinceItWasFetched(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
checker := newAbuseIPDB(abuseIPDB, abuseIPDBParams())
hitFrom(t, checker, suspect, true)
synctest.Wait()
// AbuseIPDB gives another score from now on, but the one kept is
// used, and the client is not checked again, until the TTL has
// passed.
abuseIPDB.setScore(suspect, 80)
time.Sleep(cacheTTL - time.Nanosecond)
wantScore(t, checker, suspect, true, 100, true)
synctest.Wait()
wantChecked(t, abuseIPDB, suspect)
// Then it is not used, and the client is checked again.
time.Sleep(time.Nanosecond)
wantScore(t, checker, suspect, true, 0, false)
synctest.Wait()
wantScore(t, checker, suspect, true, 80, true)
wantChecked(t, abuseIPDB, suspect, suspect)
})
}
func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
var log bytes.Buffer
queue := newQueue()
p := abuseIPDBParams()
p.DailyBudget = 3
p.Alerts = queue
p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil))
abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
checker := newAbuseIPDB(abuseIPDB, p)
// At noon, the first three offenders spend the budget, and the
// fourth, unchecked, is not.
time.Sleep(12 * time.Hour)
const unchecked = "192.0.2.4"
clients := []string{suspect, "192.0.2.2", "192.0.2.3", unchecked}
for _, client := range clients {
hitFrom(t, checker, client, true)
}
synctest.Wait()
wantChecked(t, abuseIPDB, clients[:3]...)
wantBudgetLeft(t, checker, 0)
// The check that used the budget up raised the alert, and logged it.
const usedUp = "the daily budget of AbuseIPDB checks is used up"
wantFailureAlert(t, queue, time.Now(), usedUp,
"3 checks spent; none is made until the day ends at 00:00 UTC", 0)
if !strings.Contains(log.String(), `"msg":"`+usedUp+`"`) {
t.Errorf("logged\n%s\nwant the budget used up", log.String())
}
// Restarted with what reputation.json keeps, it uses the scores, and
// checks no client until the day ends.
restarted := &abuseIPDBStandIn{}
again := newAbuseIPDB(restarted, p)
again.Load(checker.Snapshot())
wantScore(t, again, suspect, true, 100, true)
wantBudgetLeft(t, again, 0)
time.Sleep(12*time.Hour - time.Nanosecond)
wantScore(t, again, unchecked, true, 0, false)
synctest.Wait()
wantChecked(t, restarted)
// At midnight the budget is whole again.
time.Sleep(time.Nanosecond)
wantBudgetLeft(t, again, 3)
wantScore(t, again, unchecked, true, 0, false)
synctest.Wait()
wantChecked(t, restarted, unchecked)
wantBudgetLeft(t, again, 2)
})
}
func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
// key is the key sent, status and body what AbuseIPDB answers with,
// and error the failure.
key, body string
status int
error string
}{
{
"a refusal, past AbuseIPDB's own limit", key,
`{"errors":[{"detail":"Daily rate limit of 1000 requests exceeded"}]}`,
http.StatusTooManyRequests, "the server answered 429 Too Many Requests",
},
{
"a refusal of a wrong key", "wrong-key-0123456789abcdef", "", 0,
"the server answered 401 Unauthorized",
},
{
"a server failure", key, "", http.StatusInternalServerError,
"the server answered 500 Internal Server Error",
},
{
"an answer without a score", key, `{"data":{"ipAddress":"` + suspect + `"}}`,
http.StatusOK, "the answer gives no abuseConfidenceScore",
},
{
"an answer that is not JSON", key, "<html>", http.StatusOK,
"read the answer: invalid character '<' looking for beginning of value",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
var log bytes.Buffer
queue := newQueue()
p := abuseIPDBParams()
p.Key = tc.key
p.Alerts = queue
p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil))
abuseIPDB := &abuseIPDBStandIn{status: tc.status, body: tc.body}
checker := newAbuseIPDB(abuseIPDB, p)
// The failure gives no score, and no client is checked within a
// minute of it.
wantScore(t, checker, suspect, true, 0, false)
synctest.Wait()
time.Sleep(time.Minute - time.Nanosecond)
wantScore(t, checker, other, true, 0, false)
synctest.Wait()
wantChecked(t, abuseIPDB, suspect)
wantFailures(t, checker, 1)
time.Sleep(time.Nanosecond)
wantScore(t, checker, other, true, 0, false)
synctest.Wait()
wantChecked(t, abuseIPDB, suspect, other)
wantFailures(t, checker, 2)
if scores := checker.Snapshot().Scores; len(scores) != 0 {
t.Errorf("scores %+v, want none", scores)
}
// One alert for the first failure; the cooldown holds back the
// second.
wantFailureAlert(t, queue, time.Now().Add(-time.Minute),
"checking a client with AbuseIPDB failed", tc.error, 1)
if !strings.Contains(log.String(), `"msg":"checking a client with `+
`AbuseIPDB failed","source":"abuseipdb","error":"`+tc.error) {
t.Errorf("logged\n%s\nwant the failures", log.String())
}
})
})
}
}
func TestCheckNotAnsweredWithinTheTimeoutFailsAndHitNeverWaits(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
queue := newQueue()
p := abuseIPDBParams()
p.Alerts = queue
abuseIPDB := &abuseIPDBStandIn{hanging: true}
checker := newAbuseIPDB(abuseIPDB, p)
began := time.Now()
// The second, while the first's check is under way, starts none.
wantScore(t, checker, suspect, true, 0, false)
wantScore(t, checker, suspect, true, 0, false)
if waited := time.Since(began); waited != 0 {
t.Errorf("waited %s for the check, want no wait", waited)
}
time.Sleep(timeout - time.Nanosecond)
synctest.Wait()
wantChecked(t, abuseIPDB, suspect)
wantFailures(t, checker, 0)
time.Sleep(time.Nanosecond)
synctest.Wait()
wantFailures(t, checker, 1)
wantFailureAlert(t, queue, time.Now(), "checking a client with AbuseIPDB failed",
"check the client: context deadline exceeded", 0)
})
}
func TestKeyIsSentInTheKeyHeaderAndNeverShown(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
var log bytes.Buffer
queue := newQueue()
p := abuseIPDBParams()
p.Alerts = queue
p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil))
abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
checker := newAbuseIPDB(abuseIPDB, p)
m := metrics.New(1, "app")
m.AddReputation(reputation.New(params()), reputation.NewDNSBL(dnsblParams()))
m.AddAbuseIPDB(checker)
// One check that AbuseIPDB answers, and one it refuses with an answer
// that names the key.
wantScore(t, checker, suspect, true, 0, false)
synctest.Wait()
wantScore(t, checker, suspect, true, 100, true)
abuseIPDB.answerWith(http.StatusUnauthorized, `{"errors":[{"detail":"`+key+`"}]}`)
wantScore(t, checker, other, true, 0, false)
synctest.Wait()
wantFailures(t, checker, 1)
abuseIPDB.mu.Lock()
sent := slices.Clone(abuseIPDB.keys)
abuseIPDB.mu.Unlock()
if !slices.Equal(sent, []string{key, key}) {
t.Errorf("checks sent the keys %v, want %s twice", sent, key)
}
alerted, err := json.Marshal(waiting(queue))
if err != nil {
t.Fatalf("encode the alerts: %v", err)
}
kept, err := json.Marshal(checker.Snapshot())
if err != nil {
t.Fatalf("encode the checks: %v", err)
}
for name, shown := range map[string]string{
"the log": log.String(), "the alerts": string(alerted),
"the metrics": scrapeMetrics(t, m), "reputation.json": string(kept),
} {
if strings.Contains(shown, key) {
t.Errorf("%s shows the key:\n%s", name, shown)
}
}
})
}
func TestMetricsCountTheChecksTheFailuresAndTheBudgetLeft(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
abuseIPDB := &abuseIPDBStandIn{}
p := abuseIPDBParams()
p.DailyBudget = 5
checker := newAbuseIPDB(abuseIPDB, p)
m := metrics.New(1, "app")
m.AddReputation(reputation.New(params()), reputation.NewDNSBL(dnsblParams()))
m.AddAbuseIPDB(checker)
// One check that AbuseIPDB answers, and one that fails.
hitFrom(t, checker, suspect, true)
synctest.Wait()
abuseIPDB.answerWith(http.StatusInternalServerError, "")
hitFrom(t, checker, other, true)
synctest.Wait()
scraped := scrapeMetrics(t, m)
for series, want := range map[string]string{
"queries_total": "2",
"failures_total": "1",
"daily_budget_remaining": "3",
} {
line := "\nsmallwebwaf_reputation_" + series +
`{instance="app",source="abuseipdb"} ` + want + "\n"
if !strings.Contains(scraped, line) {
t.Errorf("metrics\n%s\nwant%s", scraped, line)
}
}
})
}
func TestScoreFetchedATTLAgoIsNeitherUsedNorKept(t *testing.T) {
t.Parallel()
now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
p := abuseIPDBParams()
p.Now = func() time.Time { return now }
checker := reputation.NewAbuseIPDB(p)
// The last score still in use, and one, of other's /64, fetched a TTL
// ago.
inUse := reputation.Score{
Client: netip.MustParsePrefix(suspect + "/32"), Score: 100,
Fetched: now.Add(-cacheTTL + time.Nanosecond),
}
stale := reputation.Score{
Client: netip.MustParsePrefix("2001:db8::/64"), Score: 100,
Fetched: now.Add(-cacheTTL),
}
checker.Load(reputation.Checks{Scores: []reputation.Score{stale, inUse}})
wantScore(t, checker, suspect, false, 100, true)
wantScore(t, checker, other, false, 0, false)
got := checker.Snapshot().Scores
if !reflect.DeepEqual(got, []reputation.Score{inUse}) {
t.Errorf("scores %+v, want only %+v", got, inUse)
}
}
func TestAtMost100000ScoresKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.T) {
t.Parallel()
now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
p := abuseIPDBParams()
p.Now = func() time.Time { return now }
checker := reputation.NewAbuseIPDB(p)
// 100,001 scores, listed by client, as reputation.json lists them, each
// fetched a millisecond before the one before it: the last is one too
// many.
const count = 100001
scores := make([]reputation.Score, 0, count)
addr := netip.MustParseAddr("198.18.0.0")
for i := range count {
scores = append(scores, reputation.Score{
Client: netip.PrefixFrom(addr, 32),
Fetched: now.Add(-time.Duration(i) * time.Millisecond),
})
addr = addr.Next()
}
checker.Load(reputation.Checks{Scores: scores})
got := checker.Snapshot().Scores
if len(got) != count-1 || !slices.Contains(got, scores[0]) ||
slices.Contains(got, scores[count-1]) {
t.Errorf("%d scores kept, want all but the one fetched longest ago", len(got))
}
}
// abuseIPDBStandIn is a stand-in for AbuseIPDB. It answers a check sent
// with key by the client's score, as scores gives it, 0 for a client it
// does not give; a check sent with another key with 401; and, while
// status is not 0, every check with status and body; and while hanging,
// none at all. It notes each client checked, and the key sent.
type abuseIPDBStandIn struct {
mu sync.Mutex
scores map[string]int64
status int
body string
hanging bool
checked []string
keys []string
}
// RoundTrip has the stand-in answer req, in place of the network. A check
// abandoned before the stand-in answers fails, as over the network.
func (s *abuseIPDBStandIn) RoundTrip(req *http.Request) (*http.Response, error) {
client := req.URL.Query().Get("ipAddress")
sent := req.Header.Get("Key")
s.mu.Lock()
s.checked = append(s.checked, client)
s.keys = append(s.keys, sent)
score := s.scores[client]
status, body, hanging := s.status, s.body, s.hanging
s.mu.Unlock()
switch {
case hanging:
<-req.Context().Done()
return nil, req.Context().Err()
case sent != key:
status = http.StatusUnauthorized
case status == 0:
status = http.StatusOK
body = fmt.Sprintf(`{"data":{"ipAddress":%q,"abuseConfidenceScore":%d}}`, client,
score)
}
return &http.Response{
StatusCode: status,
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
Header: http.Header{},
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
}
// setScore has the stand-in give client score.
func (s *abuseIPDBStandIn) setScore(client string, score int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.scores[client] = score
}
// answerWith has the stand-in answer every check with status and body.
func (s *abuseIPDBStandIn) answerWith(status int, body string) {
s.mu.Lock()
defer s.mu.Unlock()
s.status, s.body = status, body
}
// abuseIPDBParams returns the AbuseIPDBParams of the tests: key, a minimum
// score of 75, a daily budget of 900, and the cache TTL and timeout of the
// DNSBL tests, by the bubble's clock, with alerts to a queue that sends
// none.
func abuseIPDBParams() reputation.AbuseIPDBParams {
return reputation.AbuseIPDBParams{
URL: "https://abuseipdb.example/api/v2/check",
Key: key,
MinScore: 75,
DailyBudget: 900,
CacheTTL: cacheTTL,
Timeout: timeout,
Now: time.Now,
ProcessLog: slog.New(slog.DiscardHandler),
Alerts: newQueue(),
}
}
// newAbuseIPDB returns the AbuseIPDB of p, checking clients with
// abuseIPDB.
func newAbuseIPDB(
abuseIPDB *abuseIPDBStandIn, p reputation.AbuseIPDBParams,
) *reputation.AbuseIPDB {
checker := reputation.NewAbuseIPDB(p)
checker.SetTransport(abuseIPDB)
return checker
}
// wantScore checks the score checker gives client, and whether it is a
// hit, as a request from client finds them, offender or not.
func wantScore(
t *testing.T, checker *reputation.AbuseIPDB, client string, offender bool,
score int64, hit bool,
) {
t.Helper()
gotScore, gotHit := hitFrom(t, checker, client, offender)
if gotScore != score || gotHit != hit {
t.Errorf("%s has the score %d, a hit %t, want %d, %t", client, gotScore, gotHit,
score, hit)
}
}
// hitFrom is checker's Hit for a request from address, offender or not.
// Its client is address for an IPv4 address, and its /64 for an IPv6 one,
// as smallwebwaf counts clients.
func hitFrom(
t *testing.T, checker *reputation.AbuseIPDB, address string, offender bool,
) (int64, bool) {
t.Helper()
addr := netip.MustParseAddr(address)
client := netip.PrefixFrom(addr, addr.BitLen())
if addr.Is6() {
client = netip.PrefixFrom(addr, 64).Masked()
}
return checker.Hit(t.Context(), client, addr, offender)
}
// wantChecked checks the clients the stand-in was asked about, in any
// order.
func wantChecked(t *testing.T, abuseIPDB *abuseIPDBStandIn, want ...string) {
t.Helper()
abuseIPDB.mu.Lock()
got := slices.Sorted(slices.Values(abuseIPDB.checked))
abuseIPDB.mu.Unlock()
want = slices.Sorted(slices.Values(want))
if !slices.Equal(got, want) {
t.Errorf("checked %v, want %v", got, want)
}
}
// wantFailures checks how many checks failed.
func wantFailures(t *testing.T, checker *reputation.AbuseIPDB, want int) {
t.Helper()
if got := checker.Failures(); got != want {
t.Errorf("%d checks failed, want %d", got, want)
}
}
// wantFailureAlert checks that the one alert waiting in queue is a
// source_failure alert from AbuseIPDB, raised at raised, with reason and
// the error failure, and that the cooldown has held back held repeats of
// it.
func wantFailureAlert(
t *testing.T, queue *alerts.Queue, raised time.Time, reason, failure string,
held int64,
) {
t.Helper()
got := waiting(queue)
if len(got) != 1 || !got[0].Time.Equal(raised) ||
got[0].Event != alerts.EventSourceFailure || got[0].Reason != reason ||
got[0].Detail["source"] != reputation.AbuseIPDBSource ||
got[0].Detail["error"] != failure || queue.Suppressed() != held {
t.Errorf("alerts waiting %+v, %d held back, want only AbuseIPDB's %q with %q, "+
"and %d", got, queue.Suppressed(), reason, failure, held)
}
}
// wantBudgetLeft checks how many checks the day's budget has left.
func wantBudgetLeft(t *testing.T, checker *reputation.AbuseIPDB, want int) {
t.Helper()
if got := checker.BudgetLeft(); got != want {
t.Errorf("%d checks left, want %d", got, want)
}
}
// scrapeMetrics returns the metrics m serves.
func scrapeMetrics(t *testing.T, m *metrics.Metrics) string {
t.Helper()
scraped := httptest.NewRecorder()
m.ServeHTTP(scraped, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/",
http.NoBody))
return scraped.Body.String()
}
+6 -9
View File
@@ -20,16 +20,17 @@ import (
) )
const ( const (
// maxVerdicts is how many verdicts are kept. Past it, the one fetched // maxVerdicts is how many verdicts of the DNSBL zones are kept, and how
// longest ago is dropped. // many scores of AbuseIPDB. Past it, the one fetched longest ago is
// dropped.
maxVerdicts = 100000 maxVerdicts = 100000
// maxQueries is how many queries may be under way at once. Past it, a // maxQueries is how many queries may be under way at once. Past it, a
// zone is not asked about a client until the client's next request, so // zone is not asked about a client until the client's next request, so
// that a swarm of new addresses cannot fill the memory. // that a swarm of new addresses cannot fill the memory.
maxQueries = 1000 maxQueries = 1000
// failureDelay is how long a zone is not asked again after a query to // failureDelay is how long a zone is not asked again after a query to
// it fails, so that a zone refusing queries is not asked on every // it fails, and no client is checked with AbuseIPDB after a check
// request. // fails, so that a source refusing them is not asked on every request.
failureDelay = time.Minute failureDelay = time.Minute
) )
@@ -261,11 +262,7 @@ func (d *DNSBL) ask(ctx context.Context, q query) {
// Raised before it is logged, so that the alert is there once the // Raised before it is logged, so that the alert is there once the
// log line is. // log line is.
d.params.Alerts.Raise(alerts.Alert{ raiseFailure(d.params.Alerts, failed, shown, err)
Event: alerts.EventSourceFailure,
Reason: failed,
Detail: map[string]any{"source": shown, "error": err.Error()},
})
d.params.ProcessLog.Warn(failed, "zone", shown, "error", err.Error()) d.params.ProcessLog.Warn(failed, "zone", shown, "error", err.Error())
} }
} }
+6
View File
@@ -13,6 +13,12 @@ func (l *Lists) SetTransport(transport http.RoundTripper) {
l.httpClient.Transport = transport l.httpClient.Transport = transport
} }
// SetTransport has a's checks go through transport instead of the
// network.
func (a *AbuseIPDB) SetTransport(transport http.RoundTripper) {
a.httpClient.Transport = transport
}
// SetDial has d's queries go through dial instead of the network. // SetDial has d's queries go through dial instead of the network.
func (d *DNSBL) SetDial( func (d *DNSBL) SetDial(
dial func(ctx context.Context, network, address string) (net.Conn, error), dial func(ctx context.Context, network, address string) (net.Conn, error),
+16 -8
View File
@@ -3,9 +3,10 @@
// SWWAF_ASN_LIMIT_PERCENT_URL names. It keeps the last good copy of each, // SWWAF_ASN_LIMIT_PERCENT_URL names. It keeps the last good copy of each,
// whole, comment lines included, which is used while a fetch fails, and // whole, comment lines included, which is used while a fetch fails, and
// when each was last tried. It also asks the DNSBL zones of // when each was last tried. It also asks the DNSBL zones of
// SWWAF_DNSBL_ZONES about clients, and keeps their verdicts. The state // SWWAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks
// package writes all of these to reputation.json and reads them from it, // clients with AbuseIPDB, and keeps their scores and the checks spent
// so that a restart keeps them too. // today. The state package writes all of these to reputation.json and
// reads them from it, so that a restart keeps them too.
package reputation package reputation
import ( import (
@@ -329,11 +330,7 @@ func (l *Lists) fetch(ctx context.Context, listURL string) {
// Raised before it is logged, so that the alert is there once the // Raised before it is logged, so that the alert is there once the
// log line is. // log line is.
l.params.Alerts.Raise(alerts.Alert{ raiseFailure(l.params.Alerts, failed, listURL, err)
Event: alerts.EventSourceFailure,
Reason: failed,
Detail: map[string]any{"source": listURL, "error": err.Error()},
})
l.params.ProcessLog.Warn(failed, "url", listURL, "error", err.Error()) l.params.ProcessLog.Warn(failed, "url", listURL, "error", err.Error())
return return
@@ -342,6 +339,17 @@ func (l *Lists) fetch(ctx context.Context, listURL string) {
l.params.ProcessLog.Info("fetched a list", "url", listURL, "lines", len(lines)) l.params.ProcessLog.Info("fetched a list", "url", listURL, "lines", len(lines))
} }
// raiseFailure raises a source_failure alert into queue, with reason, and
// in its detail the source that failed, a list's URL, a zone with its key
// masked or abuseipdb, and err.
func raiseFailure(queue *alerts.Queue, reason, source string, err error) {
queue.Raise(alerts.Alert{
Event: alerts.EventSourceFailure,
Reason: reason,
Detail: map[string]any{"source": source, "error": err.Error()},
})
}
// get fetches the list at listURL, and returns its lines. An answer other // get fetches the list at listURL, and returns its lines. An answer other
// than 200, or a list longer than maxListBytes, is a failure. // than 200, or a list longer than maxListBytes, is a failure.
func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) { func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
+4 -2
View File
@@ -36,7 +36,8 @@ const (
ActionRuleBlocked = "rule_blocked" ActionRuleBlocked = "rule_blocked"
// ActionDenied is a request refused because its client is in // ActionDenied is a request refused because its client is in
// SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny, // SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny,
// or listed by a DNSBL zone while SWWAF_REPUTATION_ACTION is deny. // or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
// SWWAF_REPUTATION_ACTION is deny.
ActionDenied = "denied" ActionDenied = "denied"
// ActionCountryDenied is a request refused for its client's country. // ActionCountryDenied is a request refused for its client's country.
ActionCountryDenied = "country_denied" ActionCountryDenied = "country_denied"
@@ -139,7 +140,8 @@ type Line struct {
// minute_bytes, hour_bytes or day_bytes for a byte limit. // minute_bytes, hour_bytes or day_bytes for a byte limit.
LimitHit string `json:"limit_hit,omitempty"` LimitHit string `json:"limit_hit,omitempty"`
// Reputation are the URLs of the blocklists that list the client, then // Reputation are the URLs of the blocklists that list the client, then
// the DNSBL zones whose verdict lists it, their keys masked. // the DNSBL zones whose verdict lists it, their keys masked, then
// abuseipdb when its score is a hit.
Reputation []string `json:"reputation,omitempty"` Reputation []string `json:"reputation,omitempty"`
// Offence is the offence the request was held as, OffenceLimit. // Offence is the offence the request was held as, OffenceLimit.
Offence string `json:"offence,omitempty"` Offence string `json:"offence,omitempty"`
+11 -8
View File
@@ -21,6 +21,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/proxy" "sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/remotelog" "sneak.berlin/go/smallwebwaf/internal/remotelog"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/state" "sneak.berlin/go/smallwebwaf/internal/state"
@@ -169,14 +170,15 @@ func newServer(
} }
server := proxy.New(proxy.Params{ server := proxy.New(proxy.Params{
Config: cfg, Config: cfg,
RequestLog: stdout, RequestLog: stdout,
ProcessLog: processLog, ProcessLog: processLog,
GeoJSURL: lookup.URL, GeoJSURL: lookup.URL,
LookupFile: lookupFile, AbuseIPDBURL: reputation.AbuseIPDBURL,
Now: now, LookupFile: lookupFile,
Rules: ruleFiles, Now: now,
Alerts: alertQueue, Rules: ruleFiles,
Alerts: alertQueue,
}) })
server.Metrics.AddAlerts(alertQueue) server.Metrics.AddAlerts(alertQueue)
@@ -202,6 +204,7 @@ func loadStateFiles(
GeoJS: server.GeoJS, GeoJS: server.GeoJS,
Lists: server.Lists, Lists: server.Lists,
DNSBL: server.DNSBL, DNSBL: server.DNSBL,
AbuseIPDB: server.AbuseIPDB,
Alerts: alertQueue, Alerts: alertQueue,
Anomalies: server.Anomalies, Anomalies: server.Anomalies,
Now: now, Now: now,
+47 -12
View File
@@ -2,8 +2,8 @@
// SWWAF_STATE_DIR, as the "Persistent state" section of SPEC.md describes: // SWWAF_STATE_DIR, as the "Persistent state" section of SPEC.md describes:
// bans.json holds the bans, clients.json each client's counters and // bans.json holds the bans, clients.json each client's counters and
// history, lookups.json GeoJS's answers, reputation.json the last try and // history, lookups.json GeoJS's answers, reputation.json the last try and
// last good copy of each list fetched from a URL and the DNSBL zones' // last good copy of each list fetched from a URL, the DNSBL zones'
// verdicts, and alerts.json the // verdicts, and AbuseIPDB's scores and checks spent, and alerts.json the
// cooldowns, the hour under way, the alerts waiting for each destination // cooldowns, the hour under way, the alerts waiting for each destination
// and the anomaly counters. Load // and the anomaly counters. Load
// reads them at start, Watch takes in an admin's edit of one while // reads them at start, Watch takes in an admin's edit of one while
@@ -77,14 +77,15 @@ type Params struct {
// is (SWWAF_STATE_COUNTER_INTERVAL). // is (SWWAF_STATE_COUNTER_INTERVAL).
WriteDelay time.Duration WriteDelay time.Duration
CounterInterval time.Duration CounterInterval time.Duration
// Ledger, Limiter, GeoJS, Lists, DNSBL, Alerts and Anomalies hold the // Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies
// state. Alerts also receive a file_error alert for an edit set aside, // hold the state. Alerts also receive a file_error alert for an edit set
// and for a write that fails while smallwebwaf runs. // aside, and for a write that fails while smallwebwaf runs.
Ledger *bans.Ledger Ledger *bans.Ledger
Limiter *ratelimit.Limiter Limiter *ratelimit.Limiter
GeoJS *lookup.GeoJS GeoJS *lookup.GeoJS
Lists *reputation.Lists Lists *reputation.Lists
DNSBL *reputation.DNSBL DNSBL *reputation.DNSBL
AbuseIPDB *reputation.AbuseIPDB
Alerts *alerts.Queue Alerts *alerts.Queue
Anomalies *anomaly.Counters Anomalies *anomaly.Counters
// Now tells the time by which the counters' buckets run out, normally // Now tells the time by which the counters' buckets run out, normally
@@ -147,9 +148,10 @@ type lookupsFile struct {
// reputationFile is reputation.json, indented for an admin to read and // reputationFile is reputation.json, indented for an admin to read and
// edit, so that each line of a list's copy is on a line of its own. // edit, so that each line of a list's copy is on a line of its own.
type reputationFile struct { type reputationFile struct {
Version int `json:"version"` Version int `json:"version"`
Lists []reputation.List `json:"lists"` Lists []reputation.List `json:"lists"`
Verdicts []reputation.Verdict `json:"verdicts"` Verdicts []reputation.Verdict `json:"verdicts"`
AbuseIPDB reputation.Checks `json:"abuseipdb"`
} }
// alertsFile is alerts.json, indented for an admin to read and edit. // alertsFile is alerts.json, indented for an admin to read and edit.
@@ -436,6 +438,7 @@ func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) {
} }
f.params.DNSBL.Load(file.Verdicts) f.params.DNSBL.Load(file.Verdicts)
f.params.AbuseIPDB.Load(file.AbuseIPDB)
entries = len(file.Lists) entries = len(file.Lists)
case alertsJSON: case alertsJSON:
waiting, err := f.takeInAlerts(path, data) waiting, err := f.takeInAlerts(path, data)
@@ -571,7 +574,7 @@ func (f *Files) encode(name string) ([]byte, error) {
case reputationJSON: case reputationJSON:
return encodeIndented(reputationFile{ return encodeIndented(reputationFile{
Version: version, Lists: f.params.Lists.Snapshot(), Version: version, Lists: f.params.Lists.Snapshot(),
Verdicts: f.params.DNSBL.Snapshot(), Verdicts: f.params.DNSBL.Snapshot(), AbuseIPDB: f.params.AbuseIPDB.Snapshot(),
}) })
default: // alerts.json default: // alerts.json
held := f.params.Alerts.Snapshot() held := f.params.Alerts.Snapshot()
@@ -736,9 +739,11 @@ func (f *lookupsFile) check(data []byte) error {
// of it without the time it was fetched, or without its lines, which hold // of it without the time it was fetched, or without its lines, which hold
// the list. It refuses a verdict without its zone or its client, which // the list. It refuses a verdict without its zone or its client, which
// would be about no one, whether the zone lists the client, or the time // would be about no one, whether the zone lists the client, or the time
// it was fetched, which would drop it. A verdict's listed is false for a // it was fetched, which would drop it, and so an AbuseIPDB score without
// client the zone does not list, which Verdicts cannot tell from a // its client, the score, or the time it was fetched. A verdict's listed is
// missing one, so each listed is read again as written. // false for a client the zone does not list, and a score can be 0, which
// the structs cannot tell from a missing one, so each is read again as
// written.
func (f *reputationFile) check(data []byte) error { func (f *reputationFile) check(data []byte) error {
for i, kept := range f.Lists { for i, kept := range f.Lists {
switch { switch {
@@ -777,6 +782,36 @@ func (f *reputationFile) check(data []byte) error {
} }
} }
return checkScores(f.AbuseIPDB.Scores, data)
}
// checkScores refuses an AbuseIPDB score, of scores, read from data, as
// reputationFile's check describes.
func checkScores(scores []reputation.Score, data []byte) error {
var written struct {
AbuseIPDB struct {
Scores []struct {
Score *int64 `json:"score"`
} `json:"scores"`
} `json:"abuseipdb"`
}
err := json.Unmarshal(data, &written)
if err != nil {
return err
}
for i, kept := range scores {
switch {
case !kept.Client.IsValid():
return fmt.Errorf("abuseipdb scores %w", missing(i, "client"))
case written.AbuseIPDB.Scores[i].Score == nil:
return fmt.Errorf("abuseipdb scores %w", missing(i, "score"))
case kept.Fetched.IsZero():
return fmt.Errorf("abuseipdb scores %w", missing(i, "fetched"))
}
}
return nil return nil
} }
+93 -8
View File
@@ -212,8 +212,9 @@ const filledAlertsJSON = `{
` `
// filledReputationJSON is reputation.json holding the blocklists' last // filledReputationJSON is reputation.json holding the blocklists' last
// tries and the copy of one, with its comment line, and two verdicts of a // tries and the copy of one, with its comment line, two verdicts of a
// DNSBL zone, as fill puts them in. // DNSBL zone, and the AbuseIPDB checks spent today with two scores, as
// fill puts them in.
const filledReputationJSON = `{ const filledReputationJSON = `{
"version": 1, "version": 1,
"lists": [ "lists": [
@@ -245,7 +246,23 @@ const filledReputationJSON = `{
"listed": false, "listed": false,
"fetched": "2026-10-05T22:00:00Z" "fetched": "2026-10-05T22:00:00Z"
} }
] ],
"abuseipdb": {
"day": "2026-10-06T00:00:00Z",
"spent": 3,
"scores": [
{
"client": "203.0.113.9/32",
"score": 100,
"fetched": "2026-10-05T23:00:00Z"
},
{
"client": "2001:db8::/64",
"score": 0,
"fetched": "2026-10-05T22:00:00Z"
}
]
}
} }
` `
@@ -282,6 +299,11 @@ func TestFilesWrittenAndReadBack(t *testing.T) {
wantEqual(t, reputationJSON, after.DNSBL.Snapshot(), before.DNSBL.Snapshot()) wantEqual(t, reputationJSON, after.DNSBL.Snapshot(), before.DNSBL.Snapshot())
checks, wantChecks := after.AbuseIPDB.Snapshot(), before.AbuseIPDB.Snapshot()
if !reflect.DeepEqual(checks, wantChecks) {
t.Errorf("%s read back\n%+v\nwant\n%+v", reputationJSON, checks, wantChecks)
}
if got, want := after.Alerts.Snapshot(), before.Alerts.Snapshot(); !reflect.DeepEqual( if got, want := after.Alerts.Snapshot(), before.Alerts.Snapshot(); !reflect.DeepEqual(
got, want) { got, want) {
t.Errorf("%s read back\n%+v\nwant\n%+v", alertsJSON, got, want) t.Errorf("%s read back\n%+v\nwant\n%+v", alertsJSON, got, want)
@@ -439,10 +461,13 @@ func TestMissingFilesAreEmptyState(t *testing.T) {
load(t, params) load(t, params)
held := params.Alerts.Snapshot() held := params.Alerts.Snapshot()
checks := params.AbuseIPDB.Snapshot()
if len(params.Ledger.Snapshot()) != 0 || len(params.Limiter.Snapshot()) != 0 || if len(params.Ledger.Snapshot()) != 0 || len(params.Limiter.Snapshot()) != 0 ||
len(params.GeoJS.Snapshot()) != 0 || len(params.Lists.Snapshot()) != 0 || len(params.GeoJS.Snapshot()) != 0 || len(params.Lists.Snapshot()) != 0 ||
len(params.DNSBL.Snapshot()) != 0 || len(held.Cooldowns) != 0 || len(params.DNSBL.Snapshot()) != 0 || len(checks.Scores) != 0 || checks.Spent != 0 ||
len(held.Waiting[alerts.DestinationWebhook]) != 0 || held.Hour.Sent != 0 { len(held.Cooldowns) != 0 || len(held.Waiting[alerts.DestinationWebhook]) != 0 ||
held.Hour.Sent != 0 {
t.Error("state from no files") t.Error("state from no files")
} }
} }
@@ -678,6 +703,47 @@ func TestReputationJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
} }
} }
func TestReputationJSONScoreWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
t.Parallel()
// scores opens the list of AbuseIPDB scores, and ends closes it; client,
// score and fetched make a score.
const (
scores = `{"version": 1, "abuseipdb": {"scores": [`
client = `"client": "198.51.100.7/32", `
score = `"score": 0, `
fetched = `"fetched": "2026-10-06T00:00:00Z"`
ends = `}]}}`
)
for _, tc := range []struct {
name, content string
// want is what the error says after the file's path.
want string
}{
{
"without its client", scores + `{` + score + fetched + ends,
`: abuseipdb scores entry 1 has no "client"`,
},
{
// A score of 0 is not having none.
"without the score",
scores + `{` + client + score + fetched + `}, {` + client + fetched + ends,
`: abuseipdb scores entry 2 has no "score"`,
},
{
"without the time it was fetched", scores + `{` + client + `"score": 100` + ends,
`: abuseipdb scores entry 1 has no "fetched"`,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
wantRefused(t, reputationJSON, tc.content, tc.want)
})
}
}
func TestAlertsJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) { func TestAlertsJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1182,7 +1248,9 @@ func TestEditOfEachFileTakenIn(t *testing.T) {
edit(t, dir, reputationJSON, `{"version": 1, "lists": [{"url": "`+blocklistURL+`", `+ edit(t, dir, reputationJSON, `{"version": 1, "lists": [{"url": "`+blocklistURL+`", `+
`"tried": "2026-10-06T00:00:00Z", "fetched": "2026-10-06T00:00:00Z", `+ `"tried": "2026-10-06T00:00:00Z", "fetched": "2026-10-06T00:00:00Z", `+
`"lines": ["198.51.100.7"]}], "verdicts": [{"zone": "`+dnsblZone+`", `+ `"lines": ["198.51.100.7"]}], "verdicts": [{"zone": "`+dnsblZone+`", `+
`"client": "198.51.100.7", "listed": true, "fetched": "2026-10-06T00:00:00Z"}]}`) `"client": "198.51.100.7", "listed": true, "fetched": "2026-10-06T00:00:00Z"}], `+
`"abuseipdb": {"day": "2026-10-06T00:00:00Z", "spent": 9, "scores": [`+
`{"client": "198.51.100.7/32", "score": 80, "fetched": "2026-10-06T00:00:00Z"}]}}`)
wantTakenIn(t, lines, dir, reputationJSON) wantTakenIn(t, lines, dir, reputationJSON)
listedBy := params.Lists.ListedBy(client.Addr()) listedBy := params.Lists.ListedBy(client.Addr())
@@ -1195,6 +1263,14 @@ func TestEditOfEachFileTakenIn(t *testing.T) {
Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: midnight(), Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: midnight(),
}}) }})
checks := reputation.Checks{
Day: midnight(), Spent: 9,
Scores: []reputation.Score{{Client: client, Score: 80, Fetched: midnight()}},
}
if got := params.AbuseIPDB.Snapshot(); !reflect.DeepEqual(got, checks) {
t.Errorf("%s taken in as\n%+v\nwant\n%+v", reputationJSON, got, checks)
}
// A netblock with bits past its length is read as the netblock it is // A netblock with bits past its length is read as the netblock it is
// in. // in.
edit(t, dir, alertsJSON, `{"version": 1, "cooldowns": [{"event": "ban", `+ edit(t, dir, alertsJSON, `{"version": 1, "cooldowns": [{"event": "ban", `+
@@ -1615,6 +1691,10 @@ func newParams(dir string) state.Params {
Zones: []string{dnsblZone}, CacheTTL: 24 * time.Hour, Timeout: time.Second, Zones: []string{dnsblZone}, CacheTTL: 24 * time.Hour, Timeout: time.Second,
Now: midnight, ProcessLog: discard, Alerts: queue, Now: midnight, ProcessLog: discard, Alerts: queue,
}), }),
AbuseIPDB: reputation.NewAbuseIPDB(reputation.AbuseIPDBParams{
MinScore: 75, DailyBudget: 900, CacheTTL: 24 * time.Hour, Timeout: time.Second,
Now: midnight, ProcessLog: discard, Alerts: queue,
}),
Alerts: queue, Alerts: queue,
Anomalies: anomaly.New(anomaly.Params{ Anomalies: anomaly.New(anomaly.Params{
Net: anomaly.Thresholds{RequestsPerMinute: 1000}, Net: anomaly.Thresholds{RequestsPerMinute: 1000},
@@ -1639,8 +1719,9 @@ func office() netip.Prefix {
// fill puts a permanent ban an admin made, a ban for a broken limit and // fill puts a permanent ban an admin made, a ban for a broken limit and
// one for a clear sign of attack, clients with counts and histories, // one for a clear sign of attack, clients with counts and histories,
// GeoJS answers, the blocklists' last tries and the copy of one, and two // GeoJS answers, the blocklists' last tries and the copy of one, two
// verdicts of a DNSBL zone, as filledReputationJSON holds them, and alerts // verdicts of a DNSBL zone, and the AbuseIPDB checks spent today with two
// scores, as filledReputationJSON holds them, and alerts
// and anomaly counters, as filledAlertsJSON holds them, into the parts of // and anomaly counters, as filledAlertsJSON holds them, into the parts of
// params. // params.
func fill(params state.Params) { func fill(params state.Params) {
@@ -1696,6 +1777,10 @@ func fill(params state.Params) {
}, },
{Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: now.Add(-time.Hour)}, {Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: now.Add(-time.Hour)},
}) })
params.AbuseIPDB.Load(reputation.Checks{Day: now, Spent: 3, Scores: []reputation.Score{
{Client: netip.MustParsePrefix("2001:db8::/64"), Fetched: now.Add(-2 * time.Hour)},
{Client: client, Score: 100, Fetched: now.Add(-time.Hour)},
}})
// An alert waiting, a repeat of it the cooldown holds back, another // An alert waiting, a repeat of it the cooldown holds back, another
// alert waiting, and one past the two an hour, for the hour's summary. // alert waiting, and one past the two an hour, for the hour's summary.