Byte limits per client over a minute, an hour and a day #102
@@ -22,29 +22,30 @@ fields, which come a little later, and the metrics endpoint and the header size
|
|||||||
and the idle time as settings, which come last in it. So are the four parts of
|
and the idle time as settings, which come last in it. So are the four parts of
|
||||||
the stage after it: the rule files, with the bans for a clear sign of attack,
|
the stage after it: the rule files, with the bans for a clear sign of attack,
|
||||||
the other admin endpoints, alerts to all three destinations, a JSON webhook,
|
the other admin endpoints, alerts to all three destinations, a JSON webhook,
|
||||||
Slack and ntfy, and remote log sending. So is the first part of the stage after
|
Slack and ntfy, and remote log sending. So are two parts of the stage after
|
||||||
that: the AS number and country of every client, looked up through GeoJS or in
|
that: the AS number and country of every client, looked up through GeoJS or in
|
||||||
the IPinfo Lite database file. `smallwebwaf` passes each request to the app and
|
the IPinfo Lite database file, and the byte limits. `smallwebwaf` passes each
|
||||||
the app's answer back, unchanged, within its timeouts and size limits, works out
|
request to the app and the app's answer back, unchanged, within its timeouts and
|
||||||
each client's address, looks up its AS number and country unless you switch that
|
size limits, works out each client's address, looks up its AS number and country
|
||||||
off, bans a client that sends too many requests, not counting those for the
|
unless you switch that off, bans a client that sends too many requests or too
|
||||||
paths you choose, refuses a client that comes from a country you refuse or from
|
many bytes, not counting those for the paths you choose, refuses a client that
|
||||||
a network you refuse, lets the networks you choose through, checks each request
|
comes from a country you refuse or from a network you refuse, lets the networks
|
||||||
against the rule files and bans a client whose request is a clear sign of
|
you choose through, checks each request against the rule files and bans a client
|
||||||
attack, keeps its bans, each client's counters and history, and GeoJS's answers
|
whose request is a clear sign of attack, keeps its bans, each client's counters
|
||||||
in JSON files across restarts, takes in your edits of those files, such as a ban
|
and history, and GeoJS's answers in JSON files across restarts, takes in your
|
||||||
you make, keep or lift, and of the rule files while it runs, writes a JSON log
|
edits of those files, such as a ban you make, keep or lift, and of the rule
|
||||||
line for every request, sends its log lines to a syslog server too if you name
|
files while it runs, writes a JSON log line for every request, sends its log
|
||||||
one, sends an alert to a webhook, to Slack and to ntfy, each if you name one,
|
lines to a syslog server too if you name one, sends an alert to a webhook, to
|
||||||
for each ban it makes or makes permanent, for GeoJS failing, for a rule file or
|
Slack and to ntfy, each if you name one, for each ban it makes or makes
|
||||||
state file with an error and for a replacement of the lookup database it cannot
|
permanent, for GeoJS failing, for a rule file or state file with an error and
|
||||||
read, serves Prometheus metrics to a scraper that holds the metrics token, lets
|
for a replacement of the lookup database it cannot read, serves Prometheus
|
||||||
an admin who holds the admin token list, add and lift bans and ask what it knows
|
metrics to a scraper that holds the metrics token, lets an admin who holds the
|
||||||
of a client, and in `observe` mode passes on the requests it would refuse,
|
admin token list, add and lift bans and ask what it knows of a client, and in
|
||||||
logging what it would have done with them. It comes as the image the app's own
|
`observe` mode passes on the requests it would refuse, logging what it would
|
||||||
image is built on. The rest of the design comes after that, in the order of the
|
have done with them. It comes as the image the app's own image is built on. The
|
||||||
build order in [`SPEC.md`](SPEC.md). The survey of existing tools that led to
|
rest of the design comes after that, in the order of the build order in
|
||||||
the design is in [`EVALUATION.md`](EVALUATION.md).
|
[`SPEC.md`](SPEC.md). The survey of existing tools that led to the design is in
|
||||||
|
[`EVALUATION.md`](EVALUATION.md).
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
@@ -107,26 +108,40 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
|
|||||||
window still covers. At most 20,000 clients are kept, the least recently seen
|
window still covers. At most 20,000 clients are kept, the least recently seen
|
||||||
dropped first, with their history, and a restart gives no client a fresh
|
dropped first, with their history, and a restart gives no client a fresh
|
||||||
allowance (see "State files" below).
|
allowance (see "State files" below).
|
||||||
- Bans a client that breaks a rate limit, as "Bans" in [`SPEC.md`](SPEC.md)
|
- Counts each client's bytes over a minute, an hour and a day, in the same way:
|
||||||
describes: the first ban lasts an hour, and a limit broken again within a day
|
once a request passed to the app has ended, the body bytes of its answer, of
|
||||||
of a ban ending bans for three times as long as that ban, so 1, 3, 9, 27 and
|
the request, or of both, as `SWWAF_BYTES_COUNT` says. For a WebSocket, or any
|
||||||
81 hours; a ban that would last longer than seven days is permanent instead. A
|
other upgraded connection, what it carried from the app counts with the
|
||||||
ban covers the client's netblock: its IPv4 address, or the netblock around it
|
answer, and what it carried from the client with the request, once it closes.
|
||||||
that `SWWAF_BAN_SCOPE_V4_PREFIX` sets, or its IPv6 /64. While it lasts, every
|
Bytes that take the client over one of the byte limits below break that limit,
|
||||||
request from the netblock is refused with `SWWAF_BAN_RESPONSE` after the
|
and ban the client as a broken rate limit does, so that its next request is
|
||||||
static lists and before the country lists, so the client is not looked up, and
|
refused. The byte limits never cut an answer or an upgraded connection short:
|
||||||
is not counted for the rate limits. A ban sets the client's counters back to
|
the one whose bytes break a limit has already been passed on, or has closed.
|
||||||
zero. Each ban carries notes for deciding whether to lift it: the limit, its
|
They leave out what the rate limits leave out: a client in `SWWAF_ALLOW_NETS`
|
||||||
window and the requests counted in it, the request that broke it, the client's
|
or `SWWAF_RATE_LIMIT_EXEMPT_NETS`, and a request for a path
|
||||||
AS number, AS name and country once they are looked up, the netblock's
|
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` exempts.
|
||||||
requests since it was first seen, how many of them the ban has refused, and
|
- Bans a client that breaks a rate limit or a byte limit, as "Bans" in
|
||||||
how many bans the netblock had before, for a broken limit, for a clear sign of
|
[`SPEC.md`](SPEC.md) describes: the first ban lasts an hour, and a limit
|
||||||
attack and by an admin. At most `SWWAF_MAX_BANS` bans `smallwebwaf` made are
|
broken again within a day of a ban ending bans for three times as long as that
|
||||||
kept, past, active and permanent; past that, the earliest such ban of the
|
ban, so 1, 3, 9, 27 and 81 hours; a ban that would last longer than seven days
|
||||||
netblock that has gone longest without a request is dropped first. The bans
|
is permanent instead. A ban covers the client's netblock: its IPv4 address, or
|
||||||
whose cause is `admin`, those you make or keep, are kept besides, and never
|
the netblock around it that `SWWAF_BAN_SCOPE_V4_PREFIX` sets, or its IPv6 /64.
|
||||||
dropped. `bans.json` shows the bans and their notes, a restart lifts none, and
|
While it lasts, every request from the netblock is refused with
|
||||||
you make, keep or lift a ban by editing it (see "State files" below).
|
`SWWAF_BAN_RESPONSE` after the static lists and before the country lists, so
|
||||||
|
the client is not looked up, and is not counted for the rate limits. A ban
|
||||||
|
sets the client's counters back to zero. Each ban carries notes for deciding
|
||||||
|
whether to lift it: the limit, whether it is on requests or bytes, its window
|
||||||
|
and the requests or bytes counted in it, the request that broke it, the
|
||||||
|
client's AS number, AS name and country once they are looked up, the
|
||||||
|
netblock's requests since it was first seen, how many of them the ban has
|
||||||
|
refused, and how many bans the netblock had before, for a broken limit, for a
|
||||||
|
clear sign of attack and by an admin. At most `SWWAF_MAX_BANS` bans
|
||||||
|
`smallwebwaf` made are kept, past, active and permanent; past that, the
|
||||||
|
earliest such ban of the netblock that has gone longest without a request is
|
||||||
|
dropped first. The bans whose cause is `admin`, those you make or keep, are
|
||||||
|
kept besides, and never dropped. `bans.json` shows the bans and their notes, a
|
||||||
|
restart lifts none, and you make, keep or lift a ban by editing it (see "State
|
||||||
|
files" below).
|
||||||
- Checks each request against the rules of the rule files (see "Rule files"
|
- Checks each request against the rules of the rule files (see "Rule files"
|
||||||
below) after the rate limits, and before its body is read. A `log` rule that
|
below) after the rate limits, and before its body is read. A `log` rule that
|
||||||
matches is noted in the log line; a `block` rule refuses the request with
|
matches is noted in the log line; a `block` rule refuses the request with
|
||||||
@@ -138,10 +153,10 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
|
|||||||
default, and any request from the netblock while it lasts makes it permanent.
|
default, and any request from the netblock while it lasts makes it permanent.
|
||||||
Once it has run out, the netblock is served like any other, but its next clear
|
Once it has run out, the netblock is served like any other, but its next clear
|
||||||
sign of attack bans it permanently at once. Such a ban covers the same
|
sign of attack bans it permanently at once. Such a ban covers the same
|
||||||
netblock as a ban for a broken rate limit, does not set the client's counters
|
netblock as a ban for a broken limit, does not set the client's counters back
|
||||||
back to zero, and does not make the netblock's next ban for a broken limit
|
to zero, and does not make the netblock's next ban for a broken limit longer.
|
||||||
longer. Its notes give the id and the target of the rule that matched in place
|
Its notes give the id and the target of the rule that matched in place of the
|
||||||
of the limit.
|
limit.
|
||||||
- Looks up the AS number and country of every client through GeoJS, or in the
|
- Looks up the AS number and country of every client through GeoJS, or in the
|
||||||
IPinfo Lite database file while `SWWAF_LOOKUP_SOURCE` is `file`, after the
|
IPinfo Lite database file while `SWWAF_LOOKUP_SOURCE` is `file`, after the
|
||||||
static lists and bans, unless `SWWAF_LOOKUP_SOURCE` is `off` (see "Country and
|
static lists and bans, unless `SWWAF_LOOKUP_SOURCE` is `off` (see "Country and
|
||||||
@@ -160,29 +175,33 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
|
|||||||
`SWWAF_ALLOW_NETS`, and `SWWAF_DENIED_COUNTRIES` does not refuse it.
|
`SWWAF_ALLOW_NETS`, and `SWWAF_DENIED_COUNTRIES` does not refuse it.
|
||||||
- Checks the client's own address against the static lists, the three netblock
|
- Checks the client's own address against the static lists, the three netblock
|
||||||
settings below, before anything else, its lookup included. A client in
|
settings below, before anything else, its lookup included. A client in
|
||||||
`SWWAF_ALLOW_NETS` skips bans, the country lists, the rate limits and the rule
|
`SWWAF_ALLOW_NETS` skips bans, the country lists, the rate limits, the byte
|
||||||
files, and is not looked up; the timeouts and size limits still apply. A
|
limits and the rule files, and is not looked up; the timeouts and size limits
|
||||||
client in `SWWAF_DENY_NETS` is refused with `SWWAF_BAN_RESPONSE` before its
|
still apply. A client in `SWWAF_DENY_NETS` is refused with
|
||||||
body is read, and the request is not counted for the rate limits; an address
|
`SWWAF_BAN_RESPONSE` before its body is read, and the request is not counted
|
||||||
in `SWWAF_ALLOW_NETS` too is let through. A client in
|
for the rate limits; an address in `SWWAF_ALLOW_NETS` too is let through. A
|
||||||
`SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor refused by the rate
|
client in `SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor refused by the
|
||||||
limits; the country lists, the rule files and bans still apply to it.
|
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
|
- In `observe` mode, with `SWWAF_MODE=observe`, refuses none of the requests
|
||||||
that `SWWAF_DENY_NETS`, a ban, the country lists, a rate limit or a rule would
|
that `SWWAF_DENY_NETS`, a ban, the country lists, a rate limit or a rule would
|
||||||
refuse: it passes them to the app, and their log lines name what `enforce`
|
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
|
mode would have done (see `would_action` in "Request log" below). The checks
|
||||||
run, and requests are counted, as in `enforce` mode, with three differences:
|
run, and requests and bytes are counted, as in `enforce` mode, with three
|
||||||
neither a broken rate limit nor a `ban` rule makes a ban; a broken rate limit
|
differences: neither a broken rate limit or byte limit nor a `ban` rule makes
|
||||||
does not set the client's counters back to zero, so each request over the
|
a ban; a broken limit does not set the client's counters back to zero, so each
|
||||||
limit is logged as one that would be refused; and a request under a ban does
|
request over a rate limit is logged as one that would be refused, and each
|
||||||
not make it permanent. A ban it would have made, or made permanent, raises the
|
whose bytes keep the client over a byte limit as breaking it; and a request
|
||||||
alert `enforce` mode would have raised, marked as what would have happened
|
under a ban does not make it permanent. As in `enforce` mode, the bytes
|
||||||
(see "Alerts" below). The bans in `bans.json` are kept, and refuse requests
|
counted are only those of the requests `enforce` mode would have passed to the
|
||||||
again when `smallwebwaf` next runs in `enforce` mode, as long as they last.
|
app. A ban it would have made, or made permanent, raises the alert `enforce`
|
||||||
The timeouts and size limits still apply, since they protect `smallwebwaf` and
|
mode would have raised, marked as what would have happened (see "Alerts"
|
||||||
the app themselves, and a request for one of `smallwebwaf`'s own endpoints
|
below). The bans in `bans.json` are kept, and refuse requests again when
|
||||||
without its token is still answered `401`. It is for trying a configuration
|
`smallwebwaf` next runs in `enforce` mode, as long as they last. The timeouts
|
||||||
before enforcing it.
|
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
|
- Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any
|
||||||
check and without asking the app, for the image's health check.
|
check and without asking the app, for the image's health check.
|
||||||
- Answers `GET /_smallwebwaf/metrics` with its metrics (see "Metrics" below) for
|
- Answers `GET /_smallwebwaf/metrics` with its metrics (see "Metrics" below) for
|
||||||
@@ -254,10 +273,11 @@ effective settings are logged at start.
|
|||||||
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body.
|
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body.
|
||||||
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body.
|
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body.
|
||||||
- `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip bans, the
|
- `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip bans, the
|
||||||
country lists, the rate limits and the rule files, such as your monitoring or
|
country lists, the rate limits, the byte limits and the rule files, such as
|
||||||
your own networks.
|
your monitoring or your own networks.
|
||||||
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients the
|
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients the
|
||||||
rate limits do not apply to, such as a machine that talks to the app all day.
|
rate limits and the byte limits do not apply to, such as a machine that talks
|
||||||
|
to the app all day.
|
||||||
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are always refused.
|
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are always refused.
|
||||||
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `1000`), `SWWAF_RATE_LIMIT_PER_HOUR`
|
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `1000`), `SWWAF_RATE_LIMIT_PER_HOUR`
|
||||||
(default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`): the most
|
(default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`): the most
|
||||||
@@ -265,19 +285,29 @@ effective settings are logged at start.
|
|||||||
several times what one busy person produces, since a browser loading a heavy
|
several times what one busy person produces, since a browser loading a heavy
|
||||||
page makes a few hundred requests and several people often share one address.
|
page makes a few hundred requests and several people often share one address.
|
||||||
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes whose requests
|
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes whose requests
|
||||||
the rate limits neither count nor refuse, such as `/assets/` for static
|
the rate limits neither count nor refuse, and whose bytes the byte limits do
|
||||||
assets; each starts with `/`. A request whose path, percent-decoded, contains
|
not count, such as `/assets/` for static assets; each starts with `/`. A
|
||||||
`..` anywhere or a backslash, or whose path as sent holds an encoded slash
|
request whose path, percent-decoded, contains `..` anywhere or a backslash, or
|
||||||
(`%2F` or `%2f`), is never exempt, since the app may act on it as a path
|
whose path as sent holds an encoded slash (`%2F` or `%2f`), is never exempt,
|
||||||
outside every prefix: `/assets/..%2Flogin` as `/login`. Any other request is
|
since the app may act on it as a path outside every prefix:
|
||||||
exempt when its path as sent, the path the app receives, before any query
|
`/assets/..%2Flogin` as `/login`. Any other request is exempt when its path as
|
||||||
string and not percent-decoded, starts with a prefix, character for character.
|
sent, the path the app receives, before any query string and not
|
||||||
`/assets/` matches `/assets/app.js` and `/assets/`, but not `/assets`,
|
percent-decoded, starts with a prefix, character for character. `/assets/`
|
||||||
`/Assets/app.js`, `/%61ssets/app.js`, `/static/assets/app.js`,
|
matches `/assets/app.js` and `/assets/`, but not `/assets`, `/Assets/app.js`,
|
||||||
`/static/../assets/app.js` or `/assets%2Fapp.js`. A character the client sends
|
`/%61ssets/app.js`, `/static/assets/app.js`, `/static/../assets/app.js` or
|
||||||
percent-encoded, such as a space, is written percent-encoded in a prefix, as
|
`/assets%2Fapp.js`. A character the client sends percent-encoded, such as a
|
||||||
in `/my%20files/`, and there are no wildcards: `*` is a character like any
|
space, is written percent-encoded in a prefix, as in `/my%20files/`, and there
|
||||||
other.
|
are no wildcards: `*` is a character like any other.
|
||||||
|
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `10G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
|
||||||
|
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `50G`): the most
|
||||||
|
bytes a client may have counted in a minute, an hour and a day. A request's
|
||||||
|
bytes are counted once its answer has ended, so each default is above the
|
||||||
|
largest request body and the largest response together,
|
||||||
|
`SWWAF_REQUEST_MAX_BYTES` and `SWWAF_RESPONSE_MAX_BYTES`: at the defaults no
|
||||||
|
download breaks a limit on its own.
|
||||||
|
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
||||||
|
`response` for those of the answers, `request` for those of the requests, or
|
||||||
|
`both`.
|
||||||
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): where each client's AS number and
|
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): where each client's AS number and
|
||||||
country are looked up: `geojs`, the GeoJS web service, which is then told the
|
country are looked up: `geojs`, the GeoJS web service, which is then told the
|
||||||
address of every new visitor, `file`, the IPinfo Lite database file
|
address of every new visitor, `file`, the IPinfo Lite database file
|
||||||
@@ -311,12 +341,12 @@ effective settings are logged at start.
|
|||||||
the client unanswered: traefik answers `502`, as it does whenever its backend
|
the client unanswered: traefik answers `502`, as it does whenever its backend
|
||||||
drops a connection. A `block` rule always answers `403`.
|
drops a connection. A `block` rule always answers `403`.
|
||||||
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken rate
|
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken rate
|
||||||
limit.
|
limit or byte limit.
|
||||||
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): a rate limit broken again
|
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): a rate limit or byte limit
|
||||||
within this time after a ban ended, other than one for a clear sign of attack,
|
broken again within this time after a ban ended, other than one for a clear
|
||||||
bans for three times as long as that ban.
|
sign of attack, bans for three times as long as that ban.
|
||||||
- `SWWAF_MAX_BAN_DURATION` (default `7d`): a ban for a broken rate limit that
|
- `SWWAF_MAX_BAN_DURATION` (default `7d`): a ban for a broken rate limit or byte
|
||||||
would be longer is permanent instead.
|
limit that would be longer is permanent instead.
|
||||||
- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
||||||
attack.
|
attack.
|
||||||
- `SWWAF_MAX_BANS` (default `5000`): the most bans `smallwebwaf` made that are
|
- `SWWAF_MAX_BANS` (default `5000`): the most bans `smallwebwaf` made that are
|
||||||
@@ -410,15 +440,16 @@ effective settings are logged at start.
|
|||||||
|
|
||||||
Durations are in Go's syntax, with `d` for days (`90s`, `15m`, `7d`). Sizes are
|
Durations are in Go's syntax, with `d` for days (`90s`, `15m`, `7d`). Sizes are
|
||||||
bytes, with an optional `K`, `M` or `G`, which are powers of 1024 (`1K` is 1024
|
bytes, with an optional `K`, `M` or `G`, which are powers of 1024 (`1K` is 1024
|
||||||
bytes). Rate limits are whole numbers of requests. Netblocks are in CIDR form,
|
bytes). Rate limits are whole numbers of requests, and byte limits are sizes.
|
||||||
and a bare address stands for itself alone. Countries are the two-letter codes
|
Netblocks are in CIDR form, and a bare address stands for itself alone.
|
||||||
ISO 3166-1 assigns today, and `xk` for Kosovo, in either case (`de` and `DE` are
|
Countries are the two-letter codes ISO 3166-1 assigns today, and `xk` for
|
||||||
the same); any other code, such as `nk` (North Korea is `kp`) or the withdrawn
|
Kosovo, in either case (`de` and `DE` are the same); any other code, such as
|
||||||
`su`, stops the start, and so does a code on both country lists. `off` switches
|
`nk` (North Korea is `kp`) or the withdrawn `su`, stops the start, and so does a
|
||||||
a timeout, a size limit, a rate limit, `SWWAF_ALERT_COOLDOWN` or
|
code on both country lists. `off` switches a timeout, a size limit, a rate
|
||||||
`SWWAF_ALERT_MAX_PER_HOUR` off; `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`,
|
limit, a byte limit, `SWWAF_ALERT_COOLDOWN` or `SWWAF_ALERT_MAX_PER_HOUR` off;
|
||||||
`SWWAF_LOOKUP_TIMEOUT`, the ban settings, the state settings,
|
`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`, `SWWAF_LOOKUP_TIMEOUT`, the ban
|
||||||
`SWWAF_METRICS_TOP_N` and `SWWAF_LOG_REMOTE_BUFFER` cannot be off.
|
settings, the state settings, `SWWAF_METRICS_TOP_N` and
|
||||||
|
`SWWAF_LOG_REMOTE_BUFFER` cannot be off.
|
||||||
|
|
||||||
Several limits are fixed rather than settings. At most 20,000 clients are kept,
|
Several limits are fixed rather than settings. At most 20,000 clients are kept,
|
||||||
with their counters and history, and an IPv6 client is counted by its /64. At
|
with their counters and history, and an IPv6 client is counted by its /64. At
|
||||||
@@ -462,7 +493,7 @@ and for the container, `-v /srv/app/tokens:/etc/smallwebwaf/tokens:ro` and
|
|||||||
refused ones included:
|
refused ones included:
|
||||||
|
|
||||||
```
|
```
|
||||||
{"type":"request","time":"2026-10-03T12:00:00.123Z","instance":"fsn1app1/gitea","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"response_content_type":"text/html; charset=utf-8","upstream_status":200,"action":"forward","counts":{"minute":1,"hour":12,"day":40},"duration_total":3.217,"duration_checks":0.041,"duration_upstream_connect":0.052,"duration_upstream_first_byte":2.874,"duration_upstream_total":3.104}
|
{"type":"request","time":"2026-10-03T12:00:00.123Z","instance":"fsn1app1/gitea","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"response_content_type":"text/html; charset=utf-8","upstream_status":200,"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"duration_total":3.217,"duration_checks":0.041,"duration_upstream_connect":0.052,"duration_upstream_first_byte":2.874,"duration_upstream_total":3.104}
|
||||||
```
|
```
|
||||||
|
|
||||||
A field that does not apply to a request is left out of its line, apart from
|
A field that does not apply to a request is left out of its line, apart from
|
||||||
@@ -527,13 +558,20 @@ which every line has.
|
|||||||
health check, one from a client in `SWWAF_ALLOW_NETS` or
|
health check, one from a client in `SWWAF_ALLOW_NETS` or
|
||||||
`SWWAF_RATE_LIMIT_EXEMPT_NETS`, one for a path that
|
`SWWAF_RATE_LIMIT_EXEMPT_NETS`, one for a path that
|
||||||
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` exempts, and one that `SWWAF_DENY_NETS`, a ban
|
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` exempts, and one that `SWWAF_DENY_NETS`, a ban
|
||||||
or the country lists refuse, or would refuse in `observe` mode. The byte
|
or the country lists refuse, or would refuse in `observe` mode. Its
|
||||||
totals come with the byte limits.
|
`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
|
- `rule_ids` is there for a request that matched rules of the rule files, and
|
||||||
lists their ids in the order they matched, up to the one that refused it.
|
lists their ids in the order they matched, up to the one that refused it.
|
||||||
- `limit_hit` is there for a request that broke a rate limit, and names the
|
- `limit_hit` is there for a request that broke a rate limit, or whose bytes
|
||||||
window whose limit it went over: `minute`, `hour` or `day`, the shortest if it
|
broke a byte limit, and names the window whose limit it went over as `counts`
|
||||||
went over several. `offence` is then `limit`.
|
names it: `minute`, `hour` or `day` for a rate limit, and `minute_bytes`,
|
||||||
|
`hour_bytes` or `day_bytes` for a byte limit, the shortest if it went over
|
||||||
|
several. `offence` is then `limit`. A request whose bytes broke a byte limit
|
||||||
|
is not refused: its `action` is what it would have been otherwise, such as
|
||||||
|
`forward`.
|
||||||
- `ban_expires` is there for a request that made a ban or was refused under one,
|
- `ban_expires` is there for a request that made a ban or was refused under one,
|
||||||
or in `observe` mode would have been refused under one, and gives when the ban
|
or in `observe` mode would have been refused under one, and gives when the ban
|
||||||
ends, in the same form as `time`, or `permanent`.
|
ends, in the same form as `time`, or `permanent`.
|
||||||
@@ -596,8 +634,8 @@ gives, as "Alert webhook schema" in [`SPEC.md`](SPEC.md) describes, and to
|
|||||||
it, as below. An alert is for one of these events, and is sent when
|
it, as below. An alert is for one of these events, and is sent when
|
||||||
`SWWAF_ALERT_EVENTS` names its event:
|
`SWWAF_ALERT_EVENTS` names its event:
|
||||||
|
|
||||||
- `ban`: a ban `smallwebwaf` makes, for a broken rate limit or a clear sign of
|
- `ban`: a ban `smallwebwaf` makes, for a broken rate limit or byte limit or a
|
||||||
attack.
|
clear sign of attack.
|
||||||
- `permanent_ban`: a permanent ban it makes, or a ban for a clear sign of attack
|
- `permanent_ban`: a permanent ban it makes, or a ban for a clear sign of attack
|
||||||
that a request made permanent.
|
that a request made permanent.
|
||||||
- `source_failure`: GeoJS failing or refusing `smallwebwaf`.
|
- `source_failure`: GeoJS failing or refusing `smallwebwaf`.
|
||||||
@@ -633,6 +671,7 @@ is sent on one line:
|
|||||||
"asn": "",
|
"asn": "",
|
||||||
"as_name": "",
|
"as_name": "",
|
||||||
"country": "",
|
"country": "",
|
||||||
|
"kind": "requests",
|
||||||
"limit": 1000,
|
"limit": 1000,
|
||||||
"window": "minute",
|
"window": "minute",
|
||||||
"count": 1001,
|
"count": 1001,
|
||||||
@@ -752,20 +791,23 @@ entries by client address, but for the alerts waiting, with times in UTC.
|
|||||||
|
|
||||||
- `bans.json`: every ban with its notes, indented to be read. A permanent ban's
|
- `bans.json`: every ban with its notes, indented to be read. A permanent ban's
|
||||||
`expires` is `null`. A ban's `cause` is `limit` for a broken rate limit or
|
`expires` is `null`. A ban's `cause` is `limit` for a broken rate limit or
|
||||||
`attack` for a clear sign of attack, for a ban `smallwebwaf` made, and `admin`
|
byte limit or `attack` for a clear sign of attack, for a ban `smallwebwaf`
|
||||||
for one you made or keep. Its `reason` is a short text: for a ban
|
made, and `admin` for one you made or keep. Its `reason` is a short text: for
|
||||||
`smallwebwaf` made, the limit broken, such as
|
a ban `smallwebwaf` made, the limit broken, such as
|
||||||
`requests per minute over the limit of 1000`, or the rule that matched, such
|
`requests per minute over the limit of 1000` or
|
||||||
|
`bytes per hour over the limit of 21474836480`, or the rule that matched, such
|
||||||
as `matched the rule env-file`; for yours, what you wrote. Its `lifted` is
|
as `matched the rule env-file`; for yours, what you wrote. Its `lifted` is
|
||||||
when you lifted it, and is left out until you do.
|
when you lifted it, and is left out until you do. The `kind` in the notes of a
|
||||||
- `clients.json`: each client's two buckets in the minute, the hour and the day,
|
ban for a broken limit is `requests` or `bytes`, what the limit is on.
|
||||||
and its history: when it was first and last seen, its AS number, AS name and
|
- `clients.json`: each client's two buckets of requests in the minute, the hour
|
||||||
country as last looked up and when the lookup gave them, its requests, how
|
and the day, its two buckets of bytes in each, `minute_bytes`, `hour_bytes`
|
||||||
many were forwarded and how many refused (one `smallwebwaf` answered at its
|
and `day_bytes`, and its history: when it was first and last seen, its AS
|
||||||
own endpoints is neither, unless it was refused with `401` for a missing or
|
number, AS name and country as last looked up and when the lookup gave them,
|
||||||
wrong token), the body bytes in each direction, its responses by status class
|
its requests, how many were forwarded and how many refused (one `smallwebwaf`
|
||||||
and its offences by kind. Each client is on a line of its own, so `grep` shows
|
answered at its own endpoints is neither, unless it was refused with `401` for
|
||||||
everything about one.
|
a missing or wrong token), the body bytes in each direction, its responses by
|
||||||
|
status class and its offences by kind. Each client is on a line of its own, so
|
||||||
|
`grep` shows everything about one.
|
||||||
- `lookups.json`: GeoJS's answers, one to a line, each with the client's AS
|
- `lookups.json`: GeoJS's answers, one to a line, each with the client's AS
|
||||||
number, AS name and country, when GeoJS gave it and when it was last used.
|
number, AS name and country, when GeoJS gave it and when it was last used.
|
||||||
- `alerts.json`: the state of the alerts (see "Alerts" above), indented to be
|
- `alerts.json`: the state of the alerts (see "Alerts" above), indented to be
|
||||||
@@ -801,9 +843,9 @@ message naming the file, and the line and column where Go's JSON decoder gives
|
|||||||
them; so does a state directory `smallwebwaf` cannot write. So does an entry
|
them; so does a state directory `smallwebwaf` cannot write. So does an entry
|
||||||
without a field it needs, named with the entry's place in the file: a ban's
|
without a field it needs, named with the entry's place in the file: a ban's
|
||||||
`netblock`, `start` or `expires`, which is `null` for a permanent ban; a
|
`netblock`, `start` or `expires`, which is `null` for a permanent ban; a
|
||||||
client's `client`, or the `start` of a window in which it has requests; an
|
client's `client`, or the `start` of a window in which it has requests or bytes;
|
||||||
answer's `client`, `country`, which is `""` for a client GeoJS cannot place, or
|
an answer's `client`, `country`, which is `""` for a client GeoJS cannot place,
|
||||||
`answered`; a cooldown's `event` or `sent`; an alert waiting's `event` or
|
or `answered`; a cooldown's `event` or `sent`; an alert waiting's `event` or
|
||||||
`time`. So does a ban whose `cause` is not `limit`, `attack` or `admin`, and
|
`time`. So does a ban whose `cause` is not `limit`, `attack` or `admin`, and
|
||||||
alerts waiting for a destination that is not `webhook`, `slack` or `ntfy`. An
|
alerts waiting for a destination that is not `webhook`, `slack` or `ntfy`. An
|
||||||
answer's `asn` or `as_name` left out reads as empty.
|
answer's `asn` or `as_name` left out reads as empty.
|
||||||
@@ -955,7 +997,8 @@ scraped, and keeps this one as `exported_instance` unless the scrape sets
|
|||||||
`smallwebwaf_upstream_duration_seconds`: how long those passed to the app took
|
`smallwebwaf_upstream_duration_seconds`: how long those passed to the app took
|
||||||
from then on, as histograms; `smallwebwaf_requests_in_flight`: the requests
|
from then on, as histograms; `smallwebwaf_requests_in_flight`: the requests
|
||||||
under way.
|
under way.
|
||||||
- `smallwebwaf_rate_limit_hits_total` by `window`,
|
- `smallwebwaf_rate_limit_hits_total` by `window`, `minute`, `hour` or `day`,
|
||||||
|
and `kind`, `requests` for a rate limit or `bytes` for a byte limit,
|
||||||
`smallwebwaf_size_and_time_limit_hits_total` by `limit`, the setting whose
|
`smallwebwaf_size_and_time_limit_hits_total` by `limit`, the setting whose
|
||||||
limit was passed, `smallwebwaf_offences_total` by `kind`, and
|
limit was passed, `smallwebwaf_offences_total` by `kind`, and
|
||||||
`smallwebwaf_bans_made_total` by `cause`, `limit`, `attack` or `admin`, the
|
`smallwebwaf_bans_made_total` by `cause`, `limit`, `attack` or `admin`, the
|
||||||
@@ -1375,7 +1418,8 @@ addresses are never sent to GeoJS.
|
|||||||
body over the size limit; in `observe` mode, only for the size limit, with
|
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
|
what it would have refused for noted in the log line. A request under
|
||||||
`/_smallwebwaf/` that `check` lets through is answered by `answerAdmin`
|
`/_smallwebwaf/` that `check` lets through is answered by `answerAdmin`
|
||||||
instead of reaching the app.
|
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.
|
||||||
- `internal/metrics`: the metrics, counted as the other parts tell it what
|
- `internal/metrics`: the metrics, counted as the other parts tell it what
|
||||||
happened, and served in the Prometheus text format.
|
happened, and served in the Prometheus text format.
|
||||||
- `internal/bans`: the ban ledger: each netblock's bans with their notes, how
|
- `internal/bans`: the ban ledger: each netblock's bans with their notes, how
|
||||||
@@ -1388,8 +1432,9 @@ addresses are never sent to GeoJS.
|
|||||||
client's history and to the notes of its bans; or in the lookup database,
|
client's history and to the notes of its bans; or in the lookup database,
|
||||||
which it reads again when the file is replaced. `internal/lookup/lookuptest`
|
which it reads again when the file is replaced. `internal/lookup/lookuptest`
|
||||||
writes lookup databases for the tests.
|
writes lookup databases for the tests.
|
||||||
- `internal/ratelimit`: the table of clients: counts each client's requests,
|
- `internal/ratelimit`: the table of clients: counts each client's requests and
|
||||||
tells when one takes it over a rate limit, and keeps each client's history.
|
bytes, tells when they take it over a rate limit or a byte limit, and keeps
|
||||||
|
each client's history.
|
||||||
- `internal/state`: reads the state files at start, takes in an admin's edit of
|
- `internal/state`: reads the state files at start, takes in an admin's edit of
|
||||||
one while running, and writes them when they are due and at the stop.
|
one while running, and writes them when they are due and at the stop.
|
||||||
- `internal/requestlog`: the lines on stdout: the request log line and the
|
- `internal/requestlog`: the lines on stdout: the request log line and the
|
||||||
|
|||||||
@@ -66,12 +66,15 @@ func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) {
|
|||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultRules())
|
||||||
|
|
||||||
limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
||||||
bans.Notes{Limit: 1000, Window: "minute"})
|
bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"})
|
||||||
|
byteLimit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.3/32"),
|
||||||
|
midnight(), bans.Notes{Kind: "bytes", Limit: 10 << 30, Window: "hour"})
|
||||||
attack, _ := ledger.BanForAttack(netip.MustParsePrefix("203.0.113.2/32"), midnight(),
|
attack, _ := ledger.BanForAttack(netip.MustParsePrefix("203.0.113.2/32"), midnight(),
|
||||||
bans.Notes{RuleID: "git-dir", Target: "path"})
|
bans.Notes{RuleID: "git-dir", Target: "path"})
|
||||||
|
|
||||||
for _, tc := range []struct{ got, want string }{
|
for _, tc := range []struct{ got, want string }{
|
||||||
{limit.Reason, "requests per minute over the limit of 1000"},
|
{limit.Reason, "requests per minute over the limit of 1000"},
|
||||||
|
{byteLimit.Reason, "bytes per hour over the limit of 10737418240"},
|
||||||
{attack.Reason, "matched the rule git-dir"},
|
{attack.Reason, "matched the rule git-dir"},
|
||||||
} {
|
} {
|
||||||
if tc.got != tc.want {
|
if tc.got != tc.want {
|
||||||
|
|||||||
+18
-13
@@ -1,8 +1,8 @@
|
|||||||
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
||||||
// netblocks of clients that break a rate limit or show a clear sign of
|
// netblocks of clients that break a rate limit or a byte limit or show a
|
||||||
// attack, and those an admin makes, with their notes, as the "Bans"
|
// clear sign of attack, and those an admin makes, with their notes, as
|
||||||
// section of SPEC.md describes. The bans are kept in memory, and written
|
// the "Bans" section of SPEC.md describes. The bans are kept in memory,
|
||||||
// to bans.json and read from it by the state package.
|
// and written to bans.json and read from it by the state package.
|
||||||
package bans
|
package bans
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -97,11 +97,14 @@ type Notes struct {
|
|||||||
ASN string `json:"asn"`
|
ASN string `json:"asn"`
|
||||||
ASName string `json:"as_name"`
|
ASName string `json:"as_name"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
// Limit, Window and Count are, for a ban for a broken limit, the limit
|
// Kind, Limit, Window and Count are, for a ban for a broken limit,
|
||||||
// that was broken, its window, "minute", "hour" or "day", and the
|
// what the limit was on, "requests" for a rate limit or "bytes" for a
|
||||||
// count reached: the client's requests in the window, the one that
|
// byte limit, the limit that was broken, its window, "minute", "hour"
|
||||||
// broke the limit included. These are the requests that counted
|
// or "day", and the count reached: the client's requests, or bytes, in
|
||||||
// toward the ban, and the window is the time over which they came.
|
// the window, those of the request that broke the limit included.
|
||||||
|
// These are what counted toward the ban, and the window is the time
|
||||||
|
// over which they came.
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
Limit int64 `json:"limit,omitempty"`
|
Limit int64 `json:"limit,omitempty"`
|
||||||
Window string `json:"window,omitempty"`
|
Window string `json:"window,omitempty"`
|
||||||
Count float64 `json:"count,omitempty"`
|
Count float64 `json:"count,omitempty"`
|
||||||
@@ -109,8 +112,8 @@ type Notes struct {
|
|||||||
// of the rule file rule that matched, and its target.
|
// of the rule file rule that matched, and its target.
|
||||||
RuleID string `json:"rule_id,omitempty"`
|
RuleID string `json:"rule_id,omitempty"`
|
||||||
Target string `json:"target,omitempty"`
|
Target string `json:"target,omitempty"`
|
||||||
// Request is the request that broke the limit, or that was the clear
|
// Request is the request that broke the limit, or whose bytes broke
|
||||||
// sign of attack.
|
// it, or that was the clear sign of attack.
|
||||||
Request Request `json:"request"`
|
Request Request `json:"request"`
|
||||||
// Requests is how many requests the netblock has sent since it was
|
// Requests is how many requests the netblock has sent since it was
|
||||||
// first seen, and Refused how many of them the ban has refused so
|
// first seen, and Refused how many of them the ban has refused so
|
||||||
@@ -260,7 +263,8 @@ func activeBan(bans []Ban, now time.Time) *Ban {
|
|||||||
// active, as when two of its requests break a limit at once, that ban is
|
// active, as when two of its requests break a limit at once, that ban is
|
||||||
// returned with false, and no other is made. The ledger fills in the
|
// returned with false, and no other is made. The ledger fills in the
|
||||||
// notes' Refused and EarlierBans itself, and gives the ban the reason
|
// notes' Refused and EarlierBans itself, and gives the ban the reason
|
||||||
// "requests per <Window> over the limit of <Limit>", from the notes.
|
// "<Kind> per <Window> over the limit of <Limit>", from the notes, such
|
||||||
|
// as "requests per minute over the limit of 1000".
|
||||||
func (l *Ledger) BanForLimit(
|
func (l *Ledger) BanForLimit(
|
||||||
netblock netip.Prefix, now time.Time, notes Notes,
|
netblock netip.Prefix, now time.Time, notes Notes,
|
||||||
) (Ban, bool) {
|
) (Ban, bool) {
|
||||||
@@ -317,7 +321,8 @@ func (l *Ledger) WouldBePermanent(
|
|||||||
|
|
||||||
// limitReason is the reason of a ban for a broken limit, with notes.
|
// limitReason is the reason of a ban for a broken limit, with notes.
|
||||||
func limitReason(notes Notes) string {
|
func limitReason(notes Notes) string {
|
||||||
return fmt.Sprintf("requests per %s over the limit of %d", notes.Window, notes.Limit)
|
return fmt.Sprintf("%s per %s over the limit of %d",
|
||||||
|
notes.Kind, notes.Window, notes.Limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// attackReason is the reason of a ban for a clear sign of attack, with
|
// attackReason is the reason of a ban for a clear sign of attack, with
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
|
|||||||
|
|
||||||
// As it ends, a clear sign of attack would ban for seven days, and a
|
// As it ends, a clear sign of attack would ban for seven days, and a
|
||||||
// limit broken again for three hours, but neither is made.
|
// limit broken again for three hours, but neither is made.
|
||||||
limitNotes := bans.Notes{Limit: 1, Window: "minute"}
|
limitNotes := bans.Notes{Kind: "requests", Limit: 1, Window: "minute"}
|
||||||
attack, wouldAttack := ledger.WouldBanForAttack(netblock, first.Expires,
|
attack, wouldAttack := ledger.WouldBanForAttack(netblock, first.Expires,
|
||||||
bans.Notes{RuleID: "git-dir"})
|
bans.Notes{RuleID: "git-dir"})
|
||||||
limit, wouldLimit := ledger.WouldBanForLimit(netblock, first.Expires, limitNotes)
|
limit, wouldLimit := ledger.WouldBanForLimit(netblock, first.Expires, limitNotes)
|
||||||
|
|||||||
@@ -91,6 +91,15 @@ type Config struct {
|
|||||||
// limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_PATHS).
|
// limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_PATHS).
|
||||||
// Each starts with /.
|
// Each starts with /.
|
||||||
RateLimitExemptPaths []string
|
RateLimitExemptPaths []string
|
||||||
|
// BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the
|
||||||
|
// most bytes a client's requests may carry in a minute, an hour and a
|
||||||
|
// day (SWWAF_BYTES_LIMIT_PER_MINUTE, SWWAF_BYTES_LIMIT_PER_HOUR and
|
||||||
|
// SWWAF_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count
|
||||||
|
// toward them (SWWAF_BYTES_COUNT): response, request or both.
|
||||||
|
BytesLimitPerMinute int64
|
||||||
|
BytesLimitPerHour int64
|
||||||
|
BytesLimitPerDay int64
|
||||||
|
BytesCount string
|
||||||
// LookupSource is where each client's AS number and country are
|
// LookupSource is where each client's AS number and country are
|
||||||
// looked up (SWWAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
|
// looked up (SWWAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
|
||||||
// LookupDBPath is the lookup database, the IPinfo Lite file looked up
|
// LookupDBPath is the lookup database, the IPinfo Lite file looked up
|
||||||
@@ -266,6 +275,7 @@ var (
|
|||||||
"is not an absolute path, such as /var/lib/smallwebwaf")
|
"is not an absolute path, such as /var/lib/smallwebwaf")
|
||||||
errShortToken = errors.New("is shorter than 32 characters")
|
errShortToken = errors.New("is shorter than 32 characters")
|
||||||
errNotMode = errors.New("is not enforce or observe")
|
errNotMode = errors.New("is not enforce or observe")
|
||||||
|
errNotBytesCount = errors.New("is not response, request or both")
|
||||||
errNotPathPrefix = errors.New(
|
errNotPathPrefix = errors.New(
|
||||||
"is not a path prefix starting with /, such as /assets/")
|
"is not a path prefix starting with /, such as /assets/")
|
||||||
errNotBoolean = errors.New("is not true or false")
|
errNotBoolean = errors.New("is not true or false")
|
||||||
@@ -321,6 +331,10 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
|||||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"),
|
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"),
|
||||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"),
|
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"),
|
||||||
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
||||||
|
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "10G"),
|
||||||
|
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
|
||||||
|
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "50G"),
|
||||||
|
BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"),
|
||||||
LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"),
|
LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"),
|
||||||
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""),
|
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""),
|
||||||
LookupTimeout: env.durationNotOff("SWWAF_LOOKUP_TIMEOUT", "1s"),
|
LookupTimeout: env.durationNotOff("SWWAF_LOOKUP_TIMEOUT", "1s"),
|
||||||
@@ -551,6 +565,17 @@ func (e *environment) count(name, defaultValue string) int64 {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bytesCount reads the setting that is which body bytes count toward the
|
||||||
|
// byte limits: response, request or both.
|
||||||
|
func (e *environment) bytesCount(name, defaultValue string) string {
|
||||||
|
value := e.value(name, defaultValue)
|
||||||
|
if value != "response" && value != "request" && value != "both" {
|
||||||
|
e.check(name, fmt.Errorf("%q %w", value, errNotBytesCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// pathPrefixes reads a setting that is a list of path prefixes.
|
// pathPrefixes reads a setting that is a list of path prefixes.
|
||||||
func (e *environment) pathPrefixes(name, defaultValue string) []string {
|
func (e *environment) pathPrefixes(name, defaultValue string) []string {
|
||||||
prefixes, err := parsePathPrefixes(e.value(name, defaultValue))
|
prefixes, err := parsePathPrefixes(e.value(name, defaultValue))
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ const (
|
|||||||
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
|
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
|
||||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||||
|
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
||||||
|
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
||||||
|
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
||||||
|
bytesCount = "SWWAF_BYTES_COUNT"
|
||||||
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
||||||
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
||||||
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
||||||
@@ -190,6 +194,10 @@ func TestDefaults(t *testing.T) {
|
|||||||
wantLookupSettings(t, cfg, config.Config{
|
wantLookupSettings(t, cfg, config.Config{
|
||||||
LookupSource: defaultLookupSource, LookupTimeout: time.Second,
|
LookupSource: defaultLookupSource, LookupTimeout: time.Second,
|
||||||
})
|
})
|
||||||
|
wantByteLimitSettings(t, cfg, config.Config{
|
||||||
|
BytesLimitPerMinute: 10 << 30, BytesLimitPerHour: 20 << 30,
|
||||||
|
BytesLimitPerDay: 50 << 30, BytesCount: "both",
|
||||||
|
})
|
||||||
|
|
||||||
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
|
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
|
||||||
t.Errorf("%s is %s", upstreamURL, cfg.UpstreamURL)
|
t.Errorf("%s is %s", upstreamURL, cfg.UpstreamURL)
|
||||||
@@ -220,6 +228,22 @@ func TestDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := fromEnvironment(t, environment{})
|
||||||
|
|
||||||
|
// The largest request body and the largest response, both counted.
|
||||||
|
largest := cfg.RequestMaxBytes + cfg.ResponseMaxBytes
|
||||||
|
for _, limit := range []int64{
|
||||||
|
cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.BytesLimitPerDay,
|
||||||
|
} {
|
||||||
|
if largest > limit {
|
||||||
|
t.Errorf("a request of %d bytes breaks the byte limit of %d", largest, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValuesAsSet(t *testing.T) {
|
func TestValuesAsSet(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -302,6 +326,22 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestByteLimitSettingsAsSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := fromEnvironment(t, environment{
|
||||||
|
bytesLimitPerMinute: "512M",
|
||||||
|
bytesLimitPerHour: off,
|
||||||
|
bytesLimitPerDay: "100000",
|
||||||
|
bytesCount: "response",
|
||||||
|
})
|
||||||
|
|
||||||
|
wantByteLimitSettings(t, cfg, config.Config{
|
||||||
|
BytesLimitPerMinute: 512 << 20, BytesLimitPerHour: 0,
|
||||||
|
BytesLimitPerDay: 100000, BytesCount: "response",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestRateLimitExemptPathsAsSet(t *testing.T) {
|
func TestRateLimitExemptPathsAsSet(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -996,6 +1036,9 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
|||||||
{rateLimitPerHour, "1.5"},
|
{rateLimitPerHour, "1.5"},
|
||||||
{rateLimitPerDay, "-1"}, {rateLimitPerDay, "lots"},
|
{rateLimitPerDay, "-1"}, {rateLimitPerDay, "lots"},
|
||||||
{rateLimitExemptPaths, "/assets/,,/static/"},
|
{rateLimitExemptPaths, "/assets/,,/static/"},
|
||||||
|
{bytesLimitPerMinute, "10GB"}, {bytesLimitPerHour, "0"},
|
||||||
|
{bytesLimitPerDay, "-1G"},
|
||||||
|
{bytesCount, "all"}, {bytesCount, "Both"}, {bytesCount, ""},
|
||||||
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
|
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
|
||||||
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
|
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
|
||||||
{addLookupHeaders, "yes"},
|
{addLookupHeaders, "yes"},
|
||||||
@@ -1250,20 +1293,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := fromEnvironment(t, environment{clientRequestTimeout: "45s"})
|
cfg := fromEnvironment(t, environment{clientRequestTimeout: "45s"})
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
|
||||||
|
|
||||||
var line struct {
|
|
||||||
Settings map[string]string `json:"settings"`
|
|
||||||
}
|
|
||||||
|
|
||||||
err := json.Unmarshal(out.Bytes(), &line)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("decode %s: %v", out.Bytes(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
|
|
||||||
want := map[string]string{
|
want := map[string]string{
|
||||||
@@ -1286,6 +1315,10 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
rateLimitPerHour: "10000",
|
rateLimitPerHour: "10000",
|
||||||
rateLimitPerDay: "50000",
|
rateLimitPerDay: "50000",
|
||||||
rateLimitExemptPaths: "",
|
rateLimitExemptPaths: "",
|
||||||
|
bytesLimitPerMinute: "10G",
|
||||||
|
bytesLimitPerHour: "20G",
|
||||||
|
bytesLimitPerDay: "50G",
|
||||||
|
bytesCount: "both",
|
||||||
lookupSource: defaultLookupSource,
|
lookupSource: defaultLookupSource,
|
||||||
lookupDBPath: "",
|
lookupDBPath: "",
|
||||||
lookupTimeout: "1s",
|
lookupTimeout: "1s",
|
||||||
@@ -1323,11 +1356,31 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
alertCooldown: defaultAlertCooldown,
|
alertCooldown: defaultAlertCooldown,
|
||||||
alertMaxPerHour: "60",
|
alertMaxPerHour: "60",
|
||||||
}
|
}
|
||||||
if !maps.Equal(line.Settings, want) {
|
if got := loggedSettings(t, cfg); !maps.Equal(got, want) {
|
||||||
t.Errorf("logged settings\n%v\nwant\n%v", line.Settings, want)
|
t.Errorf("logged settings\n%v\nwant\n%v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loggedSettings returns the settings as cfg logs them, each by its name.
|
||||||
|
func loggedSettings(t *testing.T, cfg *config.Config) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
||||||
|
|
||||||
|
var line struct {
|
||||||
|
Settings map[string]string `json:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.Unmarshal(out.Bytes(), &line)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", out.Bytes(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return line.Settings
|
||||||
|
}
|
||||||
|
|
||||||
// wantSettings checks the settings that are plain values.
|
// wantSettings checks the settings that are plain values.
|
||||||
func wantSettings(t *testing.T, got *config.Config, want config.Config) {
|
func wantSettings(t *testing.T, got *config.Config, want config.Config) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -1351,6 +1404,21 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) {
|
|||||||
wantBanSettings(t, got, want)
|
wantBanSettings(t, got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wantByteLimitSettings checks the settings for the byte limits.
|
||||||
|
func wantByteLimitSettings(t *testing.T, got *config.Config, want config.Config) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if got.BytesLimitPerMinute != want.BytesLimitPerMinute ||
|
||||||
|
got.BytesLimitPerHour != want.BytesLimitPerHour ||
|
||||||
|
got.BytesLimitPerDay != want.BytesLimitPerDay ||
|
||||||
|
got.BytesCount != want.BytesCount {
|
||||||
|
t.Errorf("byte limits %d, %d and %d counting %s, want %d, %d and %d counting %s",
|
||||||
|
got.BytesLimitPerMinute, got.BytesLimitPerHour, got.BytesLimitPerDay,
|
||||||
|
got.BytesCount, want.BytesLimitPerMinute, want.BytesLimitPerHour,
|
||||||
|
want.BytesLimitPerDay, want.BytesCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// wantLookupSettings checks the settings for lookups.
|
// wantLookupSettings checks the settings for lookups.
|
||||||
func wantLookupSettings(t *testing.T, got *config.Config, want config.Config) {
|
func wantLookupSettings(t *testing.T, got *config.Config, want config.Config) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package metrics
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
@@ -89,8 +90,9 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
Help: "How long requests passed to the app took, from then to their end.",
|
Help: "How long requests passed to the app took, from then to their end.",
|
||||||
}),
|
}),
|
||||||
rateLimitHits: counterVec("smallwebwaf_rate_limit_hits_total",
|
rateLimitHits: counterVec("smallwebwaf_rate_limit_hits_total",
|
||||||
"Requests that broke a rate limit, by its window.",
|
"Requests that broke a rate limit or a byte limit, by its window and "+
|
||||||
[]string{"window"}),
|
"its kind, requests or bytes.",
|
||||||
|
[]string{"window", "kind"}),
|
||||||
sizeAndTimeLimitHits: counterVec("smallwebwaf_size_and_time_limit_hits_total",
|
sizeAndTimeLimitHits: counterVec("smallwebwaf_size_and_time_limit_hits_total",
|
||||||
"Requests that passed a size or time limit, by its setting.",
|
"Requests that passed a size or time limit, by its setting.",
|
||||||
[]string{"limit"}),
|
[]string{"limit"}),
|
||||||
@@ -325,7 +327,15 @@ func (m *Metrics) RequestEnded(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if line.LimitHit != "" {
|
if line.LimitHit != "" {
|
||||||
m.rateLimitHits.WithLabelValues(line.LimitHit).Inc()
|
// The log line names a byte limit's window with _bytes after it.
|
||||||
|
window, isBytes := strings.CutSuffix(line.LimitHit, "_bytes")
|
||||||
|
|
||||||
|
kind := ratelimit.KindRequests
|
||||||
|
if isBytes {
|
||||||
|
kind = ratelimit.KindBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
m.rateLimitHits.WithLabelValues(window, kind).Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
if limit != "" {
|
if limit != "" {
|
||||||
|
|||||||
@@ -203,7 +203,16 @@ func startWithAlerts(
|
|||||||
) (*sender, *clock, *proxy.Server, *alerts.Queue) {
|
) (*sender, *clock, *proxy.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startAppWithAlerts is startWithAlerts in front of the app handler.
|
||||||
|
func startAppWithAlerts(
|
||||||
|
t *testing.T, handler http.HandlerFunc, env map[string]string,
|
||||||
|
) (*sender, *clock, *proxy.Server, *alerts.Queue) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
app := startApp(t, handler)
|
||||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
|
|||||||
+77
-22
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
@@ -41,36 +42,92 @@ func (rq *request) banned(now time.Time) bool {
|
|||||||
|
|
||||||
// limitBroken counts the request for the rate limits at now, notes the
|
// limitBroken counts the request for the rate limits at now, notes the
|
||||||
// client's counts for the log line, and reports whether the request takes
|
// client's counts for the log line, and reports whether the request takes
|
||||||
// the client over a limit. In enforce mode such a request bans the
|
// the client over a rate limit, which breaks it.
|
||||||
// client's netblock, and sets the client's counters back to zero; in
|
|
||||||
// observe mode it does neither, and raises the alert for the ban it would
|
|
||||||
// have made, if that alert would be sent.
|
|
||||||
func (rq *request) limitBroken(now time.Time) bool {
|
func (rq *request) limitBroken(now time.Time) bool {
|
||||||
group := clientGroup(rq.client)
|
counts, hit, over := rq.h.limiter.Count(clientGroup(rq.client), now)
|
||||||
|
|
||||||
counts, hit, over := rq.h.limiter.Count(group, now)
|
|
||||||
rq.line.Counts = counts
|
rq.line.Counts = counts
|
||||||
|
|
||||||
if !over {
|
if over {
|
||||||
return false
|
rq.banForLimit(now, hit, rq.h.config.BanResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return over
|
||||||
|
}
|
||||||
|
|
||||||
|
// countBytes counts the request's bytes for the byte limits, once its
|
||||||
|
// response has ended, and notes the client's byte totals for the log line;
|
||||||
|
// its requests stay there as the rate limits counted them. The bytes are
|
||||||
|
// the response's body bytes, the request's, or both, as SWWAF_BYTES_COUNT
|
||||||
|
// says; for an upgraded connection, such as a WebSocket, which has closed
|
||||||
|
// by then, what it carried from the app counts with the response's and
|
||||||
|
// what it carried from the client with the request's. Only a request
|
||||||
|
// passed to the app has them counted, and only one the rate limits
|
||||||
|
// counted; in observe mode, not one that enforce mode would have refused.
|
||||||
|
// Bytes that take the client over a byte limit break it; the response was
|
||||||
|
// passed on whole.
|
||||||
|
func (rq *request) countBytes() {
|
||||||
|
if !rq.counted || rq.line.WouldAction != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, request := rq.out.bytes, rq.requestBytes()
|
||||||
|
if rq.upgraded != nil {
|
||||||
|
response += rq.upgraded.fromApp.Load()
|
||||||
|
request += rq.upgraded.toApp.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes int64
|
||||||
|
|
||||||
|
switch rq.h.config.BytesCount {
|
||||||
|
case "response":
|
||||||
|
bytes = response
|
||||||
|
case "request":
|
||||||
|
bytes = request
|
||||||
|
default: // both
|
||||||
|
bytes = response + request
|
||||||
|
}
|
||||||
|
|
||||||
|
now := rq.h.now()
|
||||||
|
|
||||||
|
counts, hit, over := rq.h.limiter.CountBytes(clientGroup(rq.client), now, bytes)
|
||||||
|
rq.line.Counts.MinuteBytes = counts.MinuteBytes
|
||||||
|
rq.line.Counts.HourBytes = counts.HourBytes
|
||||||
|
rq.line.Counts.DayBytes = counts.DayBytes
|
||||||
|
|
||||||
|
if over {
|
||||||
|
rq.banForLimit(now, hit, rq.out.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// banForLimit bans the client's netblock at now for a broken limit, the
|
||||||
|
// one hit names, and notes the offence for the log line. status is what
|
||||||
|
// the client was sent, or is sent: SWWAF_BAN_RESPONSE for a request over
|
||||||
|
// a rate limit, the app's answer for one whose bytes broke a byte limit.
|
||||||
|
// The ban sets the client's counters back to zero. In observe mode it
|
||||||
|
// makes no ban and sets nothing back, and raises the alert for the ban it
|
||||||
|
// would have made, if that alert would be sent.
|
||||||
|
func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
||||||
rq.line.LimitHit = hit.Window
|
rq.line.LimitHit = hit.Window
|
||||||
|
if hit.Kind == ratelimit.KindBytes {
|
||||||
|
rq.line.LimitHit += "_bytes" // as counts names the byte totals
|
||||||
|
}
|
||||||
|
|
||||||
rq.line.Offence = requestlog.OffenceLimit
|
rq.line.Offence = requestlog.OffenceLimit
|
||||||
|
|
||||||
netblock := rq.h.netblock(rq.client)
|
netblock := rq.h.netblock(rq.client)
|
||||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
|
||||||
return true
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notes := bans.Notes{
|
notes := bans.Notes{
|
||||||
ASN: rq.line.ASN,
|
ASN: rq.line.ASN,
|
||||||
ASName: rq.line.ASName,
|
ASName: rq.line.ASName,
|
||||||
Country: rq.line.Country,
|
Country: rq.line.Country,
|
||||||
|
Kind: hit.Kind,
|
||||||
Limit: hit.Limit,
|
Limit: hit.Limit,
|
||||||
Window: hit.Window,
|
Window: hit.Window,
|
||||||
Count: hit.Requests,
|
Count: hit.Count,
|
||||||
Request: rq.noted(now),
|
Request: rq.noted(now, status),
|
||||||
Requests: rq.netblockRequests(netblock),
|
Requests: rq.netblockRequests(netblock),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,18 +137,16 @@ func (rq *request) limitBroken(now time.Time) bool {
|
|||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ban, made := rq.h.ledger.BanForLimit(netblock, now, notes)
|
ban, made := rq.h.ledger.BanForLimit(netblock, now, notes)
|
||||||
rq.h.limiter.Reset(group)
|
rq.h.limiter.Reset(clientGroup(rq.client))
|
||||||
rq.line.BanExpires = banExpires(ban)
|
rq.line.BanExpires = banExpires(ban)
|
||||||
|
|
||||||
if made {
|
if made {
|
||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// banForAttack bans the client's netblock at now for a clear sign of
|
// banForAttack bans the client's netblock at now for a clear sign of
|
||||||
@@ -110,7 +165,7 @@ func (rq *request) banForAttack(now time.Time, rule rules.Rule) {
|
|||||||
Country: rq.line.Country,
|
Country: rq.line.Country,
|
||||||
RuleID: rule.ID,
|
RuleID: rule.ID,
|
||||||
Target: rule.Target,
|
Target: rule.Target,
|
||||||
Request: rq.noted(now),
|
Request: rq.noted(now, rq.h.config.BanResponse),
|
||||||
Requests: rq.netblockRequests(netblock),
|
Requests: rq.netblockRequests(netblock),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,16 +232,16 @@ func (rq *request) alertBan(ban bans.Ban) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// noted is the request, refused at now with SWWAF_BAN_RESPONSE, or in
|
// noted is the request, at now, with status, what the client was sent, or
|
||||||
// observe mode as it would have been, as the notes of the ban it makes
|
// in observe mode would have been, as the notes of the ban it makes keep
|
||||||
// keep it.
|
// it.
|
||||||
func (rq *request) noted(now time.Time) bans.Request {
|
func (rq *request) noted(now time.Time, status int) bans.Request {
|
||||||
return bans.Request{
|
return bans.Request{
|
||||||
Time: now,
|
Time: now,
|
||||||
Method: rq.in.Method,
|
Method: rq.in.Method,
|
||||||
Host: rq.in.Host,
|
Host: rq.in.Host,
|
||||||
Path: rq.in.URL.RequestURI(),
|
Path: rq.in.URL.RequestURI(),
|
||||||
Status: rq.h.config.BanResponse,
|
Status: status,
|
||||||
UserAgent: rq.in.UserAgent(),
|
UserAgent: rq.in.UserAgent(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ func TestBanNotes(t *testing.T) {
|
|||||||
ASN: asnDE,
|
ASN: asnDE,
|
||||||
ASName: asNameDE,
|
ASName: asNameDE,
|
||||||
Country: "DE",
|
Country: "DE",
|
||||||
|
Kind: "requests",
|
||||||
Limit: 1,
|
Limit: 1,
|
||||||
Window: minute,
|
Window: minute,
|
||||||
Count: 2,
|
Count: 2,
|
||||||
|
|||||||
@@ -103,6 +103,48 @@ func (b *responseBody) Close() error {
|
|||||||
return b.body.Close()
|
return b.body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// upgradedConn is the connection to the app once the app has switched
|
||||||
|
// protocols, as for a WebSocket. ReverseProxy writes to it what the client
|
||||||
|
// sends and reads from it what the app sends, on goroutines of its own,
|
||||||
|
// until the connection closes; it counts the bytes each way, for the byte
|
||||||
|
// limits.
|
||||||
|
type upgradedConn struct {
|
||||||
|
io.ReadWriteCloser
|
||||||
|
|
||||||
|
// fromApp is how many bytes the app has sent, and toApp how many the
|
||||||
|
// client has.
|
||||||
|
fromApp atomic.Int64
|
||||||
|
toApp atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reads what the app sends.
|
||||||
|
func (c *upgradedConn) Read(p []byte) (int, error) {
|
||||||
|
n, err := c.ReadWriteCloser.Read(p)
|
||||||
|
c.fromApp.Add(int64(n))
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write sends the app what the client sent.
|
||||||
|
func (c *upgradedConn) Write(p []byte) (int, error) {
|
||||||
|
n, err := c.ReadWriteCloser.Write(p)
|
||||||
|
c.toApp.Add(int64(n))
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseWrite tells the app that the client sends no more, while what the
|
||||||
|
// app sends still passes. ReverseProxy calls it once the client has
|
||||||
|
// stopped sending, and closes the connection there if it is not supported.
|
||||||
|
func (c *upgradedConn) CloseWrite() error {
|
||||||
|
conn, ok := c.ReadWriteCloser.(interface{ CloseWrite() error })
|
||||||
|
if !ok {
|
||||||
|
return http.ErrNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn.CloseWrite()
|
||||||
|
}
|
||||||
|
|
||||||
// limitBody returns body, cut off with an *http.MaxBytesError after
|
// limitBody returns body, cut off with an *http.MaxBytesError after
|
||||||
// maxBytes, or unchanged if maxBytes is zero, which is off.
|
// maxBytes, or unchanged if maxBytes is zero, which is off.
|
||||||
func limitBody(body io.ReadCloser, maxBytes int64) io.ReadCloser {
|
func limitBody(body io.ReadCloser, maxBytes int64) io.ReadCloser {
|
||||||
|
|||||||
@@ -0,0 +1,583 @@
|
|||||||
|
package proxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The byte limit settings.
|
||||||
|
const (
|
||||||
|
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
||||||
|
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
||||||
|
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
||||||
|
bytesCount = "SWWAF_BYTES_COUNT"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The values of SWWAF_BYTES_COUNT.
|
||||||
|
const (
|
||||||
|
countResponse = "response"
|
||||||
|
countRequest = "request"
|
||||||
|
countBoth = "both"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// bodyBytes is the size of the body of each request these tests send
|
||||||
|
// with one, and answerBytes that of each answer of the app.
|
||||||
|
bodyBytes = 30
|
||||||
|
answerBytes = 70
|
||||||
|
// byteLimit is the byte limit these tests set, as a setting: a request
|
||||||
|
// with a body and its answer, 100 bytes, go over it.
|
||||||
|
byteLimit = "99"
|
||||||
|
// minuteBytes is limit_hit for SWWAF_BYTES_LIMIT_PER_MINUTE.
|
||||||
|
minuteBytes = "minute_bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const scraper = "192.0.2.200"
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
setting, window string
|
||||||
|
// apart is the time between the two requests, which the window
|
||||||
|
// still covers.
|
||||||
|
apart time.Duration
|
||||||
|
}{
|
||||||
|
{bytesLimitPerMinute, minute, 0},
|
||||||
|
{bytesLimitPerHour, "hour", 2 * time.Minute},
|
||||||
|
{bytesLimitPerDay, "day", 2 * time.Hour},
|
||||||
|
} {
|
||||||
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk := startWithAnswers(t, map[string]string{
|
||||||
|
tc.setting: byteLimit, metricsToken: token,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 70 bytes are within the limit of 99.
|
||||||
|
line, _ := s.download()
|
||||||
|
if line.LimitHit != "" || line.Offence != "" {
|
||||||
|
t.Errorf("log line has limit_hit %q and offence %q, want neither",
|
||||||
|
line.LimitHit, line.Offence)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 140 bytes are over it. The response is passed on whole, and
|
||||||
|
// then bans the client for an hour.
|
||||||
|
clk.advance(tc.apart)
|
||||||
|
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
line, got := s.download()
|
||||||
|
if got.err != nil || len(got.body) != answerBytes ||
|
||||||
|
line.ResponseBytes != answerBytes {
|
||||||
|
t.Errorf("got %d bytes (%v), and the log line has response_bytes %d, "+
|
||||||
|
"want %d", len(got.body), got.err, line.ResponseBytes, answerBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.LimitHit != tc.window+"_bytes" || line.Offence != requestlog.OffenceLimit ||
|
||||||
|
line.BanExpires != expires {
|
||||||
|
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
||||||
|
"want %s_bytes, limit and %s", line.LimitHit, line.Offence,
|
||||||
|
line.BanExpires, tc.window, expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
|
wantMetric(t, s.scrape(scraper), `smallwebwaf_rate_limit_hits_total{`+
|
||||||
|
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: "50"})
|
||||||
|
|
||||||
|
// The answer's 70 bytes are over the limit of 50 on their own.
|
||||||
|
line, got := s.download()
|
||||||
|
if got.err != nil || len(got.body) != answerBytes || line.LimitHit != minuteBytes {
|
||||||
|
t.Errorf("got %d bytes (%v), and the log line has limit_hit %q, want %d and %s",
|
||||||
|
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, _, _ := startAppWithAlerts(t, breakOff, map[string]string{
|
||||||
|
bytesLimitPerMinute: "50",
|
||||||
|
})
|
||||||
|
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
// The 70 bytes passed on before the app broke off are over the limit of
|
||||||
|
// 50, and ban the client for an hour.
|
||||||
|
line, got := s.requestWithBody(http.MethodGet, client, "/", "", "", http.StatusOK,
|
||||||
|
requestlog.ActionUpstreamError)
|
||||||
|
if len(got.body) != answerBytes || line.LimitHit != minuteBytes ||
|
||||||
|
line.BanExpires != expires {
|
||||||
|
t.Errorf("got %d bytes, and the log line has limit_hit %q and ban_expires %q, "+
|
||||||
|
"want %d, %s and %s", len(got.body), line.LimitHit, line.BanExpires,
|
||||||
|
answerBytes, minuteBytes, expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
setting string
|
||||||
|
counted float64
|
||||||
|
}{
|
||||||
|
{countResponse, answerBytes},
|
||||||
|
{countRequest, bodyBytes},
|
||||||
|
{countBoth, bodyBytes + answerBytes},
|
||||||
|
} {
|
||||||
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _, _ := startAppWithAlerts(t, answerAfterUpgrade, map[string]string{
|
||||||
|
bytesLimitPerMinute: "29", bytesCount: tc.setting,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The client sends 30 bytes and the app 70, each over the limit
|
||||||
|
// of 29, which bans the client once the WebSocket has closed.
|
||||||
|
line := s.webSocket()
|
||||||
|
if line.LimitHit != minuteBytes || line.Counts.MinuteBytes != tc.counted {
|
||||||
|
t.Errorf("log line has limit_hit %q and minute_bytes %v, want %s and %v",
|
||||||
|
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebSocketPassesTheAnswerAfterTheClientStopsSending(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app := startApp(t, echoOnceTheClientStops)
|
||||||
|
addr, out := startProxy(t, app.URL,
|
||||||
|
map[string]string{trustedProxies: trustLocalhost})
|
||||||
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
|
|
||||||
|
conn, reader := s.openWebSocket()
|
||||||
|
send(t, conn, uploadBody)
|
||||||
|
|
||||||
|
// The client closes its sending side and waits for the answer, which the
|
||||||
|
// app sends only once it has seen the client stop. smallwebwaf passes the
|
||||||
|
// close on to the app through CloseWrite on upgradedConn; without that,
|
||||||
|
// it closes both connections, and the answer is lost.
|
||||||
|
tcp, ok := conn.(*net.TCPConn)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("connection is a %T, want a *net.TCPConn", conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := tcp.CloseWrite()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close the sending side: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := io.ReadAll(reader)
|
||||||
|
if err != nil || string(got) != uploadBody {
|
||||||
|
t.Errorf("got %q (%v), want %q", got, err, uploadBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.closeWebSocket(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBytesCountSaysWhichBytesCount(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
setting string
|
||||||
|
// each is the bytes each request counts, and breaking the request
|
||||||
|
// that goes over the limit of 99.
|
||||||
|
each float64
|
||||||
|
breaking int
|
||||||
|
}{
|
||||||
|
{countResponse, answerBytes, 2},
|
||||||
|
{countRequest, bodyBytes, 4},
|
||||||
|
{countBoth, bodyBytes + answerBytes, 1},
|
||||||
|
} {
|
||||||
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
|
bytesLimitPerMinute: byteLimit, bytesCount: tc.setting,
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 1; i <= tc.breaking; i++ {
|
||||||
|
line := s.upload()
|
||||||
|
|
||||||
|
want := ""
|
||||||
|
if i == tc.breaking {
|
||||||
|
want = minuteBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
counted := float64(i) * tc.each
|
||||||
|
if line.LimitHit != want || line.Counts.MinuteBytes != counted {
|
||||||
|
t.Errorf("request %d: log line has limit_hit %q and minute_bytes %v, "+
|
||||||
|
"want %q and %v", i, line.LimitHit, line.Counts.MinuteBytes,
|
||||||
|
want, counted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS
|
||||||
|
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||||
|
)
|
||||||
|
|
||||||
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
|
bytesLimitPerMinute: byteLimit,
|
||||||
|
allowNets: allowed,
|
||||||
|
rateLimitExemptNets: exempt,
|
||||||
|
rateLimitExemptPaths: "/assets/",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each sends 200 bytes, none of which is counted.
|
||||||
|
for _, sent := range []struct{ from, path string }{
|
||||||
|
{allowed, "/"}, {exempt, "/"}, {client, "/assets/app.js"},
|
||||||
|
} {
|
||||||
|
for range 2 {
|
||||||
|
line, _ := s.requestWithBody(http.MethodPost, sent.from, sent.path,
|
||||||
|
uploadHeader, uploadBody, http.StatusOK, requestlog.ActionForward)
|
||||||
|
if _, counted := line.fields["counts"]; counted || line.LimitHit != "" {
|
||||||
|
t.Errorf("%s %s: log line has counts %v and limit_hit %q, want neither",
|
||||||
|
sent.from, sent.path, line.fields["counts"], line.LimitHit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A path that is not exempt is counted, and breaks the limit.
|
||||||
|
line := s.upload()
|
||||||
|
if line.LimitHit != minuteBytes {
|
||||||
|
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestByteLimitsOffCountTheBytesAndBanNoOne(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const off = "off"
|
||||||
|
|
||||||
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
|
bytesLimitPerMinute: off, bytesLimitPerHour: off, bytesLimitPerDay: off,
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
line := s.upload()
|
||||||
|
|
||||||
|
counted := float64(i * (bodyBytes + answerBytes))
|
||||||
|
if line.LimitHit != "" || line.Counts.MinuteBytes != counted ||
|
||||||
|
line.Counts.HourBytes != counted || line.Counts.DayBytes != counted {
|
||||||
|
t.Errorf("request %d: log line has limit_hit %q and counts %+v, "+
|
||||||
|
"want none and %v bytes in each window", i, line.LimitHit,
|
||||||
|
line.Counts, counted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBanForABrokenByteLimitHasItsNotesAndItsAlert(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||||
|
bytesLimitPerMinute: byteLimit,
|
||||||
|
})
|
||||||
|
start := clk.Now()
|
||||||
|
|
||||||
|
s.requestWithBody(http.MethodPost, client, "/upload?part=1", uploadHeader,
|
||||||
|
uploadBody, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
netblock := netip.MustParsePrefix(client + "/32")
|
||||||
|
want := bans.Ban{
|
||||||
|
Netblock: netblock,
|
||||||
|
Start: start,
|
||||||
|
Expires: start.Add(time.Hour),
|
||||||
|
Cause: bans.CauseLimit,
|
||||||
|
Reason: "bytes per minute over the limit of " + byteLimit,
|
||||||
|
Notes: bans.Notes{
|
||||||
|
Kind: "bytes",
|
||||||
|
Limit: 99,
|
||||||
|
Window: minute,
|
||||||
|
Count: bodyBytes + answerBytes,
|
||||||
|
// The request as it was answered, by the app.
|
||||||
|
Request: bans.Request{
|
||||||
|
Time: start,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Host: appHost,
|
||||||
|
Path: "/upload?part=1",
|
||||||
|
Status: http.StatusOK,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
},
|
||||||
|
Requests: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := server.Ledger.Bans(netblock)
|
||||||
|
if len(got) != 1 || got[0] != want {
|
||||||
|
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, want,
|
||||||
|
requestlog.FormatTime(want.Expires)))
|
||||||
|
|
||||||
|
if offences := historyOf(t, server, client).Offences.Limit; offences != 1 {
|
||||||
|
t.Errorf("history counts %d offences for a limit, want 1", offences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveModeLogsAndAlertsAByteLimitAndBansNoOne(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||||
|
mode: observe,
|
||||||
|
bytesLimitPerMinute: byteLimit,
|
||||||
|
})
|
||||||
|
start := clk.Now()
|
||||||
|
|
||||||
|
// No ban sets the client's counters back to zero, so each request
|
||||||
|
// breaks the limit again. The answer is the app's either way, and the
|
||||||
|
// alert for the ban is not sent twice within the cooldown.
|
||||||
|
for range 2 {
|
||||||
|
line := s.upload()
|
||||||
|
wantWouldAction(t, line, "")
|
||||||
|
|
||||||
|
if line.LimitHit != minuteBytes || line.Offence != requestlog.OffenceLimit ||
|
||||||
|
line.BanExpires != "" {
|
||||||
|
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
||||||
|
"want %s, limit and none", line.LimitHit, line.Offence, line.BanExpires,
|
||||||
|
minuteBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||||
|
t.Errorf("the ledger holds %+v, want no ban", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 1 {
|
||||||
|
t.Fatalf("%d alerts wait, want 1: %+v", len(waiting), waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
notes, _ := waiting[0].Detail["notes"].(bans.Notes)
|
||||||
|
alert := banAlert(alerts.EventBan, start, client, bans.Ban{
|
||||||
|
Netblock: netip.MustParsePrefix(client + "/32"), Cause: bans.CauseLimit,
|
||||||
|
Reason: "bytes per minute over the limit of " + byteLimit, Notes: notes,
|
||||||
|
}, requestlog.FormatTime(start.Add(time.Hour)))
|
||||||
|
alert.Detail["mode"] = observe
|
||||||
|
wantAlerts(t, queue, alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
|
mode: observe,
|
||||||
|
rateLimitPerMinute: "1",
|
||||||
|
bytesLimitPerMinute: "150",
|
||||||
|
})
|
||||||
|
|
||||||
|
s.upload()
|
||||||
|
|
||||||
|
// The second request breaks the rate limit, which in enforce mode would
|
||||||
|
// refuse it before the app sent anything, so its 100 bytes are not
|
||||||
|
// counted, and the byte limit is not broken. Its line gives the bytes
|
||||||
|
// counted before it.
|
||||||
|
line := s.upload()
|
||||||
|
wantWouldAction(t, line, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
|
if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes {
|
||||||
|
t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d",
|
||||||
|
line.LimitHit, line.Counts.MinuteBytes, bodyBytes+answerBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadHeader and uploadBody are the header and the body of a request
|
||||||
|
// with a body of bodyBytes.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // a constant cannot call strings.Repeat
|
||||||
|
var (
|
||||||
|
uploadHeader = "Content-Length: " + strconv.Itoa(bodyBytes)
|
||||||
|
uploadBody = strings.Repeat("u", bodyBytes)
|
||||||
|
)
|
||||||
|
|
||||||
|
// readAndAnswer is the app of these tests: it reads each request's whole
|
||||||
|
// body and answers with answerBytes bytes.
|
||||||
|
func readAndAnswer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
_, _ = io.WriteString(w, strings.Repeat("a", answerBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// breakOff is an app that announces an answer of twice answerBytes, and
|
||||||
|
// breaks off after answerBytes.
|
||||||
|
func breakOff(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(2*answerBytes))
|
||||||
|
_, _ = io.WriteString(w, strings.Repeat("a", answerBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerAfterUpgrade is an app that switches protocols, as for a
|
||||||
|
// WebSocket, and then answers each line it receives with a line of
|
||||||
|
// answerBytes.
|
||||||
|
func answerAfterUpgrade(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
|
||||||
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||||
|
_ = buffered.Flush()
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, err := buffered.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = buffered.WriteString(strings.Repeat("a", answerBytes-1) + "\n")
|
||||||
|
_ = buffered.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// echoOnceTheClientStops is an app that switches protocols, as for a
|
||||||
|
// WebSocket, reads what the client sends until the client stops sending,
|
||||||
|
// and then sends it all back.
|
||||||
|
func echoOnceTheClientStops(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
|
||||||
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||||
|
_ = buffered.Flush()
|
||||||
|
|
||||||
|
received, _ := io.ReadAll(buffered)
|
||||||
|
_, _ = buffered.Write(received)
|
||||||
|
_ = buffered.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// webSocket opens a WebSocket from client to answerAfterUpgrade, sends a
|
||||||
|
// line of bodyBytes on it, reads the answer, and closes it. It checks the
|
||||||
|
// answer, and the log line as request does, and returns the log line.
|
||||||
|
func (s *sender) webSocket() logLine {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
conn, reader := s.openWebSocket()
|
||||||
|
send(s.t, conn, strings.Repeat("u", bodyBytes-1)+"\n")
|
||||||
|
|
||||||
|
got, err := reader.ReadString('\n')
|
||||||
|
if err != nil || len(got) != answerBytes {
|
||||||
|
s.t.Errorf("got %d bytes (%v), want %d", len(got), err, answerBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.closeWebSocket(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openWebSocket sends a request from client to switch protocols, as for a
|
||||||
|
// WebSocket, and checks that the app switches. It returns the connection,
|
||||||
|
// on which reading fails once waitLimit has passed, and a reader of what
|
||||||
|
// the app sends on it.
|
||||||
|
func (s *sender) openWebSocket() (net.Conn, *bufio.Reader) {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
conn := dial(s.t, s.addr)
|
||||||
|
send(s.t, conn, "GET /socket HTTP/1.1\r\nHost: "+appHost+"\r\n"+forwardedFor+
|
||||||
|
": "+client+"\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||||
|
|
||||||
|
err := conn.SetReadDeadline(time.Now().Add(waitLimit))
|
||||||
|
if err != nil {
|
||||||
|
s.t.Fatalf("set read deadline: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
|
||||||
|
res, err := http.ReadResponse(reader, nil)
|
||||||
|
if err != nil {
|
||||||
|
s.t.Fatalf("read the answer to the upgrade: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode != http.StatusSwitchingProtocols {
|
||||||
|
s.t.Fatalf("status %d, want %d", res.StatusCode, http.StatusSwitchingProtocols)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn, reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeWebSocket closes conn, a WebSocket openWebSocket opened, checks its
|
||||||
|
// log line as request does, and returns it.
|
||||||
|
func (s *sender) closeWebSocket(conn net.Conn) logLine {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
line := s.out.requestLines(s.t, s.sent+1)[s.sent]
|
||||||
|
s.sent++
|
||||||
|
wantLine(s.t, line, http.StatusSwitchingProtocols, requestlog.ActionForward)
|
||||||
|
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWithAnswers is startAppWithAlerts in front of readAndAnswer, for a
|
||||||
|
// test that looks at neither the server nor the alerts.
|
||||||
|
func startWithAnswers(t *testing.T, env map[string]string) (*sender, *clock) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
s, clk, _, _ := startAppWithAlerts(t, readAndAnswer, env)
|
||||||
|
|
||||||
|
return s, clk
|
||||||
|
}
|
||||||
|
|
||||||
|
// download sends a GET request for / from client, and checks that the
|
||||||
|
// app's answer is passed on, as request does. It returns the log line and
|
||||||
|
// the answer.
|
||||||
|
func (s *sender) download() (logLine, answer) {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
return s.requestWithBody(http.MethodGet, client, "/", "", "", http.StatusOK,
|
||||||
|
requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload is download for a POST request with a body of bodyBytes, and
|
||||||
|
// returns the log line.
|
||||||
|
func (s *sender) upload() logLine {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
line, _ := s.requestWithBody(http.MethodPost, client, "/", uploadHeader,
|
||||||
|
uploadBody, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
return line
|
||||||
|
}
|
||||||
@@ -222,8 +222,8 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
metrics := s.scrape(scraper)
|
metrics := s.scrape(scraper)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_requests_total{action="denied",instance="app",status_class="none"}`, 1)
|
`smallwebwaf_requests_total{action="denied",instance="app",status_class="none"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
||||||
`smallwebwaf_rate_limit_hits_total{instance="app",window="minute"}`, 1)
|
`kind="requests",window="minute"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
||||||
@@ -238,8 +238,8 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
s.get(client, 0, requestlog.ActionRateLimited)
|
s.get(client, 0, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
metrics = s.scrape(scraper)
|
metrics = s.scrape(scraper)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
||||||
`smallwebwaf_rate_limit_hits_total{instance="app",window="minute"}`, 2)
|
`kind="requests",window="minute"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 2)
|
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 2)
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ func wantAnswer(t *testing.T, got answer, body []byte) {
|
|||||||
func wantRequestFields(t *testing.T, line logLine, host string, sent, received int) {
|
func wantRequestFields(t *testing.T, line logLine, host string, sent, received int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
bytes := float64(sent + received)
|
||||||
|
|
||||||
want := withTimings(line, requestlog.Line{
|
want := withTimings(line, requestlog.Line{
|
||||||
Type: requestType, Time: line.Time, Instance: "app",
|
Type: requestType, Time: line.Time, Instance: "app",
|
||||||
ClientIP: localhost, Method: http.MethodPatch, Scheme: plain, Host: host,
|
ClientIP: localhost, Method: http.MethodPatch, Scheme: plain, Host: host,
|
||||||
@@ -131,7 +133,10 @@ func wantRequestFields(t *testing.T, line logLine, host string, sent, received i
|
|||||||
RequestID: line.RequestID, PeerIP: localhost, ClientGroup: localhost + "/32",
|
RequestID: line.RequestID, PeerIP: localhost, ClientGroup: localhost + "/32",
|
||||||
ContentLength: int64(sent), ResponseContentType: "text/plain; charset=utf-8",
|
ContentLength: int64(sent), ResponseContentType: "text/plain; charset=utf-8",
|
||||||
UpstreamStatus: http.StatusTeapot, Action: requestlog.ActionForward,
|
UpstreamStatus: http.StatusTeapot, Action: requestlog.ActionForward,
|
||||||
Counts: ratelimit.Counts{Minute: 1, Hour: 1, Day: 1},
|
Counts: ratelimit.Counts{
|
||||||
|
Minute: 1, Hour: 1, Day: 1,
|
||||||
|
MinuteBytes: bytes, HourBytes: bytes, DayBytes: bytes,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if !reflect.DeepEqual(line.Line, want) {
|
if !reflect.DeepEqual(line.Line, want) {
|
||||||
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
|
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ func New(params Params) *Server {
|
|||||||
PerMinute: params.Config.RateLimitPerMinute,
|
PerMinute: params.Config.RateLimitPerMinute,
|
||||||
PerHour: params.Config.RateLimitPerHour,
|
PerHour: params.Config.RateLimitPerHour,
|
||||||
PerDay: params.Config.RateLimitPerDay,
|
PerDay: params.Config.RateLimitPerDay,
|
||||||
|
BytesPerMinute: params.Config.BytesLimitPerMinute,
|
||||||
|
BytesPerHour: params.Config.BytesLimitPerHour,
|
||||||
|
BytesPerDay: params.Config.BytesLimitPerDay,
|
||||||
}),
|
}),
|
||||||
ledger: bans.New(bans.Rules{
|
ledger: bans.New(bans.Rules{
|
||||||
LimitBanDuration: params.Config.LimitBanDuration,
|
LimitBanDuration: params.Config.LimitBanDuration,
|
||||||
@@ -226,5 +229,10 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Once the response has ended, before the request is added to its
|
||||||
|
// client's history. Deferred, since ReverseProxy panics to end a
|
||||||
|
// response it cannot finish.
|
||||||
|
defer rq.countBytes()
|
||||||
|
|
||||||
rq.forward(r.Context())
|
rq.forward(r.Context())
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-16
@@ -3,6 +3,7 @@ package proxy
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptrace"
|
"net/http/httptrace"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
@@ -54,6 +55,9 @@ type request struct {
|
|||||||
// what the lookup gave then, the zero Answer while GeoJS had given none.
|
// what the lookup gave then, the zero Answer while GeoJS had given none.
|
||||||
lookedUp bool
|
lookedUp bool
|
||||||
lookupAnswer lookup.Answer
|
lookupAnswer lookup.Answer
|
||||||
|
// counted is true for a request the rate limits counted, whose bytes
|
||||||
|
// the byte limits count once it has ended.
|
||||||
|
counted bool
|
||||||
start time.Time
|
start time.Time
|
||||||
// checked is when the checks were done, and upstreamStart when the
|
// checked is when the checks were done, and upstreamStart when the
|
||||||
// request was handed to the app.
|
// request was handed to the app.
|
||||||
@@ -65,6 +69,9 @@ type request struct {
|
|||||||
refused atomic.Pointer[refusal]
|
refused atomic.Pointer[refusal]
|
||||||
// complete is true once the app's whole answer has been passed on.
|
// complete is true once the app's whole answer has been passed on.
|
||||||
complete bool
|
complete bool
|
||||||
|
// upgraded is the connection to the app once the app has switched
|
||||||
|
// protocols, as for a WebSocket, and nil otherwise.
|
||||||
|
upgraded *upgradedConn
|
||||||
|
|
||||||
// mu guards what follows. The timeouts run on goroutines of their
|
// mu guards what follows. The timeouts run on goroutines of their
|
||||||
// own, and the transport starts and stops them, and notes the times
|
// own, and the transport starts and stops them, and notes the times
|
||||||
@@ -205,8 +212,9 @@ func (rq *request) check(ctx context.Context) *refusal {
|
|||||||
// them refuses is not counted for the rate limits. Then come the rate
|
// 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
|
// 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
|
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
|
||||||
// every other request is counted, and last the rule files. ctx is the
|
// every other request is counted, and last the rule files. A request
|
||||||
// request's own context.
|
// 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 {
|
func (rq *request) checkClient(ctx context.Context) string {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
if isInside(rq.client, cfg.AllowNets) {
|
if isInside(rq.client, cfg.AllowNets) {
|
||||||
@@ -229,9 +237,9 @@ func (rq *request) checkClient(ctx context.Context) string {
|
|||||||
return requestlog.ActionCountryDenied
|
return requestlog.ActionCountryDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
exempt := isInside(rq.client, cfg.RateLimitExemptNets) ||
|
rq.counted = !isInside(rq.client, cfg.RateLimitExemptNets) &&
|
||||||
pathExempt(rq.in.URL, cfg.RateLimitExemptPaths)
|
!pathExempt(rq.in.URL, cfg.RateLimitExemptPaths)
|
||||||
if !exempt && rq.limitBroken(now) {
|
if rq.counted && rq.limitBroken(now) {
|
||||||
return requestlog.ActionRateLimited
|
return requestlog.ActionRateLimited
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,11 +332,18 @@ func (rq *request) modifyResponse(res *http.Response) error {
|
|||||||
if res.StatusCode == http.StatusSwitchingProtocols {
|
if res.StatusCode == http.StatusSwitchingProtocols {
|
||||||
// An upgraded connection, such as a WebSocket, is not cut by the
|
// An upgraded connection, such as a WebSocket, is not cut by the
|
||||||
// timeouts. ReverseProxy writes this answer straight to the
|
// timeouts. ReverseProxy writes this answer straight to the
|
||||||
// connection it takes over, not through rq.out.
|
// connection it takes over, not through rq.out, and then copies
|
||||||
|
// what passes each way through res.Body, the connection to the app.
|
||||||
rq.stopTimers()
|
rq.stopTimers()
|
||||||
rq.out.status = res.StatusCode
|
rq.out.status = res.StatusCode
|
||||||
rq.line.Websocket = true
|
rq.line.Websocket = true
|
||||||
|
|
||||||
|
conn, ok := res.Body.(io.ReadWriteCloser)
|
||||||
|
if ok {
|
||||||
|
rq.upgraded = &upgradedConn{ReadWriteCloser: conn}
|
||||||
|
res.Body = rq.upgraded
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,10 +446,7 @@ func (rq *request) finish() {
|
|||||||
line.ResponseContentType = header.Get("Content-Type")
|
line.ResponseContentType = header.Get("Content-Type")
|
||||||
line.CacheControl = header.Get("Cache-Control")
|
line.CacheControl = header.Get("Cache-Control")
|
||||||
line.Location = header.Get("Location")
|
line.Location = header.Get("Location")
|
||||||
|
line.RequestBytes = rq.requestBytes()
|
||||||
if rq.body != nil {
|
|
||||||
line.RequestBytes = rq.body.bytes.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// limit is the setting whose size or time limit the request passed.
|
// limit is the setting whose size or time limit the request passed.
|
||||||
var limit string
|
var limit string
|
||||||
@@ -497,11 +509,6 @@ func timing(start, end time.Time) *float64 {
|
|||||||
// may have come before either was there, and one from GeoJS that comes
|
// may have come before either was there, and one from GeoJS that comes
|
||||||
// later is added when it comes.
|
// later is added when it comes.
|
||||||
func (rq *request) addToHistory() {
|
func (rq *request) addToHistory() {
|
||||||
var requestBytes int64
|
|
||||||
if rq.body != nil {
|
|
||||||
requestBytes = rq.body.bytes.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
forwarded := !rq.upstreamStart.IsZero()
|
forwarded := !rq.upstreamStart.IsZero()
|
||||||
group := clientGroup(rq.client)
|
group := clientGroup(rq.client)
|
||||||
|
|
||||||
@@ -509,7 +516,7 @@ func (rq *request) addToHistory() {
|
|||||||
Forwarded: forwarded,
|
Forwarded: forwarded,
|
||||||
Refused: !forwarded && rq.refused.Load() != nil,
|
Refused: !forwarded && rq.refused.Load() != nil,
|
||||||
Status: rq.out.status,
|
Status: rq.out.status,
|
||||||
RequestBytes: requestBytes,
|
RequestBytes: rq.requestBytes(),
|
||||||
ResponseBytes: rq.out.bytes,
|
ResponseBytes: rq.out.bytes,
|
||||||
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
||||||
})
|
})
|
||||||
@@ -531,6 +538,15 @@ func (rq *request) addToHistory() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestBytes is how many bytes of the request's body have been read.
|
||||||
|
func (rq *request) requestBytes() int64 {
|
||||||
|
if rq.body == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return rq.body.bytes.Load()
|
||||||
|
}
|
||||||
|
|
||||||
// clientRequestDeadline is when the client must have sent its whole
|
// clientRequestDeadline is when the client must have sent its whole
|
||||||
// request, or zero when SWWAF_CLIENT_REQUEST_TIMEOUT is off.
|
// request, or zero when SWWAF_CLIENT_REQUEST_TIMEOUT is off.
|
||||||
func (rq *request) clientRequestDeadline() time.Time {
|
func (rq *request) clientRequestDeadline() time.Time {
|
||||||
|
|||||||
@@ -119,7 +119,10 @@ func wantFullLine(t *testing.T, line logLine) {
|
|||||||
ResponseContentType: "text/html", UpstreamStatus: http.StatusFound,
|
ResponseContentType: "text/html", UpstreamStatus: http.StatusFound,
|
||||||
CacheControl: "no-store", Location: "/elsewhere",
|
CacheControl: "no-store", Location: "/elsewhere",
|
||||||
Action: requestlog.ActionForward,
|
Action: requestlog.ActionForward,
|
||||||
Counts: ratelimit.Counts{Minute: 1, Hour: 1, Day: 1},
|
// Its 3 bytes in and 5 out, each way counted by default.
|
||||||
|
Counts: ratelimit.Counts{
|
||||||
|
Minute: 1, Hour: 1, Day: 1, MinuteBytes: 8, HourBytes: 8, DayBytes: 8,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if !reflect.DeepEqual(line.Line, want) {
|
if !reflect.DeepEqual(line.Line, want) {
|
||||||
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
|
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
|
||||||
|
|||||||
+139
-58
@@ -1,9 +1,9 @@
|
|||||||
// Package ratelimit keeps the table of clients: each client's requests
|
// Package ratelimit keeps the table of clients: each client's requests
|
||||||
// counted over a minute, an hour and a day, as the "Counting method"
|
// and bytes counted over a minute, an hour and a day, as the "Counting
|
||||||
// section of SPEC.md describes, which tell when a request takes the client
|
// method" section of SPEC.md describes, which tell when a request takes
|
||||||
// over a rate limit, and each client's history since it was first seen.
|
// the client over a rate limit or a byte limit, and each client's history
|
||||||
// At most 20,000 clients are kept, in memory, and written to clients.json
|
// since it was first seen. At most 20,000 clients are kept, in memory, and
|
||||||
// and read from it by the state package.
|
// written to clients.json and read from it by the state package.
|
||||||
package ratelimit
|
package ratelimit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -23,19 +23,30 @@ const maxClients = 20000
|
|||||||
|
|
||||||
const day = 24 * time.Hour
|
const day = 24 * time.Hour
|
||||||
|
|
||||||
|
// The kinds of limits, as the metrics name them.
|
||||||
|
const (
|
||||||
|
// KindRequests is a rate limit, on a client's requests.
|
||||||
|
KindRequests = "requests"
|
||||||
|
// KindBytes is a byte limit, on a client's bytes.
|
||||||
|
KindBytes = "bytes"
|
||||||
|
)
|
||||||
|
|
||||||
// Limits are the most requests a client may make in a minute, an hour and
|
// Limits are the most requests a client may make in a minute, an hour and
|
||||||
// a day. Zero is no limit.
|
// a day, and the most bytes. Zero is no limit.
|
||||||
type Limits struct {
|
type Limits struct {
|
||||||
PerMinute int64
|
PerMinute int64
|
||||||
PerHour int64
|
PerHour int64
|
||||||
PerDay int64
|
PerDay int64
|
||||||
|
BytesPerMinute int64
|
||||||
|
BytesPerHour int64
|
||||||
|
BytesPerDay int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limiter counts each client's requests against the limits, and keeps
|
// Limiter counts each client's requests and bytes against the limits, and
|
||||||
// its history. It is safe for concurrent use.
|
// keeps its history. It is safe for concurrent use.
|
||||||
type Limiter struct {
|
type Limiter struct {
|
||||||
// windows are the minute, the hour and the day, in the order of
|
// windows are the minute, the hour and the day, in the order of
|
||||||
// Client.buckets.
|
// Client.buckets and Client.byteBuckets.
|
||||||
windows [3]window
|
windows [3]window
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -43,17 +54,23 @@ type Limiter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Client is a client in the table, as clients.json holds it: its buckets
|
// Client is a client in the table, as clients.json holds it: its buckets
|
||||||
// in each window, and its history.
|
// of requests and of bytes in each window, and its history.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||||
type Client struct {
|
type Client struct {
|
||||||
Client netip.Prefix `json:"client"`
|
Client netip.Prefix `json:"client"`
|
||||||
Minute Buckets `json:"minute"`
|
Minute Buckets `json:"minute"`
|
||||||
Hour Buckets `json:"hour"`
|
Hour Buckets `json:"hour"`
|
||||||
Day Buckets `json:"day"`
|
Day Buckets `json:"day"`
|
||||||
|
MinuteBytes Buckets `json:"minute_bytes"`
|
||||||
|
HourBytes Buckets `json:"hour_bytes"`
|
||||||
|
DayBytes Buckets `json:"day_bytes"`
|
||||||
History History `json:"history"`
|
History History `json:"history"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buckets are a client's two buckets in one window: the requests in the
|
// Buckets are a client's two buckets in one window: the requests, or the
|
||||||
// bucket under way, which began at Start, and in the bucket before it.
|
// bytes, in the bucket under way, which began at Start, and in the bucket
|
||||||
|
// before it.
|
||||||
type Buckets struct {
|
type Buckets struct {
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
Current int64 `json:"current"`
|
Current int64 `json:"current"`
|
||||||
@@ -101,7 +118,7 @@ type Responses struct {
|
|||||||
|
|
||||||
// Offences are a client's offences, by kind.
|
// Offences are a client's offences, by kind.
|
||||||
type Offences struct {
|
type Offences struct {
|
||||||
// Limit is its requests that broke a rate limit.
|
// Limit is its requests that broke a rate limit or a byte limit.
|
||||||
Limit int64 `json:"limit"`
|
Limit int64 `json:"limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +136,8 @@ type Request struct {
|
|||||||
// and of its response.
|
// and of its response.
|
||||||
RequestBytes int64
|
RequestBytes int64
|
||||||
ResponseBytes int64
|
ResponseBytes int64
|
||||||
// BrokeLimit is true for a request that broke a rate limit.
|
// BrokeLimit is true for a request that broke a rate limit or a byte
|
||||||
|
// limit.
|
||||||
BrokeLimit bool
|
BrokeLimit bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,62 +150,71 @@ func New(limits Limits) *Limiter {
|
|||||||
|
|
||||||
return &Limiter{
|
return &Limiter{
|
||||||
windows: [3]window{
|
windows: [3]window{
|
||||||
{name: "minute", length: time.Minute, limit: limits.PerMinute},
|
{
|
||||||
{name: "hour", length: time.Hour, limit: limits.PerHour},
|
name: "minute", length: time.Minute,
|
||||||
{name: "day", length: day, limit: limits.PerDay},
|
limit: limits.PerMinute, byteLimit: limits.BytesPerMinute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hour", length: time.Hour,
|
||||||
|
limit: limits.PerHour, byteLimit: limits.BytesPerHour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "day", length: day,
|
||||||
|
limit: limits.PerDay, byteLimit: limits.BytesPerDay,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
clients: clients,
|
clients: clients,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hit is a request that takes a client over a rate limit.
|
// Hit is a request that takes a client over a rate limit, or whose bytes
|
||||||
|
// take it over a byte limit.
|
||||||
type Hit struct {
|
type Hit struct {
|
||||||
|
// Kind is KindRequests for a rate limit, KindBytes for a byte limit.
|
||||||
|
Kind string
|
||||||
// Window is "minute", "hour" or "day".
|
// Window is "minute", "hour" or "day".
|
||||||
Window string
|
Window string
|
||||||
// Limit is the window's limit.
|
// Limit is the window's limit.
|
||||||
Limit int64
|
Limit int64
|
||||||
// Requests is the client's requests counted in the window, this one
|
// Count is the client's requests, or bytes, counted in the window,
|
||||||
// included.
|
// this request's included.
|
||||||
Requests float64
|
Count float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Counts are a client's requests in the minute, the hour and the day that
|
// Counts are a client's requests and bytes in the minute, the hour and
|
||||||
// end at a request, that request included.
|
// the day that end at a request, that request's included.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // SPEC.md's request log names its fields in snake_case
|
||||||
type Counts struct {
|
type Counts struct {
|
||||||
Minute float64 `json:"minute"`
|
Minute float64 `json:"minute"`
|
||||||
Hour float64 `json:"hour"`
|
Hour float64 `json:"hour"`
|
||||||
Day float64 `json:"day"`
|
Day float64 `json:"day"`
|
||||||
|
MinuteBytes float64 `json:"minute_bytes"`
|
||||||
|
HourBytes float64 `json:"hour_bytes"`
|
||||||
|
DayBytes float64 `json:"day_bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count counts a request from client at now, in every window, whether or
|
// Count counts a request from client at now, in every window, whether or
|
||||||
// not it is refused, and returns the client's requests in each window. It
|
// not it is refused, and returns the client's counts in each window. It
|
||||||
// reports whether the request takes the client over a limit, and the
|
// reports whether the request takes the client over a rate limit, and the
|
||||||
// window whose limit it goes over, the shortest if it is over several.
|
// hit: the window whose limit it goes over, the shortest if it is over
|
||||||
|
// several.
|
||||||
func (l *Limiter) Count(client netip.Prefix, now time.Time) (Counts, Hit, bool) {
|
func (l *Limiter) Count(client netip.Prefix, now time.Time) (Counts, Hit, bool) {
|
||||||
l.mu.Lock()
|
return l.count(client, now, 1, 0)
|
||||||
defer l.mu.Unlock()
|
|
||||||
|
|
||||||
var (
|
|
||||||
requests [3]float64
|
|
||||||
hit Hit
|
|
||||||
)
|
|
||||||
|
|
||||||
for i, b := range l.get(client).buckets() {
|
|
||||||
w := l.windows[i]
|
|
||||||
|
|
||||||
requests[i] = b.add(now, w.length)
|
|
||||||
if hit.Window == "" && w.limit > 0 && requests[i] > float64(w.limit) {
|
|
||||||
hit = Hit{Window: w.name, Limit: w.limit, Requests: requests[i]}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
counts := Counts{Minute: requests[0], Hour: requests[1], Day: requests[2]}
|
// CountBytes counts bytes, those of a request from client that has ended,
|
||||||
|
// at now, in every window, and returns the client's counts in each window.
|
||||||
return counts, hit, hit.Window != ""
|
// It reports whether the bytes take the client over a byte limit, and the
|
||||||
|
// hit, as Count does.
|
||||||
|
func (l *Limiter) CountBytes(
|
||||||
|
client netip.Prefix, now time.Time, bytes int64,
|
||||||
|
) (Counts, Hit, bool) {
|
||||||
|
return l.count(client, now, 0, bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset sets client's counts in every window back to zero. Its history
|
// Reset sets client's counts of requests and of bytes in every window
|
||||||
// keeps its totals.
|
// back to zero. Its history keeps its totals.
|
||||||
func (l *Limiter) Reset(client netip.Prefix) {
|
func (l *Limiter) Reset(client netip.Prefix) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
@@ -195,6 +222,7 @@ func (l *Limiter) Reset(client netip.Prefix) {
|
|||||||
c, seen := l.clients.Peek(client)
|
c, seen := l.clients.Peek(client)
|
||||||
if seen {
|
if seen {
|
||||||
c.Minute, c.Hour, c.Day = Buckets{}, Buckets{}, Buckets{}
|
c.Minute, c.Hour, c.Day = Buckets{}, Buckets{}, Buckets{}
|
||||||
|
c.MinuteBytes, c.HourBytes, c.DayBytes = Buckets{}, Buckets{}, Buckets{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,19 +356,63 @@ func (l *Limiter) Load(clients []Client, now time.Time) {
|
|||||||
l.clients.Purge()
|
l.clients.Purge()
|
||||||
|
|
||||||
for _, c := range clients {
|
for _, c := range clients {
|
||||||
for i, b := range c.buckets() {
|
for i, w := range l.windows {
|
||||||
|
for _, b := range []*Buckets{c.buckets()[i], c.byteBuckets()[i]} {
|
||||||
// The window that ends at now covers neither bucket once it
|
// The window that ends at now covers neither bucket once it
|
||||||
// begins after the bucket under way has ended.
|
// begins after the bucket under way has ended.
|
||||||
length := l.windows[i].length
|
if !now.Add(-w.length).Before(b.Start.Add(w.length)) {
|
||||||
if !now.Add(-length).Before(b.Start.Add(length)) {
|
|
||||||
*b = Buckets{}
|
*b = Buckets{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
l.clients.Add(c.Client, &c)
|
l.clients.Add(c.Client, &c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// count adds requests and bytes from client at now to its buckets in
|
||||||
|
// every window, and returns its counts. A limit is broken only by what is
|
||||||
|
// added to it, so that a request whose bytes are counted after another of
|
||||||
|
// the client's requests broke a rate limit does not break it too.
|
||||||
|
func (l *Limiter) count(
|
||||||
|
client netip.Prefix, now time.Time, requests, bytes int64,
|
||||||
|
) (Counts, Hit, bool) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
c := l.get(client)
|
||||||
|
requestBuckets, byteBuckets := c.buckets(), c.byteBuckets()
|
||||||
|
|
||||||
|
var (
|
||||||
|
requestCounts, byteCounts [3]float64
|
||||||
|
hit Hit
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, w := range l.windows {
|
||||||
|
requestCounts[i] = requestBuckets[i].add(now, w.length, requests)
|
||||||
|
byteCounts[i] = byteBuckets[i].add(now, w.length, bytes)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case hit.Window != "":
|
||||||
|
case requests > 0 && w.limit > 0 && requestCounts[i] > float64(w.limit):
|
||||||
|
hit = Hit{
|
||||||
|
Kind: KindRequests, Window: w.name, Limit: w.limit, Count: requestCounts[i],
|
||||||
|
}
|
||||||
|
case bytes > 0 && w.byteLimit > 0 && byteCounts[i] > float64(w.byteLimit):
|
||||||
|
hit = Hit{
|
||||||
|
Kind: KindBytes, Window: w.name, Limit: w.byteLimit, Count: byteCounts[i],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := Counts{
|
||||||
|
Minute: requestCounts[0], Hour: requestCounts[1], Day: requestCounts[2],
|
||||||
|
MinuteBytes: byteCounts[0], HourBytes: byteCounts[1], DayBytes: byteCounts[2],
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts, hit, hit.Window != ""
|
||||||
|
}
|
||||||
|
|
||||||
// get returns client's entry in the table, a new one if it has none, and
|
// get returns client's entry in the table, a new one if it has none, and
|
||||||
// makes it the most recently seen.
|
// makes it the most recently seen.
|
||||||
func (l *Limiter) get(client netip.Prefix) *Client {
|
func (l *Limiter) get(client netip.Prefix) *Client {
|
||||||
@@ -353,30 +425,39 @@ func (l *Limiter) get(client netip.Prefix) *Client {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// buckets returns c's buckets in the minute, the hour and the day.
|
// buckets returns c's buckets of requests in the minute, the hour and the
|
||||||
|
// day.
|
||||||
func (c *Client) buckets() [3]*Buckets {
|
func (c *Client) buckets() [3]*Buckets {
|
||||||
return [3]*Buckets{&c.Minute, &c.Hour, &c.Day}
|
return [3]*Buckets{&c.Minute, &c.Hour, &c.Day}
|
||||||
}
|
}
|
||||||
|
|
||||||
// window is a length of time over which requests are counted, and the
|
// byteBuckets returns c's buckets of bytes in the minute, the hour and the
|
||||||
// most requests a client may make in it.
|
// day.
|
||||||
|
func (c *Client) byteBuckets() [3]*Buckets {
|
||||||
|
return [3]*Buckets{&c.MinuteBytes, &c.HourBytes, &c.DayBytes}
|
||||||
|
}
|
||||||
|
|
||||||
|
// window is a length of time over which requests and bytes are counted,
|
||||||
|
// and the most requests and the most bytes a client may have in it.
|
||||||
type window struct {
|
type window struct {
|
||||||
name string
|
name string
|
||||||
length time.Duration
|
length time.Duration
|
||||||
limit int64
|
limit int64
|
||||||
|
byteLimit int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// add counts a request at now in a window of length, and returns the
|
// add counts n requests, or n bytes, at now in a window of length, and
|
||||||
// client's requests in the window that ends at now: those in the bucket
|
// returns the client's count in the window that ends at now: what is in
|
||||||
// under way, and those in the bucket before it weighted by how much of
|
// the bucket under way, and what is in the bucket before it weighted by
|
||||||
// that bucket the window still covers.
|
// how much of that bucket the window still covers. With n zero it counts
|
||||||
|
// nothing, and returns the count.
|
||||||
//
|
//
|
||||||
// Concurrent requests can be counted out of order, so now can be a moment
|
// Concurrent requests can be counted out of order, so now can be a moment
|
||||||
// before the bucket under way began; such a request is counted in that
|
// before the bucket under way began; such a request is counted in that
|
||||||
// bucket. A request dated more than a second before it means the clock
|
// bucket. A request dated more than a second before it means the clock
|
||||||
// was set back, and the buckets start afresh: otherwise the bucket before
|
// was set back, and the buckets start afresh: otherwise the bucket before
|
||||||
// would keep its full weight until the clock caught up.
|
// would keep its full weight until the clock caught up.
|
||||||
func (b *Buckets) add(now time.Time, length time.Duration) float64 {
|
func (b *Buckets) add(now time.Time, length time.Duration, n int64) float64 {
|
||||||
if now.Before(b.Start.Add(-time.Second)) {
|
if now.Before(b.Start.Add(-time.Second)) {
|
||||||
*b = Buckets{}
|
*b = Buckets{}
|
||||||
}
|
}
|
||||||
@@ -393,7 +474,7 @@ func (b *Buckets) add(now time.Time, length time.Duration) float64 {
|
|||||||
b.Current = 0
|
b.Current = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
b.Current++
|
b.Current += n
|
||||||
|
|
||||||
elapsed := max(now.Sub(b.Start), 0)
|
elapsed := max(now.Sub(b.Start), 0)
|
||||||
covered := 1 - float64(elapsed)/float64(length)
|
covered := 1 - float64(elapsed)/float64(length)
|
||||||
|
|||||||
@@ -71,13 +71,116 @@ func TestHitGivesTheLimitAndTheRequestsCounted(t *testing.T) {
|
|||||||
// Over both limits; the minute's is named, with the four requests.
|
// Over both limits; the minute's is named, with the four requests.
|
||||||
_, hit, over := limiter.Count(client, start)
|
_, hit, over := limiter.Count(client, start)
|
||||||
|
|
||||||
want := ratelimit.Hit{Window: minute, Limit: limit, Requests: limit + 1}
|
want := ratelimit.Hit{
|
||||||
|
Kind: ratelimit.KindRequests, Window: minute, Limit: limit, Count: limit + 1,
|
||||||
|
}
|
||||||
if !over || hit != want {
|
if !over || hit != want {
|
||||||
t.Errorf("request over the limit gives %+v and %t, want %+v and true",
|
t.Errorf("request over the limit gives %+v and %t, want %+v and true",
|
||||||
hit, over, want)
|
hit, over, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEachByteLimitIsBrokenByTheBytesCounted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const byteLimit = 1000
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
window string
|
||||||
|
limits ratelimit.Limits
|
||||||
|
}{
|
||||||
|
{minute, ratelimit.Limits{BytesPerMinute: byteLimit}},
|
||||||
|
{hour, ratelimit.Limits{BytesPerHour: byteLimit}},
|
||||||
|
{"day", ratelimit.Limits{BytesPerDay: byteLimit}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.window, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(tc.limits)
|
||||||
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
|
// 600 bytes are within the limit, 600 more over it.
|
||||||
|
_, _, over := limiter.CountBytes(client, midnight(), 600)
|
||||||
|
if over {
|
||||||
|
t.Fatal("600 bytes are over the limit of 1000")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, hit, over := limiter.CountBytes(client, midnight(), 600)
|
||||||
|
|
||||||
|
want := ratelimit.Hit{
|
||||||
|
Kind: ratelimit.KindBytes, Window: tc.window, Limit: byteLimit, Count: 1200,
|
||||||
|
}
|
||||||
|
if !over || hit != want {
|
||||||
|
t.Errorf("1200 bytes give %+v and %t, want %+v and true", hit, over, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestALimitIsBrokenOnlyByWhatIsAddedToIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(ratelimit.Limits{PerMinute: 2, BytesPerMinute: 1000})
|
||||||
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
other := netip.MustParsePrefix("203.0.113.10/32")
|
||||||
|
start := midnight()
|
||||||
|
|
||||||
|
// The third request breaks the rate limit. The bytes of a request
|
||||||
|
// counted after it, within the byte limit, do not break it again.
|
||||||
|
for range 2 {
|
||||||
|
wantCount(t, limiter, client, start, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
wantCount(t, limiter, client, start, minute)
|
||||||
|
wantBytesCount(t, limiter, client, start, 500, "")
|
||||||
|
wantBytesCount(t, limiter, client, start, 600, ratelimit.KindBytes)
|
||||||
|
|
||||||
|
// Bytes over the byte limit do not have the next request break it, nor
|
||||||
|
// the rate limit, which that request is within.
|
||||||
|
wantBytesCount(t, limiter, other, start, 1200, ratelimit.KindBytes)
|
||||||
|
wantCount(t, limiter, other, start, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountGivesTheBytesInEachWindow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(ratelimit.Limits{})
|
||||||
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
start := midnight()
|
||||||
|
|
||||||
|
limiter.CountBytes(client, start, 300)
|
||||||
|
|
||||||
|
// A quarter into the next hour, the minute has only these 100 bytes.
|
||||||
|
// The hour still covers three quarters of the bucket before, whose 300
|
||||||
|
// bytes count 225, and these: 325. The day covers all 400.
|
||||||
|
later := start.Add(time.Hour + time.Hour/4)
|
||||||
|
limiter.CountBytes(client, later, 100)
|
||||||
|
|
||||||
|
// A request's counts give the bytes counted so far too.
|
||||||
|
counts, _, _ := limiter.Count(client, later)
|
||||||
|
|
||||||
|
want := ratelimit.Counts{
|
||||||
|
Minute: 1, Hour: 1, Day: 1, MinuteBytes: 100, HourBytes: 325, DayBytes: 400,
|
||||||
|
}
|
||||||
|
if counts != want {
|
||||||
|
t.Errorf("counts %+v, want %+v", counts, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetSetsTheBytesBackToZero(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(ratelimit.Limits{BytesPerDay: 1000})
|
||||||
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
start := midnight()
|
||||||
|
|
||||||
|
wantBytesCount(t, limiter, client, start, 1200, ratelimit.KindBytes)
|
||||||
|
limiter.Reset(client)
|
||||||
|
|
||||||
|
// The client has its whole allowance of bytes again.
|
||||||
|
wantBytesCount(t, limiter, client, start, 1000, "")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCountGivesTheRequestsInEachWindow(t *testing.T) {
|
func TestCountGivesTheRequestsInEachWindow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -267,3 +370,18 @@ func wantCount(
|
|||||||
client, now.Format(time.RFC3339), hit.Window, want)
|
client, now.Format(time.RFC3339), hit.Window, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wantBytesCount counts bytes from client at now, and checks the kind of
|
||||||
|
// the limit they break, "" for none.
|
||||||
|
func wantBytesCount(
|
||||||
|
t *testing.T, limiter *ratelimit.Limiter, client netip.Prefix, now time.Time,
|
||||||
|
bytes int64, want string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, hit, _ := limiter.CountBytes(client, now, bytes)
|
||||||
|
if hit.Kind != want {
|
||||||
|
t.Errorf("%d bytes from %s at %s break a limit on %q, want %q",
|
||||||
|
bytes, client, now.Format(time.RFC3339), hit.Kind, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ func TestLoadEmptiesBucketsWhoseTimeHasPassed(t *testing.T) {
|
|||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{})
|
limiter := ratelimit.New(ratelimit.Limits{})
|
||||||
limiter.Count(client, start)
|
limiter.Count(client, start)
|
||||||
|
limiter.CountBytes(client, start, 5)
|
||||||
limiter.AddToHistory(client, start, ratelimit.Request{Forwarded: true})
|
limiter.AddToHistory(client, start, ratelimit.Request{Forwarded: true})
|
||||||
|
|
||||||
loaded := func(now time.Time) ratelimit.Client {
|
loaded := func(now time.Time) ratelimit.Client {
|
||||||
@@ -76,19 +77,25 @@ func TestLoadEmptiesBucketsWhoseTimeHasPassed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Two minutes on, the window that ends then covers neither of the
|
// Two minutes on, the window that ends then covers neither of the
|
||||||
// minute's buckets, which are emptied; the hour's and the day's stay,
|
// minute's buckets, of requests and of bytes, which are emptied; the
|
||||||
// and so does the history.
|
// hour's and the day's stay, and so does the history.
|
||||||
got := loaded(start.Add(2 * time.Minute))
|
got := loaded(start.Add(2 * time.Minute))
|
||||||
if got.Minute != (ratelimit.Buckets{}) || got.Hour.Current != 1 ||
|
if got.Minute != (ratelimit.Buckets{}) || got.Hour.Current != 1 ||
|
||||||
got.Day.Current != 1 || got.History.Requests != 1 {
|
got.Day.Current != 1 || got.History.Requests != 1 {
|
||||||
t.Errorf("loaded two minutes on as %+v", got)
|
t.Errorf("loaded two minutes on as %+v", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if got.MinuteBytes != (ratelimit.Buckets{}) || got.HourBytes.Current != 5 ||
|
||||||
|
got.DayBytes.Current != 5 {
|
||||||
|
t.Errorf("loaded two minutes on with buckets of bytes %+v, %+v and %+v",
|
||||||
|
got.MinuteBytes, got.HourBytes, got.DayBytes)
|
||||||
|
}
|
||||||
|
|
||||||
// A moment before, the window still covers some of the earlier one.
|
// A moment before, the window still covers some of the earlier one.
|
||||||
got = loaded(start.Add(2*time.Minute - time.Nanosecond))
|
got = loaded(start.Add(2*time.Minute - time.Nanosecond))
|
||||||
if got.Minute.Current != 1 {
|
if got.Minute.Current != 1 || got.MinuteBytes.Current != 5 {
|
||||||
t.Errorf("loaded just under two minutes on with minute buckets %+v",
|
t.Errorf("loaded just under two minutes on with minute buckets %+v and %+v",
|
||||||
got.Minute)
|
got.Minute, got.MinuteBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// OffenceLimit is the offence a request line names for a request that
|
// OffenceLimit is the offence a request line names for a request that
|
||||||
// broke a rate limit.
|
// broke a rate limit, or whose bytes broke a byte limit.
|
||||||
const OffenceLimit = "limit"
|
const OffenceLimit = "limit"
|
||||||
|
|
||||||
// timeLayout is RFC 3339 with milliseconds.
|
// timeLayout is RFC 3339 with milliseconds.
|
||||||
@@ -117,13 +117,16 @@ type Line struct {
|
|||||||
// ActionBanned, ActionCountryDenied, ActionRateLimited or
|
// ActionBanned, ActionCountryDenied, ActionRateLimited or
|
||||||
// ActionRuleBlocked.
|
// ActionRuleBlocked.
|
||||||
WouldAction string `json:"would_action,omitempty"`
|
WouldAction string `json:"would_action,omitempty"`
|
||||||
// Counts are the client's requests as the rate limits counted them
|
// Counts are, for a request the rate limits counted, the client's
|
||||||
// with this one, for a request they counted.
|
// requests as they counted them with this one, and its bytes as the
|
||||||
|
// byte limits counted them, with this request's once it has ended if
|
||||||
|
// they count them.
|
||||||
Counts ratelimit.Counts `json:"counts,omitzero"`
|
Counts ratelimit.Counts `json:"counts,omitzero"`
|
||||||
// RuleIDs are the ids of the rule file rules the request matched.
|
// RuleIDs are the ids of the rule file rules the request matched.
|
||||||
RuleIDs []string `json:"rule_ids,omitempty"`
|
RuleIDs []string `json:"rule_ids,omitempty"`
|
||||||
// LimitHit is the window whose rate limit the request went over:
|
// LimitHit is the window whose limit the request went over, named as
|
||||||
// minute, hour or day.
|
// Counts names its count: minute, hour or day for a rate limit, and
|
||||||
|
// minute_bytes, hour_bytes or day_bytes for a byte limit.
|
||||||
LimitHit string `json:"limit_hit,omitempty"`
|
LimitHit string `json:"limit_hit,omitempty"`
|
||||||
// Offence is the offence the request was held as, OffenceLimit.
|
// Offence is the offence the request was held as, OffenceLimit.
|
||||||
Offence string `json:"offence,omitempty"`
|
Offence string `json:"offence,omitempty"`
|
||||||
|
|||||||
+10
-4
@@ -615,8 +615,8 @@ func (f *bansFile) check(data []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// check refuses a client without its address, which would count nobody's
|
// check refuses a client without its address, which would count nobody's
|
||||||
// requests, or with requests in a window but no start, which would drop
|
// requests, or with requests or bytes in a window but no start, which
|
||||||
// them and give the client a fresh allowance.
|
// would drop them and give the client a fresh allowance.
|
||||||
func (f *clientsFile) check([]byte) error {
|
func (f *clientsFile) check([]byte) error {
|
||||||
for i, client := range f.Clients {
|
for i, client := range f.Clients {
|
||||||
switch {
|
switch {
|
||||||
@@ -628,6 +628,12 @@ func (f *clientsFile) check([]byte) error {
|
|||||||
return missing(i, "hour.start")
|
return missing(i, "hour.start")
|
||||||
case countsWithoutStart(client.Day):
|
case countsWithoutStart(client.Day):
|
||||||
return missing(i, "day.start")
|
return missing(i, "day.start")
|
||||||
|
case countsWithoutStart(client.MinuteBytes):
|
||||||
|
return missing(i, "minute_bytes.start")
|
||||||
|
case countsWithoutStart(client.HourBytes):
|
||||||
|
return missing(i, "hour_bytes.start")
|
||||||
|
case countsWithoutStart(client.DayBytes):
|
||||||
|
return missing(i, "day_bytes.start")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,8 +703,8 @@ func (f *alertsFile) check([]byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// countsWithoutStart reports whether b holds requests but no start, which
|
// countsWithoutStart reports whether b holds requests, or bytes, but no
|
||||||
// places them in time.
|
// start, which places them in time.
|
||||||
func countsWithoutStart(b ratelimit.Buckets) bool {
|
func countsWithoutStart(b ratelimit.Buckets) bool {
|
||||||
return b.Start.IsZero() && (b.Current != 0 || b.Previous != 0)
|
return b.Start.IsZero() && (b.Current != 0 || b.Previous != 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,6 +392,12 @@ func TestEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
|
|||||||
`"hour": {"current": 3}}]}`,
|
`"hour": {"current": 3}}]}`,
|
||||||
`: entry 1 has no "hour.start"`,
|
`: entry 1 has no "hour.start"`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"a client with bytes in a window without its start", clientsJSON,
|
||||||
|
`{"version": 1, "clients": [{"client": "203.0.113.9/32", ` +
|
||||||
|
`"minute_bytes": {"previous": 5120}}]}`,
|
||||||
|
`: entry 1 has no "minute_bytes.start"`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"an answer without a client", lookupsJSON,
|
"an answer without a client", lookupsJSON,
|
||||||
`{"version": 1, "lookups": [{"country": "DE", ` + answer + `}]}`,
|
`{"version": 1, "lookups": [{"country": "DE", ` + answer + `}]}`,
|
||||||
@@ -1317,6 +1323,7 @@ func fill(params state.Params) {
|
|||||||
params.Limiter.Count(netip.MustParsePrefix(c), now)
|
params.Limiter.Count(netip.MustParsePrefix(c), now)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
params.Limiter.CountBytes(client, now, 8)
|
||||||
params.Limiter.AddToHistory(client, now, ratelimit.Request{
|
params.Limiter.AddToHistory(client, now, ratelimit.Request{
|
||||||
Forwarded: true, Status: 200, RequestBytes: 3, ResponseBytes: 5,
|
Forwarded: true, Status: 200, RequestBytes: 3, ResponseBytes: 5,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user