Ban the netblock of a client that breaks a rate limit, in memory (closes #18)
check / check (push) Successful in 3m48s
check / check (push) Successful in 3m48s
A request over a rate limit is refused with SWWAF_BAN_RESPONSE and bans the client's netblock: an hour at first, three times the last ban when broken again within a day of its end, permanent past seven days. The ban ledger in internal/bans is checked after the static lists and before the lookup, and the requests it refuses are not counted. A ban resets the client's counters and carries notes holding the request that broke the limit, as SPEC.md now says. At most SWWAF_MAX_BANS are held. SWWAF_BAN_RESPONSE also answers SWWAF_DENY_NETS and the country lists. Judgement call: the six ban settings cannot be off. Judgement call: a permanent ban's ban_expires is "permanent". Model: opus-5-5
This commit was merged in pull request #69.
This commit is contained in:
@@ -13,16 +13,17 @@ JSON log line for every request.
|
||||
|
||||
Status: the first two milestones are built
|
||||
(https://git.eeqj.de/sneak/smallwebwaf/issues/13 and
|
||||
https://git.eeqj.de/sneak/smallwebwaf/issues/14), and so are two parts of
|
||||
milestone 3: the static lists, which come next in the build order, and the
|
||||
header size and the idle time as settings, which come last in it. `smallwebwaf`
|
||||
passes each request to the app and the app's answer back, unchanged, within its
|
||||
timeouts and size limits, works out each client's address, refuses a client that
|
||||
sends too many requests, comes from a country you refuse or from a network you
|
||||
refuse, lets the networks you choose through, and writes a JSON log line for
|
||||
every request. It comes as the image the app's own image is built on. The rest
|
||||
of the design comes after that, in the order of the build order in
|
||||
[`SPEC.md`](SPEC.md). The survey of existing tools that led to the design is in
|
||||
https://git.eeqj.de/sneak/smallwebwaf/issues/14), and so are three parts of
|
||||
milestone 3: the static lists and the bans that broken rate limits lead to,
|
||||
which come next in the build order, and the header size and the idle time as
|
||||
settings, which come last in it. `smallwebwaf` passes each request to the app
|
||||
and the app's answer back, unchanged, within its timeouts and size limits, works
|
||||
out each client's address, bans a client that sends too many requests, refuses a
|
||||
client that comes from a country you refuse or from a network you refuse, lets
|
||||
the networks you choose through, and writes a JSON log line for every request.
|
||||
It comes as the image the app's own image is built on. The rest of the design
|
||||
comes after that, in the order of the build order in [`SPEC.md`](SPEC.md). The
|
||||
survey of existing tools that led to the design is in
|
||||
[`EVALUATION.md`](EVALUATION.md).
|
||||
|
||||
## Getting started
|
||||
@@ -71,31 +72,48 @@ and `make run` builds and runs it, listening on port 8080 in front of an app at
|
||||
to take what it had. Once the response has started, a limit can only cut the
|
||||
connection.
|
||||
- Counts each client's requests over a minute, an hour and a day. A request that
|
||||
takes the client over one of the rate limits below is refused with `429`
|
||||
before anything reaches the app, and so is each request after it until the
|
||||
client is back under every limit. A client is one IPv4 address, or one IPv6
|
||||
/64, since one abuser usually holds a whole /64. Refused requests count too,
|
||||
so a client that keeps sending too fast stays refused until it slows down.
|
||||
Each window is counted in two fixed buckets, the earlier one weighted by how
|
||||
much of it the window still covers. At most 20,000 clients are kept, the least
|
||||
recently seen dropped first, and only in memory: a restart starts every client
|
||||
afresh.
|
||||
- Refuses a request from a country you refuse with `403`, as soon as the
|
||||
client's country is known and before its body is read; such a request is not
|
||||
counted for the rate limits. While one of the country lists below is set, each
|
||||
client's country is looked up through GeoJS (see "Country and AS number
|
||||
lookup" below); with neither set, no visitor's address leaves the host. A
|
||||
client on a private, loopback or link-local address has no country and is
|
||||
takes the client over one of the rate limits below is refused with
|
||||
`SWWAF_BAN_RESPONSE`, `403` by default, before anything reaches the app, and
|
||||
bans the client. A client is one IPv4 address, or one IPv6 /64, since one
|
||||
abuser usually holds a whole /64. Each window is counted in two fixed buckets,
|
||||
the earlier one weighted by how much of it the window still covers. At most
|
||||
20,000 clients are kept, the least recently seen dropped first, and only in
|
||||
memory: a restart starts every client afresh.
|
||||
- Bans a client that breaks a rate limit, as "Bans" in [`SPEC.md`](SPEC.md)
|
||||
describes: the first ban lasts an hour, and a limit broken again within a day
|
||||
of a ban ending bans for three times as long as that ban, so 1, 3, 9, 27 and
|
||||
81 hours; a ban that would last longer than seven days is permanent instead. A
|
||||
ban covers the client's netblock: its IPv4 address, or the netblock around it
|
||||
that `SWWAF_BAN_SCOPE_V4_PREFIX` sets, or its IPv6 /64. While it lasts, every
|
||||
request from the netblock is refused with `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, its
|
||||
window and the requests counted in it, the request that broke it, the client's
|
||||
country when it was looked up, how many requests the ban has refused, and how
|
||||
many bans the netblock had before. At most `SWWAF_MAX_BANS` bans are kept,
|
||||
past, active and permanent; past that, the earliest ban of the netblock that
|
||||
has gone longest without a request is dropped first. Bans and their notes are
|
||||
kept in memory only, so a restart lifts every ban, and nothing shows them yet:
|
||||
`bans.json`, which shows them and lets you lift a ban, comes with the state
|
||||
files (https://git.eeqj.de/sneak/smallwebwaf/issues/17).
|
||||
- Refuses a request from a country you refuse with `SWWAF_BAN_RESPONSE`, as soon
|
||||
as the client's country is known and before its body is read; such a request
|
||||
is not counted for the rate limits. While one of the country lists below is
|
||||
set, each client's country is looked up through GeoJS (see "Country and AS
|
||||
number lookup" below); with neither set, no visitor's address leaves the host.
|
||||
A client on a private, loopback or link-local address has no country and is
|
||||
never looked up: `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses it unless it is
|
||||
in `SWWAF_ALLOW_NETS`, and `SWWAF_DENIED_COUNTRIES` does not refuse it.
|
||||
- Checks the client's own address against the static lists, the three netblock
|
||||
settings below, before anything else, its country included. A client in
|
||||
`SWWAF_ALLOW_NETS` skips the country lists and the rate limits, and is not
|
||||
looked up; the timeouts and size limits still apply. A client in
|
||||
`SWWAF_DENY_NETS` is refused with `403` before its body is read, and the
|
||||
request is not counted for the rate limits; an address in `SWWAF_ALLOW_NETS`
|
||||
too is let through. A client in `SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither
|
||||
counted nor refused by the rate limits; the country lists still apply to it.
|
||||
`SWWAF_ALLOW_NETS` skips bans, the country lists and the rate limits, and is
|
||||
not looked up; the timeouts and size limits still apply. A client in
|
||||
`SWWAF_DENY_NETS` is refused with `SWWAF_BAN_RESPONSE` before its body is
|
||||
read, and the request is not counted for the rate limits; an address in
|
||||
`SWWAF_ALLOW_NETS` too is let through. A client in
|
||||
`SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor refused by the rate
|
||||
limits; the country lists and bans still apply to it.
|
||||
- Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any
|
||||
check and without asking the app, for the image's health check.
|
||||
- Writes a line in the request log for each request (see "Request log" below).
|
||||
@@ -133,8 +151,9 @@ it, and the effective settings are logged at start.
|
||||
to send its whole answer, from the end of the request to the last byte.
|
||||
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body.
|
||||
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body.
|
||||
- `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip the country
|
||||
lists and the rate limits, such as your monitoring or your own networks.
|
||||
- `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip bans, the
|
||||
country lists and the rate limits, such as your monitoring or your own
|
||||
networks.
|
||||
- `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.
|
||||
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are always refused.
|
||||
@@ -149,6 +168,22 @@ it, and the effective settings are logged at start.
|
||||
countries whose clients get through, for example `us,de`. A client whose
|
||||
country cannot be found is refused too, so that new clients are not let in
|
||||
whenever GeoJS stops answering.
|
||||
- `SWWAF_BAN_RESPONSE` (default `403`): how a refused client is answered, one
|
||||
that is banned, breaks a rate limit, is in `SWWAF_DENY_NETS` or comes from a
|
||||
refused country: `403`, `429`, or `close` to close the connection without an
|
||||
answer. Behind traefik, `close` does not leave the client unanswered: traefik
|
||||
answers `502`, as it does whenever its backend drops a connection.
|
||||
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken rate
|
||||
limit.
|
||||
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): a rate limit broken again
|
||||
within this time after a ban ended bans for three times as long as that ban.
|
||||
- `SWWAF_MAX_BAN_DURATION` (default `7d`): a ban that would be longer is
|
||||
permanent instead.
|
||||
- `SWWAF_MAX_BANS` (default `5000`): the most bans kept, past, active and
|
||||
permanent.
|
||||
- `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): the length of the netblock around
|
||||
an IPv4 client that a ban covers, such as `24` to ban the surrounding /24. An
|
||||
IPv6 ban covers the client's /64.
|
||||
|
||||
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
|
||||
@@ -157,8 +192,8 @@ and a bare address stands for itself alone. Countries are the two-letter codes
|
||||
ISO 3166-1 assigns today, and `xk` for Kosovo, in either case (`de` and `DE` are
|
||||
the same); any other code, such as `nk` (North Korea is `kp`) or the withdrawn
|
||||
`su`, stops the start, and so does a code on both country lists. `off` switches
|
||||
a timeout, a size limit or a rate limit off; only
|
||||
`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` cannot be off.
|
||||
a timeout, a size limit or a rate limit off;
|
||||
`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` and the ban settings cannot be off.
|
||||
|
||||
Several limits are fixed rather than settings. At most 20,000 clients are kept
|
||||
for the rate limits, and an IPv6 client is counted by its /64. A new client
|
||||
@@ -176,23 +211,27 @@ refused ones included:
|
||||
|
||||
- `time` is when the request arrived, in UTC. `peer_ip` is the TCP peer,
|
||||
normally traefik. `path` and `query` are as the client sent them.
|
||||
- `country` is the client's country as GeoJS places it, and empty when it is not
|
||||
known: with neither country list set, for a client in `SWWAF_ALLOW_NETS` or
|
||||
`SWWAF_DENY_NETS`, for a client on a private, loopback or link-local address,
|
||||
and when GeoJS cannot place the client or has not answered in time.
|
||||
- `country` is the client's country as GeoJS places it. It is empty with neither
|
||||
country list set, for a client in `SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS`, for
|
||||
a client on a private, loopback or link-local address, when GeoJS cannot place
|
||||
the client or has not answered in time, and for a request refused because a
|
||||
ban covers its client, even when the client's country is known.
|
||||
- `status` is what the client was sent, `0` if nothing was; `upstream_status` is
|
||||
what the app answered, and is left out when the app did not answer.
|
||||
- `request_bytes` and `response_bytes` count body bytes.
|
||||
- `action` is `forward` for a request passed to the app, `denied` for one
|
||||
refused because its client is in `SWWAF_DENY_NETS`, `country_denied` for one
|
||||
refused for its client's country, `rate_limited` for one refused for a rate
|
||||
limit, `too_large` for a request or response over its size limit, `timed_out`
|
||||
for one that ran out of time, `upstream_error` when the app could not be
|
||||
reached or its answer broke off, and `admin` for one `smallwebwaf` answered at
|
||||
its own endpoint.
|
||||
- `limit_hit` is there for a request refused for a rate limit, and names the
|
||||
refused because its client is in `SWWAF_DENY_NETS`, `banned` for one refused
|
||||
because a ban covers its client, `country_denied` for one refused for its
|
||||
client's country, `rate_limited` for one that broke a rate limit and banned
|
||||
its client, `too_large` for a request or response over its size limit,
|
||||
`timed_out` for one that ran out of time, `upstream_error` when the app could
|
||||
not be reached or its answer broke off, and `admin` for one `smallwebwaf`
|
||||
answered at its own endpoint.
|
||||
- `limit_hit` is there for a request that broke a rate limit, and names the
|
||||
window whose limit it went over: `minute`, `hour` or `day`, the shortest if it
|
||||
went over several.
|
||||
went over several. `offence` is then `limit`.
|
||||
- `ban_expires` is there for a request that made a ban or was refused under one,
|
||||
and gives when the ban ends, in the same form as `time`, or `permanent`.
|
||||
- `aborted` is there, and true, when the client went away early.
|
||||
- `duration_total` and `duration_upstream_total` are in milliseconds.
|
||||
|
||||
@@ -309,8 +348,8 @@ goes through the candidates one by one.
|
||||
nothing. Edit a file, or add a rule file, and the running `smallwebwaf` picks
|
||||
up the change. Nothing is read from disk while serving a request. The files
|
||||
come in milestone 3 or later (see the build order in [`SPEC.md`](SPEC.md));
|
||||
until then the rate counters and the GeoJS answers are kept in memory only,
|
||||
and a restart loses them.
|
||||
until then the rate counters, the bans and the GeoJS answers are kept in
|
||||
memory only, and a restart loses them.
|
||||
- Health checks, the metrics, and listing, adding and lifting bans or asking why
|
||||
a given address was refused, all on the one port every request uses: under
|
||||
`/_smallwebwaf/` on the app's own address, through traefik like any other
|
||||
@@ -427,17 +466,18 @@ the metrics, failure behaviour and the build order.
|
||||
|
||||
So far `smallwebwaf` looks up only the country, only through GeoJS, and only
|
||||
while `SWWAF_DENIED_COUNTRIES` or `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set:
|
||||
then the address of every new visitor outside `SWWAF_ALLOW_NETS` and
|
||||
`SWWAF_DENY_NETS` is sent to GeoJS, and with neither set, none is. An IPv6
|
||||
visitor is asked about by the first address of its /64. A new visitor waits at
|
||||
most a second for its answer, and without one counts as coming from an unknown
|
||||
country until the answer arrives. The addresses waiting are asked about
|
||||
together, up to 200 in one request, one request at a time; at most 10,000
|
||||
visitors wait, and one more counts as coming from an unknown country until there
|
||||
is room. While GeoJS fails, visitors with a kept answer are unaffected and new
|
||||
ones count as coming from an unknown country. GeoJS is then left alone for a
|
||||
second, twice as long after each further failure up to five minutes, and asked
|
||||
again by the next request that needs it.
|
||||
then the address of every new visitor is sent to GeoJS, except a visitor in
|
||||
`SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS` and one refused because a ban covers its
|
||||
netblock, and with neither set, none is. An IPv6 visitor is asked about by the
|
||||
first address of its /64. A new visitor waits at most a second for its answer,
|
||||
and without one counts as coming from an unknown country until the answer
|
||||
arrives. The addresses waiting are asked about together, up to 200 in one
|
||||
request, one request at a time; at most 10,000 visitors wait, and one more
|
||||
counts as coming from an unknown country until there is room. While GeoJS fails,
|
||||
visitors with a kept answer are unaffected and new ones count as coming from an
|
||||
unknown country. GeoJS is then left alone for a second, twice as long after each
|
||||
further failure up to five minutes, and asked again by the next request that
|
||||
needs it.
|
||||
|
||||
In the full design, `smallwebwaf` looks up the AS number and country of every
|
||||
client, for the request log, the metrics and the ban notes, and for the country
|
||||
@@ -488,8 +528,10 @@ addresses are never sent to GeoJS.
|
||||
standard library's `httputil.ReverseProxy` within the timeouts and size
|
||||
limits, and writes the request's log line. Its `check` method is where a
|
||||
request is refused before anything reaches the app: for `SWWAF_DENY_NETS`, for
|
||||
the country lists, for a rate limit, and for an announced body over the size
|
||||
limit.
|
||||
a ban, for the country lists, for a rate limit, which bans the client, and for
|
||||
an announced body over the size limit.
|
||||
- `internal/bans`: the ban ledger: each netblock's bans with their notes, how
|
||||
long a new ban lasts, and which ban is dropped when `SWWAF_MAX_BANS` are held.
|
||||
- `internal/lookup`: looks up each client's country through GeoJS, and keeps the
|
||||
answers.
|
||||
- `internal/ratelimit`: counts each client's requests and tells when one takes
|
||||
@@ -503,8 +545,9 @@ addresses are never sent to GeoJS.
|
||||
checks.
|
||||
|
||||
Besides the Go standard library, `github.com/hashicorp/golang-lru/v2` keeps the
|
||||
table of clients to 20,000 and the GeoJS answers to 100,000, dropping the least
|
||||
recently seen. The country codes are the list in `internal/config/config.go`.
|
||||
table of clients to 20,000, the GeoJS answers to 100,000 and the banned
|
||||
netblocks to `SWWAF_MAX_BANS`, dropping the least recently seen. The country
|
||||
codes are the list in `internal/config/config.go`.
|
||||
|
||||
## Entrypoints
|
||||
|
||||
@@ -544,9 +587,9 @@ so that they run in minimal containers.
|
||||
|
||||
## TODO
|
||||
|
||||
- The rest of milestone 3, from the bans that broken request limits lead to
|
||||
through the metrics endpoint, and the rest of the design, in the order of the
|
||||
build order in [`SPEC.md`](SPEC.md).
|
||||
- The rest of milestone 3, after the bans that broken rate limits lead to and up
|
||||
to the metrics endpoint, and the rest of the design, in the order of the build
|
||||
order in [`SPEC.md`](SPEC.md).
|
||||
|
||||
## Documents
|
||||
|
||||
|
||||
Reference in New Issue
Block a user