AbuseIPDB scores for clients that committed an offence, within a daily budget (closes #105)
check / check (push) Waiting to run
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
This commit is contained in:
@@ -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
|
||||
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
|
||||
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
|
||||
file of AS numbers' percentages fetched the same way, and the DNS blocklists
|
||||
(DNSBL zones), which it asks about each client in the background. `smallwebwaf`
|
||||
passes each request to the app and the app's answer back, unchanged, within its
|
||||
timeouts and size limits, works out each client's address, looks up its AS
|
||||
number and country unless you switch that off, bans a client that sends too many
|
||||
requests or too many bytes, not counting those for the paths you choose, with
|
||||
lower limits for the clients of the AS numbers and countries you list, refuses a
|
||||
client that comes from a country you refuse or from a network you refuse,
|
||||
refuses, limits or only notes a client a blocklist or a DNSBL zone you name
|
||||
lists, lets the networks you choose through, checks each request against the
|
||||
rule files and bans a client whose request is a clear sign of attack, keeps its
|
||||
bans, each client's counters and history, GeoJS's answers, the last good copy of
|
||||
each list it fetches and the DNSBL zones' verdicts in JSON files across
|
||||
restarts, takes in your edits of those files, such as a ban you make, keep or
|
||||
lift, and of the rule files while it runs, writes a JSON log line for every
|
||||
request, sends its log lines to a syslog server too if you name one, sends an
|
||||
alert to a webhook, to Slack and to ntfy, each if you name one, for each ban it
|
||||
makes or makes permanent, for traffic over an anomaly threshold you set, for a
|
||||
client a blocklist or a DNSBL zone lists, for GeoJS failing, a list it cannot
|
||||
fetch or a DNSBL zone that fails or refuses a query, for a rule file or state
|
||||
file with an error and for a replacement of the lookup database it cannot read,
|
||||
serves Prometheus metrics to a scraper that holds the metrics token, lets an
|
||||
admin who holds the admin token list, add and lift bans and ask what it knows of
|
||||
a client, and in `observe` mode passes on the requests 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).
|
||||
file of AS numbers' percentages fetched the same way, the DNS blocklists (DNSBL
|
||||
zones), which it asks about each client in the background, and AbuseIPDB, which
|
||||
it asks in the background about each client that has committed an offence.
|
||||
`smallwebwaf` passes each request to the app and the app's answer back,
|
||||
unchanged, within its timeouts and size limits, works out each client's address,
|
||||
looks up its AS number and country unless you switch that off, bans a client
|
||||
that sends too many requests or too many bytes, not counting those for the paths
|
||||
you choose, with lower limits for the clients of the AS numbers and countries
|
||||
you list, refuses a client that comes from a country you refuse or from a
|
||||
network you refuse, refuses, limits or only notes a client a blocklist or a
|
||||
DNSBL zone you name lists, or AbuseIPDB scores at or over the score you set,
|
||||
lets the networks you choose through, checks each request against the rule files
|
||||
and bans a client whose request is a clear sign of attack, keeps its bans, each
|
||||
client's counters and history, GeoJS's answers, the last good copy of each list
|
||||
it fetches, the DNSBL zones' verdicts, AbuseIPDB's scores and the AbuseIPDB
|
||||
checks spent today in JSON files across restarts, takes in your edits of those
|
||||
files, such as a ban you make, keep or lift, and of the rule files while it
|
||||
runs, writes a JSON log line for every request, sends its log lines to a syslog
|
||||
server too if you name one, sends an alert to a webhook, to Slack and to ntfy,
|
||||
each if you name one, for each ban it makes or makes permanent, for traffic over
|
||||
an anomaly threshold you set, for a client a blocklist, a DNSBL zone or
|
||||
AbuseIPDB lists, for GeoJS failing, a list it cannot fetch, a DNSBL zone or
|
||||
AbuseIPDB that fails or refuses a query and the day's AbuseIPDB checks used up,
|
||||
for a rule file or state file with an error and for a replacement of the lookup
|
||||
database it cannot read, serves Prometheus metrics to a scraper that holds the
|
||||
metrics token, lets an admin who holds the admin token list, add and lift bans
|
||||
and ask what it knows of a client, and in `observe` mode passes on the requests
|
||||
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
|
||||
|
||||
@@ -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
|
||||
request's log line names the zones, and each raises an alert. A client a
|
||||
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
|
||||
settings below, before anything else, its lookup included. A client in
|
||||
`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
|
||||
up; the timeouts and size limits still apply. A client in `SWWAF_DENY_NETS` is
|
||||
refused with `SWWAF_BAN_RESPONSE` before its body is read, and the request is
|
||||
not counted for the rate limits; an address in `SWWAF_ALLOW_NETS` too is let
|
||||
through. A client in `SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor
|
||||
refused by the rate limits, and has no bytes counted by the byte limits; the
|
||||
country lists, the rule files and bans still apply to it.
|
||||
zones, AbuseIPDB, the rate limits, the byte limits and the rule files, and is
|
||||
not looked up; the timeouts and size limits still apply. A client in
|
||||
`SWWAF_DENY_NETS` is refused with `SWWAF_BAN_RESPONSE` before its body is
|
||||
read, and the request is not counted for the rate limits; an address in
|
||||
`SWWAF_ALLOW_NETS` too is let through. A client in
|
||||
`SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor refused by the rate
|
||||
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
|
||||
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
|
||||
their log lines name what `enforce` mode would have done (see `would_action`
|
||||
in "Request log" below). The checks run, and requests and bytes are counted,
|
||||
as in `enforce` mode, with three differences: neither a broken rate limit or
|
||||
byte limit nor a `ban` rule makes a ban; a broken limit does not set the
|
||||
client's counters back to zero, so each request over a rate limit is logged as
|
||||
one that would be refused, and each whose bytes keep the client over a byte
|
||||
limit as breaking it; and a request under a ban does not make it permanent. As
|
||||
in `enforce` mode, the bytes counted are only those of the requests `enforce`
|
||||
mode would have passed to the app. A ban it would have made, or made
|
||||
permanent, raises the alert `enforce` mode would have raised, marked as what
|
||||
would have happened (see "Alerts" below). The bans in `bans.json` are kept,
|
||||
and refuse requests again when `smallwebwaf` next runs in `enforce` mode, as
|
||||
long as they last. The timeouts and size limits still apply, since they
|
||||
protect `smallwebwaf` and the app themselves, and a request for one of
|
||||
`smallwebwaf`'s own endpoints without its token is still answered `401`. It is
|
||||
for trying a configuration before enforcing it.
|
||||
verdict, AbuseIPDB's score, a rate limit or a rule would refuse: it passes
|
||||
them to the app, and their log lines name what `enforce` mode would have done
|
||||
(see `would_action` in "Request log" below). The checks run, and requests and
|
||||
bytes are counted, as in `enforce` mode, with three differences: neither a
|
||||
broken rate limit or byte limit nor a `ban` rule makes a ban; a broken limit
|
||||
does not set the client's counters back to zero, so each request over a rate
|
||||
limit is logged as one that would be refused, and each whose bytes keep the
|
||||
client over a byte limit as breaking it; and a request under a ban does not
|
||||
make it permanent. As in `enforce` mode, the bytes counted are only those of
|
||||
the requests `enforce` mode would have passed to the app. A ban it would have
|
||||
made, or made permanent, raises the alert `enforce` mode would have raised,
|
||||
marked as what would have happened (see "Alerts" below). The bans in
|
||||
`bans.json` are kept, and refuse requests again when `smallwebwaf` next runs
|
||||
in `enforce` mode, as long as they last. The timeouts and size limits still
|
||||
apply, since they protect `smallwebwaf` and the app themselves, and a request
|
||||
for one of `smallwebwaf`'s own endpoints without its token is still answered
|
||||
`401`. It is for trying a configuration before enforcing it.
|
||||
- Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any
|
||||
check and without asking the app, for the image's health check.
|
||||
- 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"
|
||||
below).
|
||||
- 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
|
||||
failing, a list it cannot fetch or a DNSBL zone that fails or refuses a query,
|
||||
for a rule file or state file with an error, and for a replacement of the
|
||||
lookup database it cannot read, holding back repeats and, past an hourly
|
||||
limit, rolling the rest into one summary, to each destination you name: as a
|
||||
JSON object to the webhook `SWWAF_ALERT_WEBHOOK_URL` names, as a message to
|
||||
the Slack incoming webhook `SWWAF_ALERT_SLACK_WEBHOOK_URL` names, and as a
|
||||
message to the ntfy topic `SWWAF_ALERT_NTFY_URL` names (see "Alerts" below).
|
||||
anomaly threshold, for a client a blocklist, a DNSBL zone or AbuseIPDB lists,
|
||||
for GeoJS failing, a list it cannot fetch, a DNSBL zone or AbuseIPDB that
|
||||
fails or refuses a query, and the day's AbuseIPDB checks used up, for a rule
|
||||
file or state file with an error, and for a replacement of the lookup database
|
||||
it cannot read, holding back repeats and, past an hourly limit, rolling the
|
||||
rest into one summary, to each destination you name: as a JSON object to the
|
||||
webhook `SWWAF_ALERT_WEBHOOK_URL` names, as a message to the Slack incoming
|
||||
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
|
||||
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
|
||||
@@ -340,8 +355,9 @@ effective settings are logged at start.
|
||||
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body.
|
||||
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body.
|
||||
- `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip bans, the
|
||||
country lists, the blocklists, the DNSBL zones, the rate limits, the byte
|
||||
limits and the rule files, such as your monitoring or your own networks.
|
||||
country lists, the blocklists, the DNSBL zones, AbuseIPDB, the rate limits,
|
||||
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
|
||||
rate limits and the byte limits do not apply to, such as a machine that talks
|
||||
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
|
||||
`/etc/resolv.conf` names it. Several zones refuse queries that come through a
|
||||
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
|
||||
zone's verdict lists, as `SWWAF_BLOCKLIST_ACTION` is for a blocklist: `deny`,
|
||||
`limit:<percent>` or `log`. A zone's verdict is less certain than a list such
|
||||
as DROP, so by default a client it lists gets a quarter of every rate limit
|
||||
and byte limit.
|
||||
zone's verdict lists, or whose AbuseIPDB score is a hit, as
|
||||
`SWWAF_BLOCKLIST_ACTION` is for a blocklist: `deny`, `limit:<percent>` or
|
||||
`log`. Such a verdict is less certain than a list such as DROP, so by default
|
||||
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
|
||||
client is used after the zone gave it.
|
||||
- `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone may take
|
||||
before it fails.
|
||||
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, or a
|
||||
check with AbuseIPDB, may take before it fails.
|
||||
- `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
|
||||
`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_REPUTATION_ACTION` is `deny`: `403`, `429`, or `close` to close the
|
||||
connection without an answer. Behind traefik, `close` does not leave the
|
||||
`SWWAF_BLOCKLIST_ACTION` is `deny` or is listed by a DNSBL zone or AbuseIPDB
|
||||
while `SWWAF_REPUTATION_ACTION` is `deny`: `403`, `429`, or `close` to close
|
||||
the connection without an answer. Behind traefik, `close` does not leave the
|
||||
client unanswered: traefik answers `502`, as it does whenever its backend
|
||||
drops a connection. A `block` rule always answers `403`.
|
||||
- `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`
|
||||
or `SWWAF_ALERT_MAX_PER_HOUR` off; `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`,
|
||||
`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_METRICS_TOP_N`, `SWWAF_LOG_REMOTE_BUFFER`, `SWWAF_ANOMALY_NET_V4_PREFIX`
|
||||
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,
|
||||
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
|
||||
anomaly counters, and at most 100,000 verdicts of the DNSBL zones, with at most
|
||||
1,000 queries to them under way at once.
|
||||
anomaly counters, at most 100,000 verdicts of the DNSBL zones, with at most
|
||||
1,000 queries to them under way at once, and at most 100,000 scores of
|
||||
AbuseIPDB.
|
||||
|
||||
### Settings given as files
|
||||
|
||||
@@ -709,30 +734,31 @@ which every line has.
|
||||
- `request_bytes` and `response_bytes` count body bytes.
|
||||
- `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
|
||||
`SWWAF_BLOCKLIST_ACTION` is `deny`, or listed by a DNSBL zone while
|
||||
`SWWAF_REPUTATION_ACTION` is `deny`, `banned` for one refused because a ban
|
||||
covers its client or because it matched a `ban` rule, which bans its client,
|
||||
`country_denied` for one refused for its client's country, `rate_limited` for
|
||||
one that broke a rate limit and banned its client, `rule_blocked` for one a
|
||||
`block` rule refused, `too_large` for a request or response over its size
|
||||
limit, `timed_out` for one that ran out of time, `upstream_error` when the app
|
||||
could not be reached or its answer broke off, and `admin` for one
|
||||
`smallwebwaf` answered at its own endpoint.
|
||||
`SWWAF_BLOCKLIST_ACTION` is `deny`, or listed by a DNSBL zone or AbuseIPDB
|
||||
while `SWWAF_REPUTATION_ACTION` is `deny`, `banned` for one refused because a
|
||||
ban covers its client or because it matched a `ban` rule, which bans its
|
||||
client, `country_denied` for one refused for its client's country,
|
||||
`rate_limited` for one that broke a rate limit and banned its client,
|
||||
`rule_blocked` for one a `block` rule refused, `too_large` for a request or
|
||||
response over its size limit, `timed_out` for one that ran out of time,
|
||||
`upstream_error` when the app could not be reached or its answer broke off,
|
||||
and `admin` for one `smallwebwaf` answered at its own endpoint.
|
||||
- `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
|
||||
verdict, a rate limit or a rule would have refused in `enforce` mode, and
|
||||
names the action that refusal would have had: `denied`, `banned`,
|
||||
`country_denied`, `rate_limited` or `rule_blocked`. `action` then names what
|
||||
was done: `forward` for a request passed to the app, and another action, such
|
||||
as `too_large`, for one a size or time limit refused.
|
||||
verdict, AbuseIPDB's score, a rate limit or a rule would have refused in
|
||||
`enforce` mode, and names the action that refusal would have had: `denied`,
|
||||
`banned`, `country_denied`, `rate_limited` or `rule_blocked`. `action` then
|
||||
names what was done: `forward` for a request passed to the app, and another
|
||||
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
|
||||
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
|
||||
than the whole of the rate limits, and gives the percentage it gets, with
|
||||
`limit_percent_setting` naming the setting that gave it, such as
|
||||
`SWWAF_ASN_LIMIT_PERCENT`, or `SWWAF_ASN_LIMIT_PERCENT_URL` for the file it
|
||||
names. `bytes_percent` and `bytes_percent_setting` are the same for the byte
|
||||
limits. Each is left out when the client gets the whole of those limits.
|
||||
`SWWAF_REPUTATION_ACTION` for a DNSBL zone whose verdict lists it or an
|
||||
AbuseIPDB score that is a hit, gives less than the whole of the rate limits,
|
||||
and gives the percentage it gets, with `limit_percent_setting` naming the
|
||||
setting that gave it, such as `SWWAF_ASN_LIMIT_PERCENT`, or
|
||||
`SWWAF_ASN_LIMIT_PERCENT_URL` for the file it names. `bytes_percent` and
|
||||
`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
|
||||
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
|
||||
@@ -741,11 +767,11 @@ which every line has.
|
||||
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_PATHS` exempts, and one that `SWWAF_DENY_NETS`, a
|
||||
ban, the country lists, a blocklist or a DNSBL zone's verdict refuse, or would
|
||||
refuse in `observe` mode. Its `minute_bytes`, `hour_bytes` and `day_bytes`
|
||||
give the client's bytes in each window as the byte limits count them, in the
|
||||
same way: for a request whose bytes they count, with its own, once it has
|
||||
ended; for any other, those counted before it.
|
||||
ban, the country lists, a blocklist, a DNSBL zone's verdict or AbuseIPDB's
|
||||
score refuse, or would refuse in `observe` mode. Its `minute_bytes`,
|
||||
`hour_bytes` and `day_bytes` give the client's bytes in each window as the
|
||||
byte limits count them, in the same way: for a request whose bytes they count,
|
||||
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
|
||||
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
|
||||
@@ -756,15 +782,17 @@ which every line has.
|
||||
is not refused: its `action` is what it would have been otherwise, such as
|
||||
`forward`.
|
||||
- `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
|
||||
`SWWAF_BLOCKLIST_URLS` names them, then the zones whose verdict lists it, in
|
||||
the order `SWWAF_DNSBL_ZONES` names them, whatever `SWWAF_BLOCKLIST_ACTION`
|
||||
and `SWWAF_REPUTATION_ACTION` say. A zone whose verdict on the client has not
|
||||
come yet, or was given `SWWAF_REPUTATION_CACHE_TTL` ago or more, is not named.
|
||||
It is left out for a client the blocklists are not checked for: one in
|
||||
`SWWAF_ALLOW_NETS`, and one `SWWAF_DENY_NETS`, a ban or the country lists
|
||||
refuse first, or would in `observe` mode. The zones are not checked either for
|
||||
a client a blocklist refuses, or would in `observe` mode.
|
||||
verdict lists, or whose AbuseIPDB score is a hit, and gives the URLs of the
|
||||
blocklists that list it, in the order `SWWAF_BLOCKLIST_URLS` names them, then
|
||||
the zones whose verdict lists it, in the order `SWWAF_DNSBL_ZONES` names them,
|
||||
then `abuseipdb`, whatever `SWWAF_BLOCKLIST_ACTION` and
|
||||
`SWWAF_REPUTATION_ACTION` say. A zone whose verdict on the client has not come
|
||||
yet, or was given `SWWAF_REPUTATION_CACHE_TTL` ago or more, is not named, nor
|
||||
is AbuseIPDB for such a score. It is left out for a client the blocklists are
|
||||
not checked for: one in `SWWAF_ALLOW_NETS`, and one `SWWAF_DENY_NETS`, a ban
|
||||
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,
|
||||
or in `observe` mode would have been refused under one, and gives when the ban
|
||||
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
|
||||
`enforce` mode. It refuses and bans nothing.
|
||||
- `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
|
||||
as in `enforce` mode.
|
||||
- `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
|
||||
refused (see "DNS blocklists" below).
|
||||
failing (see "Blocklists" below), a query to a DNSBL zone failing or refused
|
||||
(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
|
||||
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
|
||||
@@ -913,7 +943,8 @@ is sent on one line:
|
||||
- `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
|
||||
`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
|
||||
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`,
|
||||
@@ -921,11 +952,12 @@ is sent on one line:
|
||||
`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
|
||||
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
|
||||
`source`, `geojs`, the URL of the list or the zone, the `error`, and for
|
||||
GeoJS, when it is asked again, `asking_again_in`; for `file_error`, the
|
||||
`file`, which for an 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.
|
||||
the `source`, the URL of the blocklist, the zone or `abuseipdb`, and for
|
||||
AbuseIPDB the `score`; for `source_failure`, the `source`, `geojs`, the URL of
|
||||
the list, the zone or `abuseipdb`, the `error`, and for GeoJS, when it is
|
||||
asked again, `asking_again_in`; for `file_error`, the `file`, which for an
|
||||
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
|
||||
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
|
||||
a `reputation_hit` about the same blocklist or zone, or for a `file_error` about
|
||||
the same file, or for a `source_failure` about the same source, or for an
|
||||
`anomaly` in the same scope, with the same netblock, AS number or name, whatever
|
||||
its window and kind, less than `SWWAF_ALERT_COOLDOWN` after it, is a repeat: it
|
||||
is held back and counted, and the next alert sent for them gives that count as
|
||||
`suppressed_repeats`. As each hour of the clock, in UTC, ends, the cooldowns
|
||||
that have run out are dropped, and the repeats they held back, which no alert
|
||||
sent since has given, go in that hour's summary.
|
||||
a `reputation_hit` about the same blocklist, zone or AbuseIPDB, or for a
|
||||
`file_error` about the same file, or for a `source_failure` about the same
|
||||
source, or for an `anomaly` in the same scope, with the same netblock, AS number
|
||||
or name, whatever its window and kind, less than `SWWAF_ALERT_COOLDOWN` after
|
||||
it, is a repeat: it is held back and counted, and the next alert sent for them
|
||||
gives that count as `suppressed_repeats`. As each hour of the clock, in UTC,
|
||||
ends, the cooldowns that have run out are dropped, and the repeats they held
|
||||
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
|
||||
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
|
||||
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
|
||||
zone 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.
|
||||
zone gave it, `fetched`; and under `abuseipdb` (see "AbuseIPDB" below), the
|
||||
`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
|
||||
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`,
|
||||
@@ -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;
|
||||
`smallwebwaf_reputation_queries_total`: the queries made to the zone; and
|
||||
`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_state_file_writes_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
|
||||
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
|
||||
verdicts of the DNSBL zones and the alerts are built, with an edit taken in
|
||||
while running (see "State files" above); the rest comes with its features.
|
||||
verdicts of the DNSBL zones, AbuseIPDB's scores and the alerts are built, with
|
||||
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
|
||||
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
|
||||
@@ -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`
|
||||
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
|
||||
|
||||
- `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
|
||||
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 rate limit, which bans the client, for a `block` or `ban` rule, the latter
|
||||
banning the client, and for an announced body over the size limit; in
|
||||
`observe` mode, only for the size limit, with what it would have refused for
|
||||
noted in the log line. A request under `/_smallwebwaf/` that `check` lets
|
||||
through is answered by `answerAdmin` instead of reaching the app. Once the
|
||||
answer to a request passed to the app has ended, `countBytes` counts its bytes
|
||||
for the byte limits, and once any request but the health check has ended,
|
||||
`countAnomalies` counts it for the anomaly thresholds.
|
||||
AbuseIPDB's score, for a rate limit, which bans the client, for a `block` or
|
||||
`ban` rule, the latter banning the client, and for an announced body over the
|
||||
size limit; in `observe` mode, only for the size limit, with what it would
|
||||
have refused for noted in the log line. A request under `/_smallwebwaf/` that
|
||||
`check` lets through is answered by `answerAdmin` instead of reaching the app.
|
||||
Once the answer to a request passed to the app has ended, `countBytes` counts
|
||||
its bytes for the byte limits, and once any request but the health check has
|
||||
ended, `countAnomalies` counts it for the anomaly thresholds.
|
||||
- `internal/metrics`: the metrics, counted as the other parts tell it what
|
||||
happened, and served in the Prometheus text format.
|
||||
- `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
|
||||
`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
|
||||
file gives an AS number; and asks the DNSBL zones about clients in the
|
||||
background, through the standard library's resolver, keeps their verdicts, and
|
||||
tells which zones' verdicts list an address.
|
||||
file gives an AS number; asks the DNSBL zones about clients in the background,
|
||||
through the standard library's resolver, keeps their verdicts, and tells which
|
||||
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
|
||||
bytes, tells when they take it over a rate limit or a byte limit, and keeps
|
||||
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
|
||||
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
|
||||
longest ago, the anomaly counters to 20,000, dropping the one counted least
|
||||
recently, and the banned netblocks in the order they were last seen, from which
|
||||
the ledger picks the ban to drop past `SWWAF_MAX_BANS`, and
|
||||
`github.com/prometheus/client_golang` keeps the metrics and serves them, and
|
||||
recently seen, the DNSBL zones' verdicts and AbuseIPDB's scores to 100,000 each,
|
||||
dropping the one fetched longest ago, the anomaly counters to 20,000, dropping
|
||||
the one counted least recently, and the banned netblocks in the order they were
|
||||
last seen, from which the ledger picks the ban to drop past `SWWAF_MAX_BANS`,
|
||||
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
|
||||
file is saved, or the lookup database replaced, and
|
||||
`github.com/oschwald/maxminddb-golang/v2` reads the lookup database, which the
|
||||
|
||||
@@ -47,10 +47,11 @@ const (
|
||||
// EventWAFBlock comes with the Core Rule Set; nothing raises it yet.
|
||||
EventWAFBlock = "waf_block"
|
||||
// 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"
|
||||
// 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"
|
||||
// EventFileError is a rule file or state file edited while smallwebwaf
|
||||
// runs that does not parse, a replacement of the lookup database that
|
||||
|
||||
+21
-10
@@ -155,14 +155,22 @@ type Config struct {
|
||||
// DNSBLZones are the DNSBL zones clients are asked about
|
||||
// (SWWAF_DNSBL_ZONES), through DNSBLResolver (SWWAF_DNSBL_RESOLVER), or
|
||||
// the host's resolver while that is the zero AddrPort.
|
||||
// ReputationAction is what is done with a client a zone's verdict lists
|
||||
// (SWWAF_REPUTATION_ACTION): deny, limit or log; for limit,
|
||||
// ReputationLimitPercent is the percentage of every limit it gets. A
|
||||
// verdict is used for ReputationCacheTTL after it was fetched
|
||||
// (SWWAF_REPUTATION_CACHE_TTL), and a query may take ReputationTimeout
|
||||
// (SWWAF_REPUTATION_TIMEOUT). Neither can be off.
|
||||
// AbuseIPDBKey is the key of the AbuseIPDB account clients are checked
|
||||
// with (SWWAF_ABUSEIPDB_KEY), "" while it is unset and none is. A score
|
||||
// of AbuseIPDBMinScore or more is a hit (SWWAF_ABUSEIPDB_MIN_SCORE), and
|
||||
// at most AbuseIPDBDailyBudget checks are made a day
|
||||
// (SWWAF_ABUSEIPDB_DAILY_BUDGET).
|
||||
// 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
|
||||
DNSBLResolver netip.AddrPort
|
||||
AbuseIPDBKey string
|
||||
AbuseIPDBMinScore int64
|
||||
AbuseIPDBDailyBudget int
|
||||
ReputationAction string
|
||||
ReputationLimitPercent int64
|
||||
ReputationCacheTTL time.Duration
|
||||
@@ -440,6 +448,9 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"),
|
||||
DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"),
|
||||
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"),
|
||||
ReputationTimeout: env.durationNotOff("SWWAF_REPUTATION_TIMEOUT", "2s"),
|
||||
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
|
||||
// an ntfy token, "" while it is unset. It is sent in a header, which
|
||||
// cannot hold a control character, so one in it is an error. The log
|
||||
// shows ******** in place of a value that is not empty, and an error
|
||||
// shows none of it.
|
||||
// an ntfy token or an AbuseIPDB key, "" while it is unset. It is sent in
|
||||
// a header, which cannot hold a control character, so one in it is an
|
||||
// error. The log shows ******** in place of a value that is not empty, and
|
||||
// an error shows none of it.
|
||||
func (e *environment) secret(name string) string {
|
||||
value, _ := e.lookup(name)
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@ const (
|
||||
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
||||
dnsblZones = "SWWAF_DNSBL_ZONES"
|
||||
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
||||
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
|
||||
abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE"
|
||||
abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET"
|
||||
reputationAction = "SWWAF_REPUTATION_ACTION"
|
||||
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL"
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1956,6 +2029,9 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
blocklistAction: actionDeny,
|
||||
dnsblZones: "",
|
||||
dnsblResolver: "",
|
||||
abuseIPDBKey: "",
|
||||
abuseIPDBMinScore: "75",
|
||||
abuseIPDBDailyBudget: "900",
|
||||
reputationAction: "limit:25",
|
||||
reputationCacheTTL: defaultReputationCacheTTL,
|
||||
reputationTimeout: "2s",
|
||||
|
||||
+62
-36
@@ -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
|
||||
// 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
|
||||
// zone's verdict lists, which ReputationHit counts, and, read from lists
|
||||
// and dnsbl as the metrics are asked for, for a list, the fetches that
|
||||
// failed and when the copy in use was fetched, and for a zone, the queries
|
||||
// made and those that failed. It is called once, before ReputationHit.
|
||||
// config.MaskZoneKey masks it: the requests whose client a blocklist, a
|
||||
// zone's verdict or AbuseIPDB's score lists, which ReputationHit counts,
|
||||
// and, read from lists and dnsbl as the metrics are asked for, for a list,
|
||||
// the fetches that failed and when the copy in use was fetched, and for a
|
||||
// zone, the queries made and those that failed. It is called once, before
|
||||
// ReputationHit.
|
||||
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",
|
||||
"Requests whose client a blocklist or a DNSBL zone lists, by the "+
|
||||
"blocklist's URL or the zone.",
|
||||
"Requests whose client a blocklist, a DNSBL zone or AbuseIPDB lists, by "+
|
||||
"the blocklist's URL, the zone, or abuseipdb.",
|
||||
[]string{sourceLabel})
|
||||
m.registry.MustRegister(m.reputationHits)
|
||||
|
||||
for _, zone := range dnsbl.Zones() {
|
||||
source := prometheus.Labels{sourceLabel: config.MaskZoneKey(zone)}
|
||||
|
||||
m.registry.MustRegister(
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
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))
|
||||
}),
|
||||
)
|
||||
m.addReputationQueries(source, func() int { return dnsbl.Queries(zone) })
|
||||
m.addReputationFailures(source, func() int { return dnsbl.Failures(zone) })
|
||||
}
|
||||
|
||||
for _, listURL := range lists.URLs() {
|
||||
source := prometheus.Labels{sourceLabel: listURL}
|
||||
|
||||
m.addReputationFailures(source, func() int { return lists.Failures(listURL) })
|
||||
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{
|
||||
Name: "smallwebwaf_reputation_last_fetch_timestamp_seconds",
|
||||
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,
|
||||
// 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) {
|
||||
m.reputationHits.WithLabelValues(source).Inc()
|
||||
}
|
||||
@@ -470,6 +470,32 @@ func (m *Metrics) StateFileEditSetAside(name string) {
|
||||
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
|
||||
// status was sent.
|
||||
func statusClass(status int) string {
|
||||
|
||||
+14
-13
@@ -28,18 +28,19 @@ func biasedThresholdsSet(cfg *config.Config) bool {
|
||||
|
||||
// limitPercentages returns the client's limit percentages, for the rate
|
||||
// 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
|
||||
// it. Each is the lowest of those the settings give it, the first of them
|
||||
// in the order below when several are lowest: the percentage
|
||||
// SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file
|
||||
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
|
||||
// that list it. Each is the lowest of those the settings give it, the
|
||||
// first of them in the order below when several are lowest: the
|
||||
// percentage SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file
|
||||
// SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one
|
||||
// SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
|
||||
// country, SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists,
|
||||
// the percentage of SWWAF_BLOCKLIST_ACTION while it is limit, and for a
|
||||
// client a DNSBL zone's verdict lists, the percentage of
|
||||
// SWWAF_REPUTATION_ACTION while it is limit. For the byte limits,
|
||||
// SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT take the place
|
||||
// of the first three for an AS number or a country they list.
|
||||
// client a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit,
|
||||
// the percentage of SWWAF_REPUTATION_ACTION while it is limit. For the
|
||||
// byte limits, SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT
|
||||
// take the place of the first three for an AS number or a country they
|
||||
// list.
|
||||
func (rq *request) limitPercentages() (percentage, percentage) {
|
||||
cfg := rq.h.config
|
||||
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"}
|
||||
}
|
||||
|
||||
dnsblListed := percentage{percent: whole}
|
||||
if rq.dnsblListed && cfg.ReputationAction == "limit" {
|
||||
dnsblListed = percentage{cfg.ReputationLimitPercent, "SWWAF_REPUTATION_ACTION"}
|
||||
reputationListed := percentage{percent: whole}
|
||||
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
||||
reputationListed = percentage{cfg.ReputationLimitPercent, "SWWAF_REPUTATION_ACTION"}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
return lowest(asnRequests, countryRequests, unknown, blocklisted, dnsblListed),
|
||||
lowest(asnBytes, countryBytes, unknown, blocklisted, dnsblListed)
|
||||
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
|
||||
lowest(asnBytes, countryBytes, unknown, blocklisted, reputationListed)
|
||||
}
|
||||
|
||||
// given returns the percentage percents, the setting named setting, gives
|
||||
|
||||
@@ -349,6 +349,7 @@ const unansweredGeoJSURL = "unanswered://geojs/v1/ip/geo.json"
|
||||
func TestMain(m *testing.M) {
|
||||
transport, _ := http.DefaultTransport.(*http.Transport)
|
||||
transport.RegisterProtocol("unanswered", unansweredGeoJS{})
|
||||
transport.RegisterProtocol("abuseipdb", abuseIPDBStandIn{})
|
||||
|
||||
m.Run()
|
||||
}
|
||||
|
||||
+33
-10
@@ -59,6 +59,9 @@ type Params struct {
|
||||
// GeoJSURL is where clients' AS numbers and countries are looked up
|
||||
// while SWWAF_LOOKUP_SOURCE is geojs, normally lookup.URL.
|
||||
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
|
||||
// SWWAF_LOOKUP_SOURCE is file, and nil otherwise.
|
||||
LookupFile *lookup.File
|
||||
@@ -71,15 +74,17 @@ type Params struct {
|
||||
Rules *rules.Files
|
||||
// Alerts receive the alert for each ban the proxy makes or makes
|
||||
// permanent, for each count over an anomaly threshold, for each request
|
||||
// whose client a blocklist or a DNSBL zone lists, and for GeoJS failing,
|
||||
// a fetch of a list failing or a query to a DNSBL zone failing.
|
||||
// whose client a blocklist, a DNSBL zone or AbuseIPDB lists, and for
|
||||
// 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
|
||||
}
|
||||
|
||||
// Server is the server smallwebwaf runs, with the parts of the proxy
|
||||
// whose state the state files keep, the lookup database, nil unless
|
||||
// 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 {
|
||||
*http.Server
|
||||
|
||||
@@ -90,6 +95,7 @@ type Server struct {
|
||||
LookupFile *lookup.File
|
||||
Lists *reputation.Lists
|
||||
DNSBL *reputation.DNSBL
|
||||
AbuseIPDB *reputation.AbuseIPDB
|
||||
Metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
@@ -102,7 +108,7 @@ type Server struct {
|
||||
func New(params Params) *Server {
|
||||
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
||||
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
|
||||
lists, dnsbl := newReputation(params)
|
||||
lists, dnsbl, abuseIPDB := newReputation(params, m)
|
||||
h := &handler{
|
||||
config: params.Config,
|
||||
requestLog: params.RequestLog,
|
||||
@@ -140,6 +146,7 @@ func New(params Params) *Server {
|
||||
lookupFile: params.LookupFile,
|
||||
lists: lists,
|
||||
dnsbl: dnsbl,
|
||||
abuseIPDB: abuseIPDB,
|
||||
rules: params.Rules,
|
||||
alerts: params.Alerts,
|
||||
}
|
||||
@@ -159,7 +166,6 @@ func New(params Params) *Server {
|
||||
})
|
||||
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
||||
m.AddRules(params.Rules)
|
||||
m.AddReputation(h.lists, h.dnsbl)
|
||||
|
||||
return &Server{
|
||||
Server: &http.Server{
|
||||
@@ -181,14 +187,18 @@ func New(params Params) *Server {
|
||||
LookupFile: h.lookupFile,
|
||||
Lists: h.lists,
|
||||
DNSBL: h.dnsbl,
|
||||
AbuseIPDB: h.abuseIPDB,
|
||||
Metrics: m,
|
||||
}
|
||||
}
|
||||
|
||||
// newReputation returns the lists fetched from URLs and the DNSBL zones'
|
||||
// verdicts, as the settings in params name them, with none fetched or
|
||||
// asked for yet.
|
||||
func newReputation(params Params) (*reputation.Lists, *reputation.DNSBL) {
|
||||
// newReputation returns the lists fetched from URLs, the DNSBL zones'
|
||||
// verdicts and AbuseIPDB's scores, as the settings in params name them,
|
||||
// with none fetched, asked for or checked yet, and adds their metrics to
|
||||
// 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
|
||||
lists := reputation.New(reputation.Params{
|
||||
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,
|
||||
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
|
||||
@@ -221,6 +243,7 @@ type handler struct {
|
||||
lookupFile *lookup.File
|
||||
lists *reputation.Lists
|
||||
dnsbl *reputation.DNSBL
|
||||
abuseIPDB *reputation.AbuseIPDB
|
||||
rules *rules.Files
|
||||
alerts *alerts.Queue
|
||||
}
|
||||
|
||||
@@ -268,7 +268,8 @@ func startProxyWithAlerts(
|
||||
// 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 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(
|
||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||
env map[string]string,
|
||||
@@ -325,14 +326,15 @@ func newProxy(
|
||||
}
|
||||
|
||||
server := proxy.New(proxy.Params{
|
||||
Config: cfg,
|
||||
RequestLog: out,
|
||||
ProcessLog: processLog,
|
||||
GeoJSURL: geojsURL,
|
||||
LookupFile: lookupFile,
|
||||
Now: now,
|
||||
Rules: ruleFiles,
|
||||
Alerts: alertQueue,
|
||||
Config: cfg,
|
||||
RequestLog: out,
|
||||
ProcessLog: processLog,
|
||||
GeoJSURL: geojsURL,
|
||||
AbuseIPDBURL: abuseIPDBURL,
|
||||
LookupFile: lookupFile,
|
||||
Now: now,
|
||||
Rules: ruleFiles,
|
||||
Alerts: alertQueue,
|
||||
})
|
||||
|
||||
return server, out, alertQueue
|
||||
|
||||
@@ -4,8 +4,14 @@ import (
|
||||
"context"
|
||||
|
||||
"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
|
||||
// noteListed does, and reports whether SWWAF_BLOCKLIST_ACTION, being deny,
|
||||
// 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.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
|
||||
@@ -30,27 +36,63 @@ func (rq *request) dnsblDenied(ctx context.Context) bool {
|
||||
rq.dnsblListed = len(listedBy) > 0
|
||||
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,
|
||||
// their keys masked, that list the client, to the log line's reputation,
|
||||
// counts each of them in the metrics, and raises a reputation_hit alert,
|
||||
// with reason, for each.
|
||||
func (rq *request) noteListed(sources []string, reason string) {
|
||||
rq.line.Reputation = append(rq.line.Reputation, sources...)
|
||||
// abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
|
||||
// its score of the client is a hit, and reports whether
|
||||
// SWWAF_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
|
||||
// does for a zone. While SWWAF_ABUSEIPDB_KEY is unset it does nothing. A
|
||||
// client without a score is checked in the background, by the request's
|
||||
// address, if its history counts 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 {
|
||||
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: map[string]any{"source": source},
|
||||
})
|
||||
rq.noteHit(source, reason, 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
@@ -599,6 +603,274 @@ 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)
|
||||
}
|
||||
|
||||
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", "/.env", 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.
|
||||
func listsFetched() time.Time {
|
||||
return time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
||||
@@ -631,6 +903,31 @@ func loadVerdicts(server *proxy.Server, listedBy map[string][]string) {
|
||||
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
|
||||
// its lines, fetched at listsFetched, as reputation.json would at start.
|
||||
func loadLists(t *testing.T, server *proxy.Server, copies map[string][]string) {
|
||||
|
||||
+19
-11
@@ -63,10 +63,15 @@ type request struct {
|
||||
// limits and for the byte limits.
|
||||
counted bool
|
||||
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,
|
||||
// and dnsblListed once a DNSBL zone's verdict is.
|
||||
blocklisted, dnsblListed bool
|
||||
start time.Time
|
||||
// dnsblListed once a DNSBL zone's verdict is, and abuseIPDBHit once
|
||||
// AbuseIPDB's score of it is a hit.
|
||||
blocklisted, dnsblListed, abuseIPDBHit bool
|
||||
start time.Time
|
||||
// checked is when the checks were done, and upstreamStart when the
|
||||
// request was handed to the app.
|
||||
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,
|
||||
// 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,
|
||||
// and then the DNSBL zones' verdicts; a request any of them refuses is not
|
||||
// counted for the rate limits. Then come the rate limits, unless the
|
||||
// client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the request's path is
|
||||
// exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that every other request
|
||||
// is counted, each of them by the client's limit percentages, and last the
|
||||
// rule files. A request exempt from the rate limits is exempt from the
|
||||
// byte limits too. ctx is the request's own context.
|
||||
// then the DNSBL zones' verdicts, and then AbuseIPDB's score; a request
|
||||
// any of them refuses is not counted for the rate limits. Then come the
|
||||
// rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the
|
||||
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
|
||||
// every other request is counted, each of them by the client's limit
|
||||
// percentages, and last the rule files. A request exempt from the rate
|
||||
// limits is exempt from the byte limits too. ctx is the request's own
|
||||
// context.
|
||||
func (rq *request) checkClient(ctx context.Context) string {
|
||||
cfg := rq.h.config
|
||||
if isInside(rq.client, cfg.AllowNets) {
|
||||
@@ -250,7 +256,7 @@ func (rq *request) checkClient(ctx context.Context) string {
|
||||
return requestlog.ActionDenied
|
||||
}
|
||||
|
||||
if rq.dnsblDenied(ctx) {
|
||||
if rq.dnsblDenied(ctx) || rq.abuseIPDBDenied(ctx) {
|
||||
return requestlog.ActionDenied
|
||||
}
|
||||
|
||||
@@ -540,6 +546,8 @@ func (rq *request) addToHistory() {
|
||||
RequestBytes: rq.requestBytes(),
|
||||
ResponseBytes: rq.out.bytes,
|
||||
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
||||
Attack: rq.attack,
|
||||
RuleBlocked: rq.ruleBlocked,
|
||||
})
|
||||
|
||||
answer, found := rq.answerAtTheEnd()
|
||||
|
||||
@@ -12,7 +12,8 @@ import (
|
||||
// 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
|
||||
// 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 {
|
||||
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.
|
||||
switch last := matched[len(matched)-1]; last.Action {
|
||||
case rules.ActionBlock:
|
||||
rq.ruleBlocked = true
|
||||
|
||||
return requestlog.ActionRuleBlocked
|
||||
case rules.ActionBan:
|
||||
rq.attack = true
|
||||
rq.banForAttack(now, last)
|
||||
|
||||
return requestlog.ActionBanned
|
||||
|
||||
@@ -118,8 +118,12 @@ type Responses struct {
|
||||
|
||||
// Offences are a client's offences, by kind.
|
||||
type Offences struct {
|
||||
// Limit is its requests that broke a rate limit or a byte limit.
|
||||
Limit int64 `json:"limit"`
|
||||
// Limit is its requests that broke a rate limit or a byte 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.
|
||||
@@ -137,8 +141,11 @@ type Request struct {
|
||||
RequestBytes int64
|
||||
ResponseBytes int64
|
||||
// BrokeLimit is true for a request that broke a rate limit or a byte
|
||||
// limit.
|
||||
BrokeLimit bool
|
||||
// limit, Attack for one that matched a ban rule, and RuleBlocked for
|
||||
// one a block rule refused.
|
||||
BrokeLimit bool
|
||||
Attack bool
|
||||
RuleBlocked bool
|
||||
}
|
||||
|
||||
// New returns a Limiter for limits, with no client counted yet.
|
||||
@@ -258,6 +265,14 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
|
||||
if r.BrokeLimit {
|
||||
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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -20,16 +20,17 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// maxVerdicts is how many verdicts are kept. Past it, the one fetched
|
||||
// longest ago is dropped.
|
||||
// maxVerdicts is how many verdicts of the DNSBL zones are kept, and how
|
||||
// many scores of AbuseIPDB. Past it, the one fetched longest ago is
|
||||
// dropped.
|
||||
maxVerdicts = 100000
|
||||
// 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
|
||||
// that a swarm of new addresses cannot fill the memory.
|
||||
maxQueries = 1000
|
||||
// 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
|
||||
// request.
|
||||
// it fails, and no client is checked with AbuseIPDB after a check
|
||||
// fails, so that a source refusing them is not asked on every request.
|
||||
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
|
||||
// log line is.
|
||||
d.params.Alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventSourceFailure,
|
||||
Reason: failed,
|
||||
Detail: map[string]any{"source": shown, "error": err.Error()},
|
||||
})
|
||||
raiseFailure(d.params.Alerts, failed, shown, err)
|
||||
d.params.ProcessLog.Warn(failed, "zone", shown, "error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ func (l *Lists) SetTransport(transport http.RoundTripper) {
|
||||
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.
|
||||
func (d *DNSBL) SetDial(
|
||||
dial func(ctx context.Context, network, address string) (net.Conn, error),
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
// 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
|
||||
// when each was last tried. It also asks the DNSBL zones of
|
||||
// SWWAF_DNSBL_ZONES about clients, and keeps their verdicts. The state
|
||||
// package writes all of these to reputation.json and reads them from it,
|
||||
// so that a restart keeps them too.
|
||||
// SWWAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
||||
// clients with AbuseIPDB, and keeps their scores and the checks spent
|
||||
// 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
|
||||
|
||||
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
|
||||
// log line is.
|
||||
l.params.Alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventSourceFailure,
|
||||
Reason: failed,
|
||||
Detail: map[string]any{"source": listURL, "error": err.Error()},
|
||||
})
|
||||
raiseFailure(l.params.Alerts, failed, listURL, err)
|
||||
l.params.ProcessLog.Warn(failed, "url", listURL, "error", err.Error())
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// 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
|
||||
// than 200, or a list longer than maxListBytes, is a failure.
|
||||
func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
||||
|
||||
@@ -36,7 +36,8 @@ const (
|
||||
ActionRuleBlocked = "rule_blocked"
|
||||
// ActionDenied is a request 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_REPUTATION_ACTION is deny.
|
||||
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
||||
// SWWAF_REPUTATION_ACTION is deny.
|
||||
ActionDenied = "denied"
|
||||
// ActionCountryDenied is a request refused for its client's country.
|
||||
ActionCountryDenied = "country_denied"
|
||||
@@ -139,7 +140,8 @@ type Line struct {
|
||||
// minute_bytes, hour_bytes or day_bytes for a byte limit.
|
||||
LimitHit string `json:"limit_hit,omitempty"`
|
||||
// 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"`
|
||||
// Offence is the offence the request was held as, OffenceLimit.
|
||||
Offence string `json:"offence,omitempty"`
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||
@@ -169,14 +170,15 @@ func newServer(
|
||||
}
|
||||
|
||||
server := proxy.New(proxy.Params{
|
||||
Config: cfg,
|
||||
RequestLog: stdout,
|
||||
ProcessLog: processLog,
|
||||
GeoJSURL: lookup.URL,
|
||||
LookupFile: lookupFile,
|
||||
Now: now,
|
||||
Rules: ruleFiles,
|
||||
Alerts: alertQueue,
|
||||
Config: cfg,
|
||||
RequestLog: stdout,
|
||||
ProcessLog: processLog,
|
||||
GeoJSURL: lookup.URL,
|
||||
AbuseIPDBURL: reputation.AbuseIPDBURL,
|
||||
LookupFile: lookupFile,
|
||||
Now: now,
|
||||
Rules: ruleFiles,
|
||||
Alerts: alertQueue,
|
||||
})
|
||||
server.Metrics.AddAlerts(alertQueue)
|
||||
|
||||
@@ -202,6 +204,7 @@ func loadStateFiles(
|
||||
GeoJS: server.GeoJS,
|
||||
Lists: server.Lists,
|
||||
DNSBL: server.DNSBL,
|
||||
AbuseIPDB: server.AbuseIPDB,
|
||||
Alerts: alertQueue,
|
||||
Anomalies: server.Anomalies,
|
||||
Now: now,
|
||||
|
||||
+47
-12
@@ -2,8 +2,8 @@
|
||||
// SWWAF_STATE_DIR, as the "Persistent state" section of SPEC.md describes:
|
||||
// bans.json holds the bans, clients.json each client's counters 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'
|
||||
// verdicts, and alerts.json the
|
||||
// last good copy of each list fetched from a URL, the DNSBL zones'
|
||||
// verdicts, and AbuseIPDB's scores and checks spent, and alerts.json the
|
||||
// cooldowns, the hour under way, the alerts waiting for each destination
|
||||
// and the anomaly counters. Load
|
||||
// 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).
|
||||
WriteDelay time.Duration
|
||||
CounterInterval time.Duration
|
||||
// Ledger, Limiter, GeoJS, Lists, DNSBL, Alerts and Anomalies hold the
|
||||
// state. Alerts also receive a file_error alert for an edit set aside,
|
||||
// and for a write that fails while smallwebwaf runs.
|
||||
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies
|
||||
// hold the state. Alerts also receive a file_error alert for an edit set
|
||||
// aside, and for a write that fails while smallwebwaf runs.
|
||||
Ledger *bans.Ledger
|
||||
Limiter *ratelimit.Limiter
|
||||
GeoJS *lookup.GeoJS
|
||||
Lists *reputation.Lists
|
||||
DNSBL *reputation.DNSBL
|
||||
AbuseIPDB *reputation.AbuseIPDB
|
||||
Alerts *alerts.Queue
|
||||
Anomalies *anomaly.Counters
|
||||
// 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
|
||||
// edit, so that each line of a list's copy is on a line of its own.
|
||||
type reputationFile struct {
|
||||
Version int `json:"version"`
|
||||
Lists []reputation.List `json:"lists"`
|
||||
Verdicts []reputation.Verdict `json:"verdicts"`
|
||||
Version int `json:"version"`
|
||||
Lists []reputation.List `json:"lists"`
|
||||
Verdicts []reputation.Verdict `json:"verdicts"`
|
||||
AbuseIPDB reputation.Checks `json:"abuseipdb"`
|
||||
}
|
||||
|
||||
// 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.AbuseIPDB.Load(file.AbuseIPDB)
|
||||
entries = len(file.Lists)
|
||||
case alertsJSON:
|
||||
waiting, err := f.takeInAlerts(path, data)
|
||||
@@ -571,7 +574,7 @@ func (f *Files) encode(name string) ([]byte, error) {
|
||||
case reputationJSON:
|
||||
return encodeIndented(reputationFile{
|
||||
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
|
||||
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
|
||||
// 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
|
||||
// it was fetched, which would drop it. A verdict's listed is false for a
|
||||
// client the zone does not list, which Verdicts cannot tell from a
|
||||
// missing one, so each listed is read again as written.
|
||||
// it was fetched, which would drop it, and so an AbuseIPDB score without
|
||||
// its client, the score, or the time it was fetched. A verdict's listed is
|
||||
// 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 {
|
||||
for i, kept := range f.Lists {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -212,8 +212,9 @@ const filledAlertsJSON = `{
|
||||
`
|
||||
|
||||
// filledReputationJSON is reputation.json holding the blocklists' last
|
||||
// tries and the copy of one, with its comment line, and two verdicts of a
|
||||
// DNSBL zone, as fill puts them in.
|
||||
// tries and the copy of one, with its comment line, two verdicts of a
|
||||
// DNSBL zone, and the AbuseIPDB checks spent today with two scores, as
|
||||
// fill puts them in.
|
||||
const filledReputationJSON = `{
|
||||
"version": 1,
|
||||
"lists": [
|
||||
@@ -245,7 +246,23 @@ const filledReputationJSON = `{
|
||||
"listed": false,
|
||||
"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())
|
||||
|
||||
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(
|
||||
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)
|
||||
|
||||
held := params.Alerts.Snapshot()
|
||||
checks := params.AbuseIPDB.Snapshot()
|
||||
|
||||
if len(params.Ledger.Snapshot()) != 0 || len(params.Limiter.Snapshot()) != 0 ||
|
||||
len(params.GeoJS.Snapshot()) != 0 || len(params.Lists.Snapshot()) != 0 ||
|
||||
len(params.DNSBL.Snapshot()) != 0 || len(held.Cooldowns) != 0 ||
|
||||
len(held.Waiting[alerts.DestinationWebhook]) != 0 || held.Hour.Sent != 0 {
|
||||
len(params.DNSBL.Snapshot()) != 0 || len(checks.Scores) != 0 || checks.Spent != 0 ||
|
||||
len(held.Cooldowns) != 0 || len(held.Waiting[alerts.DestinationWebhook]) != 0 ||
|
||||
held.Hour.Sent != 0 {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1182,7 +1248,9 @@ func TestEditOfEachFileTakenIn(t *testing.T) {
|
||||
edit(t, dir, reputationJSON, `{"version": 1, "lists": [{"url": "`+blocklistURL+`", `+
|
||||
`"tried": "2026-10-06T00:00:00Z", "fetched": "2026-10-06T00:00:00Z", `+
|
||||
`"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)
|
||||
|
||||
listedBy := params.Lists.ListedBy(client.Addr())
|
||||
@@ -1195,6 +1263,14 @@ func TestEditOfEachFileTakenIn(t *testing.T) {
|
||||
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
|
||||
// in.
|
||||
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,
|
||||
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,
|
||||
Anomalies: anomaly.New(anomaly.Params{
|
||||
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
|
||||
// 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
|
||||
// verdicts of a DNSBL zone, as filledReputationJSON holds them, and alerts
|
||||
// GeoJS answers, the blocklists' last tries and the copy of one, two
|
||||
// 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
|
||||
// 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)},
|
||||
})
|
||||
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
|
||||
// alert waiting, and one past the two an hour, for the hour's summary.
|
||||
|
||||
Reference in New Issue
Block a user