1 Commits
Author SHA1 Message Date
sneak 1bdf66cd21 Merge pull request 'next into main' (#160) from next into main
check / check (push) Canceled after 0s
Reviewed-on: #160
2026-10-26 13:11:04 +01:00
9 changed files with 61 additions and 81 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 6000 requests a minute, 100,000 an hour and 1,000,000 a day. Byte client 1000 requests a minute, 10,000 an hour and 50,000 a day. Byte limits
limits give it 4 GiB a minute, 20 GiB an hour and 200 GiB a day. A client over give it 10 GiB a minute, 20 GiB an hour and 50 GiB a day. A client over a
a limit is refused and banned. The first ban lasts an hour. Each repeat within limit is refused and banned. The first ban lasts an hour. Each repeat within a
a day makes the next ban three times as long. A ban that would last more than 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
+10 -20
View File
@@ -368,23 +368,16 @@ 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 people can share one address, as in an office, on a mobile carrier or - Many clients share one address (mobile carriers, offices, Tor), and so share
through Tor. They are one client, and share its limits and its bans. The its limits and its bans. `SWWAF_ALLOW_NETS` and `SWWAF_RATE_LIMIT_EXEMPT_NETS`
default limits are sized for about 40 busy people on one address. A known take known shared addresses out.
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 about twice above what 40 Breaking one bans the client, so the defaults sit several times above what one
people busy with heavy web apps make together on one address. Each such person busy person produces. A browser loading a heavy page makes a few hundred
makes about 75 requests in a busy minute, and 1,250 an hour for ten hours a day. requests, and a git clone a handful. A machine that talks to the app all day can
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
@@ -397,12 +390,9 @@ matched the same way.
### Byte limits, per client ### Byte limits, per client
Each of the same 40 people moves about 256 MiB an hour, for ten hours a day. The A response's bytes are counted when it ends (see step 15). So every default sits
hour and day limits sit about twice above what they move together. A response's above the largest request and answer together, and no single download breaks a
bytes are counted when it ends (see step 15). So the minute limit holds two of limit.
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 6000", "reason": "requests per minute over the limit of 1000",
"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": 6000, "limit": 1000,
"window": "minute", "window": "minute",
"count": 6001, "count": 1001,
"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": 8210, "requests": 5210,
"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 6000` or `listed by a blocklist`. `requests per minute over the limit of 1000` 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));
+8 -11
View File
@@ -11,10 +11,8 @@ 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 1 GiB unless the - A clone is a response. It fits the defaults of 30 minutes and 5 GiB for all
repository is large or the link slow. A repository whose clone is larger than but the largest repositories and slowest links.
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;
@@ -32,13 +30,12 @@ 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. While it runs a job, it reports the job's log and state 43,200 requests a day, and reports a running job's log and state every second.
every second instead, two requests a second. So one runner busy all day sends So it passes the day limit of 50,000 after about an hour and a quarter of
172,800 requests, under the request limits. Six such runners on one address, jobs. It is then banned, the job it is running fails, and each repeat within a
each busy all day, pass the day limit of 1,000,000 within two days; five stay day makes the ban longer. Newer runners ask less often, but a busy one, or
under it. Their address is then banned, their running jobs fail, and each several on one address, can still pass it. Put the runners' addresses in
repeat within a day makes the ban longer. Newer runners ask for work less `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
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 `6000`), `SWWAF_RATE_LIMIT_PER_HOUR` - `SWWAF_RATE_LIMIT_PER_MINUTE` (default `1000`), `SWWAF_RATE_LIMIT_PER_HOUR`
(default `100000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `1000000`): the (default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`): the most
most requests a client may make in each window. A count or `off`. 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 `4G`), `SWWAF_BYTES_LIMIT_PER_HOUR` - `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `10G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `200G`): the most (default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `50G`): 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 `1G`): the largest response body. A size - `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): 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", "1G"), ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "5G"),
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", "6000"), RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "1000"),
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "100000"), RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "1000000"), RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"),
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""), RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "4G"), BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "10G"),
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", "200G"), BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "50G"),
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: 1 << 30, ResponseMaxBytes: 5 << 30,
RateLimitPerMinute: 6000, RateLimitPerMinute: 1000,
RateLimitPerHour: 100000, RateLimitPerHour: 10000,
RateLimitPerDay: 1000000, RateLimitPerDay: 50000,
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: 4 << 30, BytesLimitPerHour: 20 << 30, BytesLimitPerMinute: 10 << 30, BytesLimitPerHour: 20 << 30,
BytesLimitPerDay: 200 << 30, BytesCount: "both", BytesLimitPerDay: 50 << 30, BytesCount: "both",
}) })
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" { if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
@@ -2371,17 +2371,17 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
upstreamRequestTimeout: "60s", upstreamRequestTimeout: "60s",
upstreamResponseTimeout: "30m", upstreamResponseTimeout: "30m",
requestMaxBytes: "100M", requestMaxBytes: "100M",
responseMaxBytes: "1G", responseMaxBytes: "5G",
allowNets: "", allowNets: "",
rateLimitExemptNets: "", rateLimitExemptNets: "",
denyNets: "", denyNets: "",
rateLimitPerMinute: "6000", rateLimitPerMinute: "1000",
rateLimitPerHour: "100000", rateLimitPerHour: "10000",
rateLimitPerDay: "1000000", rateLimitPerDay: "50000",
rateLimitExemptPaths: "", rateLimitExemptPaths: "",
bytesLimitPerMinute: "4G", bytesLimitPerMinute: "10G",
bytesLimitPerHour: "20G", bytesLimitPerHour: "20G",
bytesLimitPerDay: "200G", bytesLimitPerDay: "50G",
bytesCount: "both", bytesCount: "both",
lookupSource: defaultLookupSource, lookupSource: defaultLookupSource,
lookupDBPath: "", lookupDBPath: "",
+6 -13
View File
@@ -60,19 +60,8 @@ func TestHealthCheck(t *testing.T) {
}, 0, "") }, 0, "")
} }
// Another test can take a closed server's port at once, so the app's app.Close()
// address here is a multicast address, to which TCP cannot connect. wantHealthCheck(t, env, 1, "unhealthy: connect to the app: ")
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()
@@ -81,6 +70,10 @@ 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": "1G", "SWWAF_RESPONSE_MAX_BYTES": "5G",
"SWWAF_ALLOW_NETS": "", "SWWAF_ALLOW_NETS": "",
rateLimitExemptNets: "", rateLimitExemptNets: "",
"SWWAF_DENY_NETS": "", "SWWAF_DENY_NETS": "",
"SWWAF_RATE_LIMIT_PER_MINUTE": "6000", "SWWAF_RATE_LIMIT_PER_MINUTE": "1000",
"SWWAF_RATE_LIMIT_PER_HOUR": "100000", "SWWAF_RATE_LIMIT_PER_HOUR": "10000",
rateLimitPerDay: "1000000", rateLimitPerDay: "50000",
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "", "SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
"SWWAF_DENIED_COUNTRIES": "", "SWWAF_DENIED_COUNTRIES": "",
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "", "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",