Network lists: always allowed, exempt from rate limits, always refused (closes #19)
check / check (push) Successful in 3m47s

Adds SWWAF_ALLOW_NETS, SWWAF_RATE_LIMIT_EXEMPT_NETS and SWWAF_DENY_NETS,
read like SWWAF_TRUSTED_PROXIES and empty by default, and checked against
the client's own address before its country is looked up. A client in
SWWAF_ALLOW_NETS skips the country lists and the rate limits and is not
looked up. One in SWWAF_DENY_NETS is refused with 403, logged as denied
and not counted. One in SWWAF_RATE_LIMIT_EXEMPT_NETS is neither counted
nor refused by the rate limits. SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES now
refuses a private, loopback or link-local client unless SWWAF_ALLOW_NETS
lists it.

Judgement call: an address in both SWWAF_ALLOW_NETS and SWWAF_DENY_NETS is let through.
Judgement call: the size and time limits still apply to SWWAF_ALLOW_NETS.

Model: opus-5-5
This commit was merged in pull request #66.
This commit is contained in:
2026-10-06 02:36:27 +02:00
parent df4cf769e0
commit 7f6f89cd83
10 changed files with 350 additions and 55 deletions
+53 -36
View File
@@ -13,14 +13,15 @@ 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). `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 or comes from a country you refuse, 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).
https://git.eeqj.de/sneak/smallwebwaf/issues/14), and so are the static lists,
which come next in the build order. `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 [`EVALUATION.md`](EVALUATION.md).
## Getting started
@@ -82,8 +83,17 @@ and `make run` builds and runs it, listening on port 8080 in front of an app at
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
neither list checks it.
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.
- 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).
@@ -111,6 +121,11 @@ 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_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.
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `1000`), `SWWAF_RATE_LIMIT_PER_HOUR`
(default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`): the most
requests a client may make in a minute, an hour and a day. The defaults are
@@ -153,18 +168,19 @@ 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 on a private, loopback or
link-local address, and when GeoJS cannot place the client or has not answered
in time.
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.
- `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, `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.
- `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
window whose limit it went over: `minute`, `hour` or `day`, the shortest if it
went over several.
@@ -402,16 +418,17 @@ 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 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 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.
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
@@ -447,9 +464,8 @@ data is powered by IPinfo". A service that uses the database through
Neither source can place a private address, so a client on one, such as a
visitor on your local network, another container or your monitoring, has no
country: `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses it unless you list it in
`SWWAF_ALLOW_NETS`. Such addresses are never sent to GeoJS. In milestone 2,
which has no `SWWAF_ALLOW_NETS`, neither country list checks such a client; the
refusal comes with `SWWAF_ALLOW_NETS` in milestone 3 or later.
`SWWAF_ALLOW_NETS`, and `SWWAF_DENIED_COUNTRIES` does not refuse it. Such
addresses are never sent to GeoJS.
## How the code is laid out
@@ -462,8 +478,9 @@ refusal comes with `SWWAF_ALLOW_NETS` in milestone 3 or later.
the checks, passes the request to the app and the answer back with the
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 the country lists, for
a rate limit, and for an announced body over the size limit.
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.
- `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
@@ -518,8 +535,8 @@ so that they run in minimal containers.
## TODO
- Milestone 3 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 static lists, and the rest of the design,
in the order of the build order in [`SPEC.md`](SPEC.md).
## Documents