2 Commits
Author SHA1 Message Date
sneak 85afeede84 Health check test uses addresses nothing can connect to (closes #158)
check / check (push) Waiting to run
TestHealthCheck closed the app's server, and later smallwebwaf, and
expected connections to the freed ports to fail. Another test could take
such a port in between, so the connection succeeded and the test failed.

The failing app check now uses the multicast address 224.0.0.1, to which
TCP cannot connect. The failing smallwebwaf check uses port 0, on which
nothing listens. Neither result depends on other processes. The test now
stops smallwebwaf after its last check, so that it exits before the
test's temporary directories are removed. The code under test is
unchanged.

Model: opus-5-5
2026-10-27 19:01:18 +00:00
clawbot a9dc59546b New defaults for the request and byte limits (closes #161)
check / check (push) Waiting to run
The request limits and the hour and day byte limits now sit about twice
above what 40 people busy with heavy web apps make together on one
address:

- requests: 6000 a minute, 100,000 an hour, 1,000,000 a day;
- bytes: 20 GiB an hour, 200 GiB a day.

The largest response drops from 5 GiB to 1 GiB. The minute byte limit
of 4 GiB is sized to hold two of the largest responses with their
requests. SPEC.md gives the new reasoning; README.md and the reference
documents give the new figures.

Model: opus-5-5
2026-10-27 19:28:09 +01:00
9 changed files with 81 additions and 61 deletions
+4 -4
View File
@@ -17,10 +17,10 @@ reference documents under [`docs/`](docs/) are listed under "Documents" below.
## What it protects against ## What it protects against
- Scrapers that fetch every page of a site, all day. Rate limiting gives each - Scrapers that fetch every page of a site, all day. Rate limiting gives each
client 1000 requests a minute, 10,000 an hour and 50,000 a day. Byte limits client 6000 requests a minute, 100,000 an hour and 1,000,000 a day. Byte
give it 10 GiB a minute, 20 GiB an hour and 50 GiB a day. A client over a limits give it 4 GiB a minute, 20 GiB an hour and 200 GiB a day. A client over
limit is refused and banned. The first ban lasts an hour. Each repeat within a a limit is refused and banned. The first ban lasts an hour. Each repeat within
day makes the next ban three times as long. A ban that would last more than a day makes the next ban three times as long. A ban that would last more than
seven days is permanent. seven days is permanent.
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The - Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
default rule file bans such a client for seven days at its first probe. Its default rule file bans such a client for seven days at its first probe. Its
+20 -10
View File
@@ -368,16 +368,23 @@ Conventions:
- `SWWAF_ALLOW_NETS` skips the checks step 3 names, for monitoring or the - `SWWAF_ALLOW_NETS` skips the checks step 3 names, for monitoring or the
owner's own networks. `SWWAF_RATE_LIMIT_EXEMPT_NETS` bypasses the request and owner's own networks. `SWWAF_RATE_LIMIT_EXEMPT_NETS` bypasses the request and
byte limits only; the error burst, attack detection and bans still apply. byte limits only; the error burst, attack detection and bans still apply.
- Many clients share one address (mobile carriers, offices, Tor), and so share - Many people can share one address, as in an office, on a mobile carrier or
its limits and its bans. `SWWAF_ALLOW_NETS` and `SWWAF_RATE_LIMIT_EXEMPT_NETS` through Tor. They are one client, and share its limits and its bans. The
take known shared addresses out. default limits are sized for about 40 busy people on one address. A known
shared address busier than that belongs in `SWWAF_ALLOW_NETS` or
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
### Request rate limits, per client ### Request rate limits, per client
Breaking one bans the client, so the defaults sit several times above what one Breaking one bans the client, so the defaults sit about twice above what 40
busy person produces. A browser loading a heavy page makes a few hundred people busy with heavy web apps make together on one address. Each such person
requests, and a git clone a handful. A machine that talks to the app all day can makes about 75 requests in a busy minute, and 1,250 an hour for ten hours a day.
still pass the day limit. Its address belongs in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
A client slower than those 40 people cannot be told from them by counting. Only
the other checks can catch an abuser that slow. They include the trap paths, the
rule files, the Core Rule Set and the blocklists. An abuser that trips none of
them is not caught. A machine that talks to the app all day can still pass the
day limit. Its address belongs in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` takes path prefixes out of the count, such as `SWWAF_RATE_LIMIT_EXEMPT_PATHS` takes path prefixes out of the count, such as
static assets or health checks; their bytes are not counted either. A prefix is static assets or health checks; their bytes are not counted either. A prefix is
@@ -390,9 +397,12 @@ matched the same way.
### Byte limits, per client ### Byte limits, per client
A response's bytes are counted when it ends (see step 15). So every default sits Each of the same 40 people moves about 256 MiB an hour, for ten hours a day. The
above the largest request and answer together, and no single download breaks a hour and day limits sit about twice above what they move together. A response's
limit. bytes are counted when it ends (see step 15). So the minute limit holds two of
the largest responses, with their requests, ending in the same minute. No single
download then breaks a limit. An operator who raises `SWWAF_RESPONSE_MAX_BYTES`
raises the byte limits with it.
### Size and time limits ### Size and time limits
+5 -5
View File
@@ -42,7 +42,7 @@ on one line:
"asn": "", "asn": "",
"as_name": "", "as_name": "",
"country": "", "country": "",
"reason": "requests per minute over the limit of 1000", "reason": "requests per minute over the limit of 6000",
"detail": { "detail": {
"ban_expires": "2026-10-06T13:00:00.123Z", "ban_expires": "2026-10-06T13:00:00.123Z",
"cause": "limit", "cause": "limit",
@@ -51,9 +51,9 @@ on one line:
"as_name": "", "as_name": "",
"country": "", "country": "",
"kind": "requests", "kind": "requests",
"limit": 1000, "limit": 6000,
"window": "minute", "window": "minute",
"count": 1001, "count": 6001,
"request": { "request": {
"time": "2026-10-06T12:00:00.123456789Z", "time": "2026-10-06T12:00:00.123456789Z",
"method": "GET", "method": "GET",
@@ -62,7 +62,7 @@ on one line:
"status": 403, "status": 403,
"user_agent": "scraper/1.0" "user_agent": "scraper/1.0"
}, },
"requests": 5210, "requests": 8210,
"refused": 0, "refused": 0,
"earlier_bans": { "earlier_bans": {
"limit": 0, "limit": 0,
@@ -89,7 +89,7 @@ on one line:
- `asn`, `as_name` and `country`: the client's, as known when the alert was - `asn`, `as_name` and `country`: the client's, as known when the alert was
raised. They are empty when GeoJS had not answered yet. raised. They are empty when GeoJS had not answered yet.
- `reason`: a short sentence, such as - `reason`: a short sentence, such as
`requests per minute over the limit of 1000` or `listed by a blocklist`. `requests per minute over the limit of 6000` or `listed by a blocklist`.
- `detail`: what is particular to the event: - `detail`: what is particular to the event:
- for a ban, its `cause`, `ban_expires` and `notes` (the notes are listed - for a ban, its `cause`, `ban_expires` and `notes` (the notes are listed
under `bans.json` in [`state-files.md`](state-files.md)); under `bans.json` in [`state-files.md`](state-files.md));
+11 -8
View File
@@ -11,8 +11,10 @@ checked again when gitea or the Core Rule Set changes version.
- SSH access to gitea does not pass through `smallwebwaf` and is not protected - SSH access to gitea does not pass through `smallwebwaf` and is not protected
by it. by it.
- A clone is a response. It fits the defaults of 30 minutes and 5 GiB for all - A clone is a response. It fits the defaults of 30 minutes and 1 GiB unless the
but the largest repositories and slowest links. repository is large or the link slow. A repository whose clone is larger than
1 GiB needs `SWWAF_RESPONSE_MAX_BYTES` raised, and the byte limits with it
("Byte limits, per client" in `SPEC.md`).
- A push is a request, and so is every other upload: - A push is a request, and so is every other upload:
- a git push; - a git push;
- an LFS object; - an LFS object;
@@ -30,12 +32,13 @@ checked again when gitea or the Core Rule Set changes version.
request limits do most of the work there. request limits do most of the work there.
- An Actions runner that reaches gitea through `smallwebwaf` sends requests all - An Actions runner that reaches gitea through `smallwebwaf` sends requests all
day. One older than version 0.4 (April 2026) asks for work every 2 seconds, day. One older than version 0.4 (April 2026) asks for work every 2 seconds,
43,200 requests a day, and reports a running job's log and state every second. 43,200 requests a day. While it runs a job, it reports the job's log and state
So it passes the day limit of 50,000 after about an hour and a quarter of every second instead, two requests a second. So one runner busy all day sends
jobs. It is then banned, the job it is running fails, and each repeat within a 172,800 requests, under the request limits. Six such runners on one address,
day makes the ban longer. Newer runners ask less often, but a busy one, or each busy all day, pass the day limit of 1,000,000 within two days; five stay
several on one address, can still pass it. Put the runners' addresses in under it. Their address is then banned, their running jobs fail, and each
`SWWAF_RATE_LIMIT_EXEMPT_NETS`. repeat within a day makes the ban longer. Newer runners ask for work less
often. Put the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
## What the Core Rule Set lets through ## What the Core Rule Set lets through
+6 -6
View File
@@ -99,16 +99,16 @@ that take `off`, the `_FILE` form, and what each group does and why.
## Request rate limits, per client ## Request rate limits, per client
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `1000`), `SWWAF_RATE_LIMIT_PER_HOUR` - `SWWAF_RATE_LIMIT_PER_MINUTE` (default `6000`), `SWWAF_RATE_LIMIT_PER_HOUR`
(default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`): the most (default `100000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `1000000`): the
requests a client may make in each window. A count or `off`. most requests a client may make in each window. A count or `off`.
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from - `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
rate limiting and the byte limits, such as `/assets/`. rate limiting and the byte limits, such as `/assets/`.
## Byte limits, per client ## Byte limits, per client
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `10G`), `SWWAF_BYTES_LIMIT_PER_HOUR` - `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `4G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `50G`): the most (default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `200G`): the most
bytes counted for a client in each window. A size or `off`. bytes counted for a client in each window. A size or `off`.
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count: - `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
`response`, `request` or `both`. `response`, `request` or `both`.
@@ -131,7 +131,7 @@ that take `off`, the `_FILE` form, and what each group does and why.
to send its whole answer. A duration or `off`. to send its whole answer. A duration or `off`.
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body. A size - `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body. A size
or `off`. or `off`.
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body. A size - `SWWAF_RESPONSE_MAX_BYTES` (default `1G`): the largest response body. A size
or `off`. or `off`.
## Lookup of AS number and country ## Lookup of AS number and country
+6 -6
View File
@@ -502,17 +502,17 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
UpstreamRequestTimeout: env.duration("SWWAF_UPSTREAM_REQUEST_TIMEOUT", "60s"), UpstreamRequestTimeout: env.duration("SWWAF_UPSTREAM_REQUEST_TIMEOUT", "60s"),
UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"), UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"),
RequestMaxBytes: env.size("SWWAF_REQUEST_MAX_BYTES", "100M"), RequestMaxBytes: env.size("SWWAF_REQUEST_MAX_BYTES", "100M"),
ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "5G"), ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "1G"),
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""), AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""), RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""),
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""), DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "1000"), RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "6000"),
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"), RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "100000"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"), RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "1000000"),
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"), BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "4G"),
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"), BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "50G"), BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "200G"),
BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"), 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", ""),
+12 -12
View File
@@ -261,10 +261,10 @@ func TestDefaults(t *testing.T) {
UpstreamRequestTimeout: time.Minute, UpstreamRequestTimeout: time.Minute,
UpstreamResponseTimeout: 30 * time.Minute, UpstreamResponseTimeout: 30 * time.Minute,
RequestMaxBytes: 100 << 20, RequestMaxBytes: 100 << 20,
ResponseMaxBytes: 5 << 30, ResponseMaxBytes: 1 << 30,
RateLimitPerMinute: 1000, RateLimitPerMinute: 6000,
RateLimitPerHour: 10000, RateLimitPerHour: 100000,
RateLimitPerDay: 50000, RateLimitPerDay: 1000000,
BanResponse: 403, BanResponse: 403,
LimitBanDuration: time.Hour, LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour, LimitBanRepeatWindow: 24 * time.Hour,
@@ -284,8 +284,8 @@ func TestDefaults(t *testing.T) {
LookupSource: defaultLookupSource, LookupTimeout: time.Second, LookupSource: defaultLookupSource, LookupTimeout: time.Second,
}) })
wantByteLimitSettings(t, cfg, config.Config{ wantByteLimitSettings(t, cfg, config.Config{
BytesLimitPerMinute: 10 << 30, BytesLimitPerHour: 20 << 30, BytesLimitPerMinute: 4 << 30, BytesLimitPerHour: 20 << 30,
BytesLimitPerDay: 50 << 30, BytesCount: "both", BytesLimitPerDay: 200 << 30, BytesCount: "both",
}) })
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" { if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
@@ -2371,17 +2371,17 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
upstreamRequestTimeout: "60s", upstreamRequestTimeout: "60s",
upstreamResponseTimeout: "30m", upstreamResponseTimeout: "30m",
requestMaxBytes: "100M", requestMaxBytes: "100M",
responseMaxBytes: "5G", responseMaxBytes: "1G",
allowNets: "", allowNets: "",
rateLimitExemptNets: "", rateLimitExemptNets: "",
denyNets: "", denyNets: "",
rateLimitPerMinute: "1000", rateLimitPerMinute: "6000",
rateLimitPerHour: "10000", rateLimitPerHour: "100000",
rateLimitPerDay: "50000", rateLimitPerDay: "1000000",
rateLimitExemptPaths: "", rateLimitExemptPaths: "",
bytesLimitPerMinute: "10G", bytesLimitPerMinute: "4G",
bytesLimitPerHour: "20G", bytesLimitPerHour: "20G",
bytesLimitPerDay: "50G", bytesLimitPerDay: "200G",
bytesCount: "both", bytesCount: "both",
lookupSource: defaultLookupSource, lookupSource: defaultLookupSource,
lookupDBPath: "", lookupDBPath: "",
+13 -6
View File
@@ -60,8 +60,19 @@ func TestHealthCheck(t *testing.T) {
}, 0, "") }, 0, "")
} }
app.Close() // Another test can take a closed server's port at once, so the app's
wantHealthCheck(t, env, 1, "unhealthy: connect to the app: ") // address here is a multicast address, to which TCP cannot connect.
wantHealthCheck(t, map[string]string{
listenAddr: ":" + port,
upstreamURL: "http://224.0.0.1",
}, 1, "unhealthy: connect to the app: ")
// Listening on port 0 takes any free port, so nothing listens on port 0
// itself.
wantHealthCheck(t, map[string]string{listenAddr: ":0"}, 1,
"unhealthy: ask smallwebwaf: ")
wantHealthCheck(t, map[string]string{listenAddr: "8080"}, 1,
"unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ")
stop() stop()
@@ -70,10 +81,6 @@ func TestHealthCheck(t *testing.T) {
case <-time.After(waitLimit): case <-time.After(waitLimit):
t.Fatal("still running after being told to stop") t.Fatal("still running after being told to stop")
} }
wantHealthCheck(t, env, 1, "unhealthy: ask smallwebwaf: ")
wantHealthCheck(t, map[string]string{listenAddr: "8080"}, 1,
"unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ")
} }
func TestHealthCheckRefusesAnArgument(t *testing.T) { func TestHealthCheckRefusesAnArgument(t *testing.T) {
+4 -4
View File
@@ -1240,13 +1240,13 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) {
"SWWAF_UPSTREAM_REQUEST_TIMEOUT": "60s", "SWWAF_UPSTREAM_REQUEST_TIMEOUT": "60s",
"SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m", "SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m",
"SWWAF_REQUEST_MAX_BYTES": "100M", "SWWAF_REQUEST_MAX_BYTES": "100M",
"SWWAF_RESPONSE_MAX_BYTES": "5G", "SWWAF_RESPONSE_MAX_BYTES": "1G",
"SWWAF_ALLOW_NETS": "", "SWWAF_ALLOW_NETS": "",
rateLimitExemptNets: "", rateLimitExemptNets: "",
"SWWAF_DENY_NETS": "", "SWWAF_DENY_NETS": "",
"SWWAF_RATE_LIMIT_PER_MINUTE": "1000", "SWWAF_RATE_LIMIT_PER_MINUTE": "6000",
"SWWAF_RATE_LIMIT_PER_HOUR": "10000", "SWWAF_RATE_LIMIT_PER_HOUR": "100000",
rateLimitPerDay: "50000", rateLimitPerDay: "1000000",
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "", "SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
"SWWAF_DENIED_COUNTRIES": "", "SWWAF_DENIED_COUNTRIES": "",
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "", "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",