Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 611ae5c008 Leave SWWAF_RATE_LIMIT_EXEMPT_PATHS out of the request rate limits (closes #77)
check / check (push) Successful in 4m48s
A request whose path, as the client sent it and before the query
string, starts with one of the comma-separated prefixes in
SWWAF_RATE_LIMIT_EXEMPT_PATHS is neither counted nor refused by the
request rate limits; the static lists, bans and the country lists still
apply. The setting is empty by default, and a prefix that does not start
with / stops the start. README.md documents it.

Judgement call: plain prefix on the path as sent, as the issue rules, so
/assets/../login matches /assets/; traefik removes such dot segments by
default, but a request that reaches smallwebwaf uncleaned is matched as
sent.

Model: opus-5-5
2026-10-06 10:06:38 +00:00
12 changed files with 104 additions and 493 deletions
+38 -67
View File
@@ -13,23 +13,21 @@ JSON log line for every request.
Status: the first two milestones are built Status: the first two milestones are built
(https://git.eeqj.de/sneak/smallwebwaf/issues/13 and (https://git.eeqj.de/sneak/smallwebwaf/issues/13 and
https://git.eeqj.de/sneak/smallwebwaf/issues/14), and so are seven parts of https://git.eeqj.de/sneak/smallwebwaf/issues/14), and so are six parts of
milestone 3: the static lists, the bans that broken rate limits lead to, the milestone 3: the static lists, the bans that broken rate limits lead to, the
JSON state files and the paths the rate limits do not count, which come next in JSON state files and the paths the rate limits do not count, which come next in
the build order, `observe` mode, which comes a little later, and the metrics the build order, and the metrics endpoint and the header size and the idle time
endpoint and the header size and the idle time as settings, which come last in as settings, which come last in it. `smallwebwaf` passes each request to the app
it. `smallwebwaf` passes each request to the app and the app's answer back, and the app's answer back, unchanged, within its timeouts and size limits, works
unchanged, within its timeouts and size limits, works out each client's address, out each client's address, bans a client that sends too many requests, not
bans a client that sends too many requests, not counting those for the paths you counting those for the paths you choose, refuses a client that comes from a
choose, refuses a client that comes from a country you refuse or from a network country you refuse or from a network you refuse, lets the networks you choose
you refuse, lets the networks you choose through, keeps its bans, each client's through, keeps its bans, each client's counters and history, and GeoJS's answers
counters and history, and GeoJS's answers in JSON files across restarts, writes in JSON files across restarts, writes a JSON log line for every request, and
a JSON log line for every request, serves Prometheus metrics to a scraper that serves Prometheus metrics to a scraper that holds the metrics token. It comes as
holds the metrics token, and in `observe` mode passes on the requests it would the image the app's own image is built on. The rest of the design comes after
refuse, logging what it would have done with them. It comes as the image the that, in the order of the build order in [`SPEC.md`](SPEC.md). The survey of
app's own image is built on. The rest of the design comes after that, in the existing tools that led to the design is in [`EVALUATION.md`](EVALUATION.md).
order of the build order in [`SPEC.md`](SPEC.md). The survey of existing tools
that led to the design is in [`EVALUATION.md`](EVALUATION.md).
## Getting started ## Getting started
@@ -123,18 +121,6 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set.
`SWWAF_ALLOW_NETS` too is let through. A client in `SWWAF_ALLOW_NETS` too is let through. A client in
`SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor refused by the rate `SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor refused by the rate
limits; the country lists and bans still apply to it. limits; the country lists and bans still apply to it.
- In `observe` mode, with `SWWAF_MODE=observe`, refuses none of the requests
that `SWWAF_DENY_NETS`, a ban, the country lists or a rate limit would refuse:
it passes them to the app, and their log lines name what `enforce` mode would
have done (see `would_action` in "Request log" below). The checks run, and
requests are counted, as in `enforce` mode, but a broken rate limit makes no
ban and does not set the client's counters back to zero, so each request over
the limit is logged as one that would be refused. The bans in `bans.json` are
kept, and refuse requests again when `smallwebwaf` next runs in `enforce`
mode, as long as they last. The timeouts and size limits still apply, since
they protect `smallwebwaf` and the app themselves, and a request for the
metrics without the 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
@@ -156,9 +142,6 @@ it, and the effective settings are logged at start.
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `smallwebwaf` listens. - `SWWAF_LISTEN_ADDR` (default `:8080`): where `smallwebwaf` listens.
- `SWWAF_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app, as `http` or - `SWWAF_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app, as `http` or
`https`, a host and an optional port, and nothing more. `https`, a host and an optional port, and nothing more.
- `SWWAF_MODE` (default `enforce`): `enforce`, or `observe` to pass on the
requests `smallwebwaf` would refuse and log what it would have done (see "What
it does so far" above).
- `SWWAF_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`, - `SWWAF_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`,
the private address ranges): the netblocks whose `X-Forwarded-For` is the private address ranges): the netblocks whose `X-Forwarded-For` is
believed. A list given replaces the default; set but empty, it trusts nothing. believed. A list given replaces the default; set but empty, it trusts nothing.
@@ -196,15 +179,11 @@ it, and the effective settings are logged at start.
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, such as `/assets/` for static
assets; each starts with `/`. A prefix is compared, character for character, assets; each starts with `/`. A prefix is compared with the start of the path
with the start of the path the app will act on: the request's path, before any as the client sent it, before any query string, as the request log's `path`
query string, percent-decoded, with its `.` and `..` segments and repeated shows it, character for character: `/assets/` matches `/assets/app.js` and
slashes resolved and without a trailing slash, which is not always what the `/assets/img/logo.png`, but not `/assets`, `/Assets/app.js` or
request log's `path` shows. `/assets/` matches `/assets/app.js`, `/static/assets/app.js`. There are no wildcards: `*` is a character like any
`/assets//img/logo.png` and `/static/../assets/app.js`, but not `/assets/`
itself, `/assets`, `/Assets/app.js`, `/static/assets/app.js` or
`/assets/..%2Flogin`, which is `/login`. A prefix is written without
percent-encoding, and there are no wildcards: `*` is a character like any
other. other.
- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused, - `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
for example `cn,ru,kp`. for example `cn,ru,kp`.
@@ -272,8 +251,8 @@ refused ones included:
- `country` is the client's country as GeoJS places it. It is empty with neither - `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 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 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 whose client a ban the client or has not answered in time, and for a request refused because a
covers, even when the client's country is known. 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 - `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. what the app answered, and is left out when the app did not answer.
- `request_bytes` and `response_bytes` count body bytes. - `request_bytes` and `response_bytes` count body bytes.
@@ -285,18 +264,11 @@ refused ones included:
`timed_out` for one that ran out of time, `upstream_error` when the app could `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` not be reached or its answer broke off, and `admin` for one `smallwebwaf`
answered at its own endpoint. answered at its own endpoint.
- `would_action` is there in `observe` mode for a request that
`SWWAF_DENY_NETS`, a ban, the country lists or a rate limit would have refused
in `enforce` mode, and names the action that refusal would have had: `denied`,
`banned`, `country_denied` or `rate_limited`. `action` then names what was
done: `forward` for a request passed to the app, and another action, such as
`too_large`, for one a size or time limit refused.
- `limit_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, and names the
window whose limit it went over: `minute`, `hour` or `day`, the shortest if it window whose limit it went over: `minute`, `hour` or `day`, the shortest if it
went over several. `offence` is then `limit`. went over several. `offence` is then `limit`.
- `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 and gives when the ban ends, in the same form as `time`, or `permanent`.
ends, in the same form as `time`, or `permanent`.
- `aborted` is there, and true, when the client went away early. - `aborted` is there, and true, when the client went away early.
- `duration_total` and `duration_upstream_total` are in milliseconds. - `duration_total` and `duration_upstream_total` are in milliseconds.
@@ -620,16 +592,17 @@ the metrics, failure behaviour and the build order.
So far `smallwebwaf` looks up only the country, only through GeoJS, and only So far `smallwebwaf` looks up only the country, only through GeoJS, and only
while `SWWAF_DENIED_COUNTRIES` or `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set: while `SWWAF_DENIED_COUNTRIES` or `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set:
then the address of every new visitor is sent to GeoJS, except a visitor in then the address of every new visitor is sent to GeoJS, except a visitor in
`SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS` and one whose netblock a ban covers, and `SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS` and one refused because a ban covers its
with neither set, none is. An IPv6 visitor is asked about by the first address netblock, and with neither set, none is. An IPv6 visitor is asked about by the
of its /64. A new visitor waits at most a second for its answer, and without one first address of its /64. A new visitor waits at most a second for its answer,
counts as coming from an unknown country until the answer arrives. The addresses and without one counts as coming from an unknown country until the answer
waiting are asked about together, up to 200 in one request, one request at a arrives. The addresses waiting are asked about together, up to 200 in one
time; at most 10,000 visitors wait, and one more counts as coming from an request, one request at a time; at most 10,000 visitors wait, and one more
unknown country until there is room. While GeoJS fails, visitors with a kept counts as coming from an unknown country until there is room. While GeoJS fails,
answer are unaffected and new ones count as coming from an unknown country. visitors with a kept answer are unaffected and new ones count as coming from an
GeoJS is then left alone for a second, twice as long after each further failure unknown country. GeoJS is then left alone for a second, twice as long after each
up to five minutes, and asked again by the next request that needs it. 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 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 client, for the request log, the metrics and the ban notes, and for the country
@@ -681,10 +654,8 @@ addresses are never sent to GeoJS.
limits, and writes the request's log line. Its `check` method is where a limits, and writes the request's log line. Its `check` method is where a
request is refused before anything reaches the app: for `SWWAF_DENY_NETS`, for request is refused before anything reaches the app: for `SWWAF_DENY_NETS`, for
a ban, for the country lists, for a rate limit, which bans the client, and for a ban, for the country lists, for a rate limit, which bans the client, and for
an announced body over the size limit; in `observe` mode, only for the size an announced body over the size limit. A request under `/_smallwebwaf/` that
limit, with what it would have refused for noted in the log line. A request `check` lets through is answered by `answerAdmin` instead of reaching the app.
under `/_smallwebwaf/` that `check` lets through is answered by `answerAdmin`
instead of reaching the app.
- `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
@@ -750,10 +721,10 @@ so that they run in minimal containers.
## TODO ## TODO
- The rest of milestone 3: taking in an admin's edits to the state files - The rest of milestone 3, from taking in an admin's edits to the state files
(https://git.eeqj.de/sneak/smallwebwaf/issues/68), exemptions and the rest of (https://git.eeqj.de/sneak/smallwebwaf/issues/68) up to the rest of the
the request log's fields; then the rest of the design, in the order of the request log's fields, and the rest of the design, in the order of the build
build order in [`SPEC.md`](SPEC.md). order in [`SPEC.md`](SPEC.md).
## Documents ## Documents
+22 -47
View File
@@ -107,8 +107,8 @@ type Ledger struct {
changed chan struct{} changed chan struct{}
mu sync.Mutex mu sync.Mutex
// netblocks holds each banned netblock's bans, oldest first. Check and // netblocks holds each banned netblock's bans, oldest first. Check
// Find make each netblock they find the most recently seen. // makes each netblock it finds the most recently seen.
netblocks *simplelru.LRU[netip.Prefix, *[]Ban] netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
// held is how many bans netblocks holds, at most rules.MaxBans. // held is how many bans netblocks holds, at most rules.MaxBans.
held int held int
@@ -144,36 +144,36 @@ func (l *Ledger) Changed() <-chan struct{} {
return l.changed return l.changed
} }
// Check is called for a request from client, at now. It reports whether // Check is called for each request from client, at now. It reports
// a ban on a netblock client is in is active, and returns that ban, with // whether a ban on a netblock client is in is active, and returns that
// the request counted among those it refused. // ban, with the request counted among those it refused.
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) { func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
ban := l.active(client, now) lengths := l.v6Lengths
if ban == nil { if client.Is4() {
return Ban{}, false lengths = l.v4Lengths
} }
ban.Notes.Requests++ for _, length := range lengths {
ban.Notes.Refused++ bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
if !found {
continue
}
return *ban, true // A ban is made only once the one before has ended, so only the
} // last can be active.
last := &(*bans)[len(*bans)-1]
if last.ActiveAt(now) {
last.Notes.Requests++
last.Notes.Refused++
// Find is Check without counting the request among those the ban return *last, true
// refused: in observe mode a ban refuses nothing. }
func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool) {
l.mu.Lock()
defer l.mu.Unlock()
ban := l.active(client, now)
if ban == nil {
return Ban{}, false
} }
return *ban, true return Ban{}, false
} }
// BanForLimit bans netblock at now for a broken limit, with notes, and // BanForLimit bans netblock at now for a broken limit, with notes, and
@@ -304,31 +304,6 @@ func (l *Ledger) Load(bans []Ban) {
} }
} }
// active returns the ban active at now on a netblock client is in, or
// nil.
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
lengths := l.v6Lengths
if client.Is4() {
lengths = l.v4Lengths
}
for _, length := range lengths {
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
if !found {
continue
}
// A ban is made only once the one before has ended, so only the
// last can be active.
last := &(*bans)[len(*bans)-1]
if last.ActiveAt(now) {
return last
}
}
return nil
}
// add adds ban to its netblock's bans, after the last, and makes its // add adds ban to its netblock's bans, after the last, and makes its
// netblock the most recently seen. With MaxBans held, it drops one first. // netblock the most recently seen. With MaxBans held, it drops one first.
func (l *Ledger) add(ban Ban) { func (l *Ledger) add(ban Ban) {
-22
View File
@@ -162,28 +162,6 @@ func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
} }
} }
func TestFindCountsNothing(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
got, banned := ledger.Find(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
if !banned || got != ban {
t.Errorf("find during the ban gives %+v and %t, want %+v", got, banned, ban)
}
_, banned = ledger.Find(netblock.Addr(), ban.Expires)
if banned {
t.Error("the ban did not end")
}
if notes := ledger.Bans(netblock)[0].Notes; notes != ban.Notes {
t.Errorf("the notes are %+v, want them unchanged, %+v", notes, ban.Notes)
}
}
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) { func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
t.Parallel() t.Parallel()
-18
View File
@@ -27,11 +27,6 @@ type Config struct {
ListenAddr string ListenAddr string
// UpstreamURL is the app (SWWAF_UPSTREAM_URL). // UpstreamURL is the app (SWWAF_UPSTREAM_URL).
UpstreamURL *url.URL UpstreamURL *url.URL
// Observe is true in observe mode, when SWWAF_MODE is observe rather
// than enforce: a request that SWWAF_DENY_NETS, a ban, the country
// lists or a rate limit would refuse is passed to the app instead, and
// no ban is made.
Observe bool
// TrustedProxies are the netblocks whose X-Forwarded-For is // TrustedProxies are the netblocks whose X-Forwarded-For is
// believed (SWWAF_TRUSTED_PROXIES). // believed (SWWAF_TRUSTED_PROXIES).
TrustedProxies []netip.Prefix TrustedProxies []netip.Prefix
@@ -171,7 +166,6 @@ var (
errNotAbsolutePath = errors.New( errNotAbsolutePath = errors.New(
"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")
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/")
) )
@@ -184,7 +178,6 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
cfg := &Config{ cfg := &Config{
ListenAddr: env.address("SWWAF_LISTEN_ADDR", ":8080"), ListenAddr: env.address("SWWAF_LISTEN_ADDR", ":8080"),
UpstreamURL: env.appURL("SWWAF_UPSTREAM_URL", "http://127.0.0.1:8081"), UpstreamURL: env.appURL("SWWAF_UPSTREAM_URL", "http://127.0.0.1:8081"),
Observe: env.observe("SWWAF_MODE", "enforce"),
TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges), TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges),
ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"), ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"),
ClientRequestHeaderMaxBytes: env.headerSize( ClientRequestHeaderMaxBytes: env.headerSize(
@@ -288,17 +281,6 @@ func (e *environment) appURL(name, defaultValue string) *url.URL {
return upstream return upstream
} }
// observe reads the setting that is the mode, enforce or observe, and
// reports whether it is observe.
func (e *environment) observe(name, defaultValue string) bool {
mode := e.value(name, defaultValue)
if mode != "enforce" && mode != "observe" {
e.check(name, fmt.Errorf("%q %w", mode, errNotMode))
}
return mode == "observe"
}
// netblocks reads a setting that is a list of netblocks. // netblocks reads a setting that is a list of netblocks.
func (e *environment) netblocks(name, defaultValue string) []netip.Prefix { func (e *environment) netblocks(name, defaultValue string) []netip.Prefix {
netblocks, err := parseNetblocks(e.value(name, defaultValue)) netblocks, err := parseNetblocks(e.value(name, defaultValue))
-7
View File
@@ -18,7 +18,6 @@ import (
const ( const (
listenAddr = "SWWAF_LISTEN_ADDR" listenAddr = "SWWAF_LISTEN_ADDR"
upstreamURL = "SWWAF_UPSTREAM_URL" upstreamURL = "SWWAF_UPSTREAM_URL"
mode = "SWWAF_MODE"
trustedProxies = "SWWAF_TRUSTED_PROXIES" trustedProxies = "SWWAF_TRUSTED_PROXIES"
clientRequestTimeout = "SWWAF_CLIENT_REQUEST_TIMEOUT" clientRequestTimeout = "SWWAF_CLIENT_REQUEST_TIMEOUT"
clientHeaderMaxBytes = "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES" clientHeaderMaxBytes = "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES"
@@ -85,7 +84,6 @@ func TestDefaults(t *testing.T) {
wantSettings(t, cfg, config.Config{ wantSettings(t, cfg, config.Config{
ListenAddr: ":8080", ListenAddr: ":8080",
Observe: false,
ClientRequestTimeout: time.Minute, ClientRequestTimeout: time.Minute,
ClientRequestHeaderMaxBytes: 32 << 10, ClientRequestHeaderMaxBytes: 32 << 10,
ClientIdleTimeout: 2 * time.Minute, ClientIdleTimeout: 2 * time.Minute,
@@ -133,7 +131,6 @@ func TestValuesAsSet(t *testing.T) {
cfg := fromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
listenAddr: "127.0.0.1:9000", listenAddr: "127.0.0.1:9000",
upstreamURL: "https://app.internal:8443/", upstreamURL: "https://app.internal:8443/",
mode: "observe",
trustedProxies: " 192.0.2.1, 10.1.2.3/8 ,2001:db8::/32", trustedProxies: " 192.0.2.1, 10.1.2.3/8 ,2001:db8::/32",
clientRequestTimeout: "90s", clientRequestTimeout: "90s",
clientHeaderMaxBytes: "8K", clientHeaderMaxBytes: "8K",
@@ -167,7 +164,6 @@ func TestValuesAsSet(t *testing.T) {
wantSettings(t, cfg, config.Config{ wantSettings(t, cfg, config.Config{
ListenAddr: "127.0.0.1:9000", ListenAddr: "127.0.0.1:9000",
Observe: true,
ClientRequestTimeout: 90 * time.Second, ClientRequestTimeout: 90 * time.Second,
ClientRequestHeaderMaxBytes: 8 << 10, ClientRequestHeaderMaxBytes: 8 << 10,
ClientIdleTimeout: 5 * time.Minute, ClientIdleTimeout: 5 * time.Minute,
@@ -335,7 +331,6 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{upstreamURL, "http://127.0.0.1:8081/app"}, {upstreamURL, "http://127.0.0.1:8081/app"},
{upstreamURL, "http://127.0.0.1:8081/?a=1"}, {upstreamURL, "http://127.0.0.1:8081/?a=1"},
{upstreamURL, "http://user:secret@127.0.0.1:8081"}, {upstreamURL, "http://user:secret@127.0.0.1:8081"},
{mode, "Observe"}, {mode, "block"}, {mode, ""},
{trustedProxies, "10.0.0.0/33"}, {trustedProxies, "10.0.0.0/33"},
{trustedProxies, "traefik"}, {trustedProxies, "traefik"},
{trustedProxies, "10.0.0.0/8,,192.168.0.0/16"}, {trustedProxies, "10.0.0.0/8,,192.168.0.0/16"},
@@ -456,7 +451,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
want := map[string]string{ want := map[string]string{
listenAddr: ":8080", listenAddr: ":8080",
upstreamURL: "http://127.0.0.1:8081", upstreamURL: "http://127.0.0.1:8081",
mode: "enforce",
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16", trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
clientRequestTimeout: "45s", clientRequestTimeout: "45s",
clientHeaderMaxBytes: "32K", clientHeaderMaxBytes: "32K",
@@ -497,7 +491,6 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) {
t.Helper() t.Helper()
if got.ListenAddr != want.ListenAddr || if got.ListenAddr != want.ListenAddr ||
got.Observe != want.Observe ||
got.ClientRequestTimeout != want.ClientRequestTimeout || got.ClientRequestTimeout != want.ClientRequestTimeout ||
got.ClientRequestHeaderMaxBytes != want.ClientRequestHeaderMaxBytes || got.ClientRequestHeaderMaxBytes != want.ClientRequestHeaderMaxBytes ||
got.ClientIdleTimeout != want.ClientIdleTimeout || got.ClientIdleTimeout != want.ClientIdleTimeout ||
+8 -18
View File
@@ -14,15 +14,10 @@ func (rq *request) banResponse(action string) *refusal {
return &refusal{status: rq.h.config.BanResponse, action: action} return &refusal{status: rq.h.config.BanResponse, action: action}
} }
// banned reports whether a ban on a netblock the client is in covers the // banned reports whether a ban on a netblock the client is in refuses
// request at now, and notes for the log line when that ban ends. // the request at now, and notes for the log line when that ban ends.
func (rq *request) banned(now time.Time) bool { func (rq *request) banned(now time.Time) bool {
check := rq.h.ledger.Check ban, banned := rq.h.ledger.Check(rq.client, now)
if rq.h.config.Observe {
check = rq.h.ledger.Find // in observe mode the ban refuses nothing
}
ban, banned := check(rq.client, now)
if banned { if banned {
rq.line.BanExpires = banExpires(ban) rq.line.BanExpires = banExpires(ban)
} }
@@ -31,9 +26,8 @@ func (rq *request) banned(now time.Time) bool {
} }
// limitBroken counts the request for the rate limits at now, and reports // limitBroken counts the request for the rate limits at now, and reports
// whether it takes the client over one. In enforce mode such a request // whether it takes the client over one. Such a request bans the client's
// bans the client's netblock, and sets the client's counters back to // netblock, and sets the client's counters back to zero.
// zero; in observe mode it does neither.
func (rq *request) limitBroken(now time.Time) bool { func (rq *request) limitBroken(now time.Time) bool {
group := clientGroup(rq.client) group := clientGroup(rq.client)
@@ -42,13 +36,6 @@ func (rq *request) limitBroken(now time.Time) bool {
return false return false
} }
rq.line.LimitHit = hit.Window
rq.line.Offence = requestlog.OffenceLimit
if rq.h.config.Observe {
return true
}
netblock := rq.netblock() netblock := rq.netblock()
ban := rq.h.ledger.BanForLimit(netblock, now, bans.Notes{ ban := rq.h.ledger.BanForLimit(netblock, now, bans.Notes{
Country: rq.line.Country, Country: rq.line.Country,
@@ -67,6 +54,9 @@ func (rq *request) limitBroken(now time.Time) bool {
Requests: rq.h.limiter.Requests(netblock) + 1, Requests: rq.h.limiter.Requests(netblock) + 1,
}) })
rq.h.limiter.Reset(group) rq.h.limiter.Reset(group)
rq.line.LimitHit = hit.Window
rq.line.Offence = requestlog.OffenceLimit
rq.line.BanExpires = banExpires(ban) rq.line.BanExpires = banExpires(ban)
return true return true
-202
View File
@@ -1,202 +0,0 @@
package proxy_test
import (
"bytes"
"io"
"net/http"
"net/netip"
"sync/atomic"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// observe is the value of SWWAF_MODE for observe mode.
const observe = "observe"
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
t.Parallel()
const (
denied = "192.0.2.50" // in SWWAF_DENY_NETS
banned = otherClient // under a ban read from bans.json
)
for _, tc := range []struct {
setting string // "" leaves SWWAF_MODE at its default
observe bool
}{
{"", false},
{"enforce", false},
{observe, true},
} {
t.Run(mode+"="+tc.setting, func(t *testing.T) {
t.Parallel()
geojsURL, _ := startGeoJS(t)
env := map[string]string{
rateLimitPerMinute: "1",
denyNets: denied,
deniedCountries: "kp",
}
if tc.setting != "" {
env[mode] = tc.setting
}
s, clk, server := startWithClock(t, geojsURL, env)
server.Ledger.Load([]bans.Ban{{
Netblock: netip.MustParsePrefix(banned + "/32"),
Start: clk.Now(),
Expires: clk.Now().Add(time.Hour),
}})
// fromDE's first request is within the limit of one a minute,
// and its second breaks it.
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
for _, sent := range []struct{ from, refusal string }{
{denied, requestlog.ActionDenied},
{banned, requestlog.ActionBanned},
{fromKP, requestlog.ActionCountryDenied},
{fromDE, requestlog.ActionRateLimited},
} {
if !tc.observe {
line := s.get(sent.from, http.StatusForbidden, sent.refusal)
wantWouldAction(t, line, "")
continue
}
// Passed to the app, which answered it.
line := s.get(sent.from, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, sent.refusal)
if line.UpstreamStatus != http.StatusOK {
t.Errorf("log line has upstream_status %d, want 200",
line.UpstreamStatus)
}
}
})
}
}
func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
t.Parallel()
s, clk, server := startWithClock(t, "", map[string]string{
mode: observe,
rateLimitPerMinute: "1",
})
kept := bans.Ban{
Netblock: netip.MustParsePrefix(otherClient + "/32"),
Start: clk.Now(),
Expires: clk.Now().Add(time.Hour),
}
server.Ledger.Load([]bans.Ban{kept})
// No ban sets client's counters back to zero, so each request after
// the first breaks the limit of one a minute.
s.get(client, http.StatusOK, requestlog.ActionForward)
for range 2 {
line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionRateLimited)
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
line.BanExpires != "" {
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
"want minute, limit and none", line.LimitHit, line.Offence,
line.BanExpires)
}
}
// The ban read from bans.json refuses nothing, and so counts no
// refusal in its notes, but is kept.
line := s.get(otherClient, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned)
if line.BanExpires != requestlog.FormatTime(kept.Expires) {
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires,
requestlog.FormatTime(kept.Expires))
}
got := server.Ledger.Snapshot()
if len(got) != 1 || got[0] != kept {
t.Errorf("bans\n%+v\nwant only\n%+v", got, kept)
}
}
func TestObserveModeKeepsTheSizeLimitsAndTheToken(t *testing.T) {
t.Parallel()
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
var calls atomic.Int32
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
answerWithSize(w, 2*sizeLimit, true)
})
addr, out := startProxy(t, app.URL, map[string]string{
mode: observe,
trustedProxies: trustLocalhost,
denyNets: denied,
requestMaxBytes: sizeLimitSetting,
responseMaxBytes: sizeLimitSetting,
metricsToken: token,
})
// SWWAF_DENY_NETS would refuse each request; instead a size limit or
// the missing token does.
for i, tc := range []struct {
method, path string
body io.Reader
status int
action string
}{
{
http.MethodPost, "/upload", bytes.NewReader(make([]byte, 2*sizeLimit)),
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge,
},
{
http.MethodGet, "/download", http.NoBody,
http.StatusBadGateway, requestlog.ActionTooLarge,
},
{
http.MethodGet, proxy.MetricsPath, http.NoBody,
http.StatusUnauthorized, requestlog.ActionAdmin,
},
} {
req := newRequest(t, tc.method, addr, tc.path, tc.body)
req.Header.Set(forwardedFor, denied)
wantStatus(t, do(t, req), tc.status)
line := out.requestLines(t, i+1)[i]
wantLine(t, line, tc.status, tc.action)
wantWouldAction(t, line, requestlog.ActionDenied)
}
// The upload was refused before it reached the app.
if calls.Load() != 1 {
t.Errorf("the app was called %d times, want once", calls.Load())
}
}
// wantWouldAction checks the request log line's would_action, and that a
// line that should have none has no such field.
func wantWouldAction(t *testing.T, line logLine, want string) {
t.Helper()
got, present := line.fields["would_action"]
switch {
case want == "" && present:
t.Errorf("log line has would_action %v, want none", got)
case want != "" && got != want:
t.Errorf("log line has would_action %v, want %s", got, want)
}
}
-1
View File
@@ -50,7 +50,6 @@ const (
clientResponseTimeout = "SWWAF_CLIENT_RESPONSE_TIMEOUT" clientResponseTimeout = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
upstreamRequestTimeout = "SWWAF_UPSTREAM_REQUEST_TIMEOUT" upstreamRequestTimeout = "SWWAF_UPSTREAM_REQUEST_TIMEOUT"
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT" upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT"
mode = "SWWAF_MODE"
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES" requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES"
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES" responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
trustedProxies = "SWWAF_TRUSTED_PROXIES" trustedProxies = "SWWAF_TRUSTED_PROXIES"
+2 -44
View File
@@ -5,7 +5,6 @@ import (
"sync/atomic" "sync/atomic"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
@@ -76,28 +75,18 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
const denied = "192.0.2.50" // in SWWAF_DENY_NETS const denied = "192.0.2.50" // in SWWAF_DENY_NETS
s, _, server := startWithClock(t, "", map[string]string{ s, _, _ := startWithClock(t, "", map[string]string{
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
rateLimitExemptPaths: "/assets/,/favicon.ico", rateLimitExemptPaths: "/assets/,/favicon.ico",
denyNets: denied, denyNets: denied,
deniedCountries: "kp",
})
// The answers are kept before the requests, so that none waits for
// GeoJS.
server.GeoJS.Load([]lookup.Answer{
keptAnswer(client, "DE"), keptAnswer(fromKP, "KP"),
}) })
// With a limit of one request a minute, the requests for paths under a // With a limit of one request a minute, the requests for paths under a
// prefix are not counted, so client's first request for / is within // prefix are not counted, so client's first request for / is within
// the limit; and once client has reached it, they are not refused. // the limit; and once client has reached it, they are not refused.
// The app acts on /static/../assets/app.js as /assets/app.js.
s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward) s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward)
s.request(client, "/favicon.ico?v=2", http.StatusOK, requestlog.ActionForward) s.request(client, "/favicon.ico?v=2", http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.request(client, "/static/../assets/app.js",
http.StatusOK, requestlog.ActionForward)
line := s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward) line := s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward)
if line.LimitHit != "" { if line.LimitHit != "" {
@@ -108,38 +97,7 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
// /assets/, and breaks the limit. // /assets/, and breaks the limit.
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited) s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
// A ban, SWWAF_DENY_NETS and the country lists still refuse a path // A ban and SWWAF_DENY_NETS still refuse a path under a prefix.
// under a prefix.
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied) s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
s.request(fromKP, "/assets/app.js",
http.StatusForbidden, requestlog.ActionCountryDenied)
}
func TestRateLimitCountsPathsOutsideEveryExemptPrefix(t *testing.T) {
t.Parallel()
// A prefix matches only at the start of the path, and the app acts on
// the last three paths as /login, once they are percent-decoded and
// their .. segments resolved.
for _, sent := range []string{
"/static/assets/app.js",
"/assets/../login",
"/assets/%2e%2e/login",
"/assets/..%2Flogin",
} {
t.Run(sent, func(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rateLimitPerMinute: "1",
rateLimitExemptPaths: "/assets/",
})
// Counted, the second request breaks the limit of one request
// a minute.
s.request(client, sent, http.StatusOK, requestlog.ActionForward)
s.request(client, sent, http.StatusForbidden, requestlog.ActionRateLimited)
})
}
} }
+34 -62
View File
@@ -8,7 +8,6 @@ import (
"net/http/httputil" "net/http/httputil"
"net/netip" "net/netip"
"os" "os"
"path"
"slices" "slices"
"strings" "strings"
"sync" "sync"
@@ -112,24 +111,41 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
// check is the one place where a request can be refused once its client // check is the one place where a request can be refused once its client
// is known, before its body is read or anything reaches the app. It // is known, before its body is read or anything reaches the app. It
// returns nil to let the request through. The checks of checkClient come // returns nil to let the request through. A client in SWWAF_ALLOW_NETS
// first, answered with SWWAF_BAN_RESPONSE, and then the size limit, so // skips every check but the size limit. For any other client,
// that a request the rate limits count is counted even when it is // SWWAF_DENY_NETS comes first, then a ban on its netblock, so that a
// refused for its size. In observe mode a request checkClient refuses // client either refuses is not looked up, and then the country lists; a
// goes on to the size limit like any other. ctx is the request's own // request any of them refuses is not counted for the rate limits. Then
// context. // come the rate limits, unless the client is in
// SWWAF_RATE_LIMIT_EXEMPT_NETS or the request's path, as the client sent
// it and the log line shows it, starts with one of
// SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that every other request is counted,
// one refused for its size too. Every refusal but the size limit's is
// answered with SWWAF_BAN_RESPONSE. ctx is the request's own context.
func (rq *request) check(ctx context.Context) *refusal { func (rq *request) check(ctx context.Context) *refusal {
action := rq.checkClient(ctx) cfg := rq.h.config
if action != "" { allowed := isInside(rq.client, cfg.AllowNets)
if !rq.h.config.Observe { exempt := isInside(rq.client, cfg.RateLimitExemptNets) ||
return rq.banResponse(action) startsWithAny(rq.in.URL.EscapedPath(), cfg.RateLimitExemptPaths)
} now := rq.h.now()
// The log line names what enforce mode would have done. if !allowed && isInside(rq.client, cfg.DenyNets) {
rq.line.WouldAction = action return rq.banResponse(requestlog.ActionDenied)
} }
maxBytes := rq.h.config.RequestMaxBytes if !allowed && rq.banned(now) {
return rq.banResponse(requestlog.ActionBanned)
}
if !allowed && rq.countryDenied(ctx) {
return rq.banResponse(requestlog.ActionCountryDenied)
}
if !allowed && !exempt && rq.limitBroken(now) {
return rq.banResponse(requestlog.ActionRateLimited)
}
maxBytes := cfg.RequestMaxBytes
if maxBytes > 0 && rq.in.ContentLength > maxBytes { if maxBytes > 0 && rq.in.ContentLength > maxBytes {
return &refusal{ return &refusal{
status: http.StatusRequestEntityTooLarge, status: http.StatusRequestEntityTooLarge,
@@ -141,54 +157,10 @@ func (rq *request) check(ctx context.Context) *refusal {
return nil return nil
} }
// checkClient runs the checks on the request's client, and returns the // startsWithAny reports whether path starts with one of prefixes.
// action of the first that refuses the request, or "" when none does. A func startsWithAny(path string, prefixes []string) bool {
// client in SWWAF_ALLOW_NETS skips them. For any other client,
// SWWAF_DENY_NETS comes first, then a ban on its netblock, so that a
// client either refuses is not looked up, and then the country lists; a
// request any of them refuses is not counted for the rate limits. Then
// come the rate limits, unless the client is in
// SWWAF_RATE_LIMIT_EXEMPT_NETS or the path the app will act on starts
// with one of SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that every other request
// is counted. ctx is the request's own context.
func (rq *request) checkClient(ctx context.Context) string {
cfg := rq.h.config
if isInside(rq.client, cfg.AllowNets) {
return ""
}
now := rq.h.now()
if isInside(rq.client, cfg.DenyNets) {
return requestlog.ActionDenied
}
if rq.banned(now) {
return requestlog.ActionBanned
}
if rq.countryDenied(ctx) {
return requestlog.ActionCountryDenied
}
// The prefixes are matched against the path the app will act on:
// URL.Path is the request's path percent-decoded, and path.Clean
// resolves its . and .. segments and repeated slashes, and drops a
// trailing slash. So /assets/..%2Flogin is /login, outside /assets/,
// whatever the log line's path shows.
exempt := isInside(rq.client, cfg.RateLimitExemptNets) ||
startsWithAny(path.Clean(rq.in.URL.Path), cfg.RateLimitExemptPaths)
if !exempt && rq.limitBroken(now) {
return requestlog.ActionRateLimited
}
return ""
}
// startsWithAny reports whether s starts with one of prefixes.
func startsWithAny(s string, prefixes []string) bool {
return slices.ContainsFunc(prefixes, func(prefix string) bool { return slices.ContainsFunc(prefixes, func(prefix string) bool {
return strings.HasPrefix(s, prefix) return strings.HasPrefix(path, prefix)
}) })
} }
-4
View File
@@ -67,10 +67,6 @@ type Line struct {
Referer string `json:"referer"` Referer string `json:"referer"`
UserAgent string `json:"user_agent"` UserAgent string `json:"user_agent"`
Action string `json:"action"` Action string `json:"action"`
// WouldAction is, in observe mode, the action enforce mode would have
// taken with a request it would have refused: ActionDenied,
// ActionBanned, ActionCountryDenied or ActionRateLimited.
WouldAction string `json:"would_action,omitempty"`
// LimitHit is the window whose rate limit the request went over: // LimitHit is the window whose rate limit the request went over:
// minute, hour or day. // minute, hour or day.
LimitHit string `json:"limit_hit,omitempty"` LimitHit string `json:"limit_hit,omitempty"`
-1
View File
@@ -383,7 +383,6 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) {
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: appURL, upstreamURL: appURL,
stateDir: dir, stateDir: dir,
"SWWAF_MODE": "enforce",
"SWWAF_STATE_WRITE_DELAY": "10s", "SWWAF_STATE_WRITE_DELAY": "10s",
"SWWAF_STATE_COUNTER_INTERVAL": "15m", "SWWAF_STATE_COUNTER_INTERVAL": "15m",
"SWWAF_TRUSTED_PROXIES": "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16", "SWWAF_TRUSTED_PROXIES": "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",