1 Commits
Author SHA1 Message Date
sneak 35054fe94b Default request limits per hour and per day, sized for API use (closes #176)
check / check (push) Failing after 1m45s
The default request limits now suit programmatic use of an API, such
as an automation fleet on another host, and still catch crawlers:

- per minute: off;
- per hour: 30000, passed by a crawler that keeps up 10 a second;
- per day: 300000, passed by one that keeps up 4 a second.

The byte limits and ban lengths are unchanged. SPEC.md, README.md and
docs/settings.md give the reasons and what counting cannot catch;
docs/gitea.md redoes the Actions runner sums.

Judgement call: the ban alert example in docs/alerts.md now shows the
hour limit, since the minute limit is off by default.

Model: opus-5-5
2026-11-04 19:50:41 +00:00
8 changed files with 58 additions and 66 deletions

No files matched your search

+9 -11
View File
@@ -17,17 +17,15 @@ 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 300 requests a minute, 30,000 an hour and 300,000 a day. That leaves client 30,000 requests an hour and 300,000 a day, with no limit per minute.
room for programmatic use of an API, such as an automation fleet on another That leaves room for programmatic use of an API, such as an automation fleet
host. The minute limit catches, within seconds, a client firing requests as on another host. The hour limit catches a crawler that keeps up 10 requests a
fast as it can. With the minute limit raised or off, the hour limit catches second. The day limit catches one that keeps up 4 a second around the clock.
one that keeps up 10 a second. The day limit catches one that keeps up 4 a Counting cannot catch a client slower than about 3.5 requests a second; only
second around the clock. Counting cannot catch a client that stays under all the protections below can. Byte limits give each client 4 GiB a minute, 20 GiB
three limits, such as one that keeps up 3 a second; only the protections below an hour and 200 GiB a day. A client over a limit is refused and banned. The
can. Byte limits give each client 4 GiB a minute, 20 GiB an hour and 200 GiB a first ban lasts an hour. Each repeat within a day makes the next ban three
day. A client over a limit is refused and banned. The first ban lasts an hour. times as long. A ban that would last more than seven days is permanent.
Each repeat within a day makes the next ban three times as long. A ban that
would last more than 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
next request during the ban makes the ban permanent. The default rule file next request during the ban makes the ban permanent. The default rule file
+19 -24
View File
@@ -15,8 +15,9 @@ to them.
all. Every setting has a default chosen for a service facing the internet all. Every setting has a default chosen for a service facing the internet
in 2026. A setting that needs an alert destination, an account key or a token in 2026. A setting that needs an alert destination, an account key or a token
stays off until the operator supplies it. stays off until the operator supplies it.
- It limits each client's requests and bytes per minute, hour and day. It bounds - It limits each client's requests per hour and day, and its bytes per minute,
the size and duration of every request and answer. It detects attacks. hour and day. It bounds the size and duration of every request and answer. It
detects attacks.
- It bans abusers: briefly at first, for seven days on a clear sign of attack, - It bans abusers: briefly at first, for seven days on a clear sign of attack,
and permanently when they keep at it. and permanently when they keep at it.
- It looks up each client's AS number and country. It can refuse whole countries - It looks up each client's AS number and country. It can refuse whole countries
@@ -376,31 +377,25 @@ Conventions:
### Request rate limits, per client ### Request rate limits, per client
Breaking one bans the client. The defaults suit programmatic use of an API, such Breaking one bans the client. The defaults suit programmatic use of an API, such
as an automation fleet on another host, and still stop abusive bots. A fleet of as an automation fleet on another host, and still stop abusive bots. They count
20 agents, each making a request every 10 seconds at its busiest, makes 120 per hour and per day, so a short burst of requests breaks no limit on its own.
requests a minute and 7,200 an hour. Working around the clock, it makes 172,800
a day. It stays under all three limits.
- Per minute: 300, 5 requests a second. A client firing requests as fast as it - Per minute: `off`.
can passes it within seconds. It leaves room for the fleet and a few quick - Per hour: 30,000, about 8 requests a second for a whole hour. A crawler that
first visits with an empty cache. A Gitea page opened with an empty cache keeps up 10 a second makes 36,000 an hour, and passes it.
loads about 40 files.
- Per hour: 30,000, about 8 requests a second for a whole hour. At the default
minute limit no client reaches it, since 300 a minute is at most 18,000 an
hour. With the minute limit raised or off, a crawler that keeps up 10 a second
makes 36,000 an hour, and passes it.
- Per day: 300,000, about 3.5 requests a second around the clock. A crawler that - Per day: 300,000, about 3.5 requests a second around the clock. A crawler that
keeps up 4 a second makes 345,600 a day, and passes it. At 240 a minute and keeps up 4 a second makes 345,600 a day, and passes it.
14,400 an hour, it stays under the other two limits.
Counting cannot catch a client that stays under all three limits, such as one A fleet of 20 agents, each making a request every 10 seconds, makes 7,200
that keeps up 3 requests a second. Only the other checks can catch an abuser requests an hour. Working around the clock, it makes 172,800 a day. Both stay
that slow. They include the trap paths, the rule files, the Core Rule Set and under the limits.
the blocklists. An abuser that trips none of them is not caught. A machine that
keeps up more than 5 requests a second passes the minute limit. One that keeps Counting cannot catch a client slower than these limits. Only the other checks
up more than about 3.5 a second all day passes the day limit, as two busy can catch an abuser that slow. They include the trap paths, the rule files, the
Actions runners on one address do. Such a machine's address belongs in Core Rule Set and the blocklists. An abuser that trips none of them is not
`SWWAF_RATE_LIMIT_EXEMPT_NETS`. caught. A machine that keeps up more than about 3.5 requests a second all day
passes the day limit, as two busy Actions runners on one address do. 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
+10 -10
View File
@@ -35,34 +35,34 @@ on one line:
```json ```json
{ {
"instance": "fsn1app1/gitea", "instance": "fsn1app1/gitea",
"time": "2026-10-06T12:00:30.123461Z", "time": "2026-10-06T12:50:00.123461Z",
"event": "ban", "event": "ban",
"client": "203.0.113.9", "client": "203.0.113.9",
"netblock": "203.0.113.9/32", "netblock": "203.0.113.9/32",
"asn": "", "asn": "",
"as_name": "", "as_name": "",
"country": "", "country": "",
"reason": "requests per minute over the limit of 300", "reason": "requests per hour over the limit of 30000",
"detail": { "detail": {
"ban_expires": "2026-10-06T13:00:30.123Z", "ban_expires": "2026-10-06T13:50:00.123Z",
"cause": "limit", "cause": "limit",
"notes": { "notes": {
"asn": "", "asn": "",
"as_name": "", "as_name": "",
"country": "", "country": "",
"kind": "requests", "kind": "requests",
"limit": 300, "limit": 30000,
"window": "minute", "window": "hour",
"count": 301, "count": 30001,
"request": { "request": {
"time": "2026-10-06T12:00:30.123456789Z", "time": "2026-10-06T12:50:00.123456789Z",
"method": "GET", "method": "GET",
"host": "app.example", "host": "app.example",
"path": "/owner/repo/commits/branch/main?page=301", "path": "/owner/repo/commits/branch/main?page=812",
"status": 403, "status": 403,
"user_agent": "scraper/1.0" "user_agent": "scraper/1.0"
}, },
"requests": 301, "requests": 30001,
"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 300` or `listed by a blocklist`. `requests per hour over the limit of 30000` 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));
+5 -6
View File
@@ -34,12 +34,11 @@ checked again when gitea or the Core Rule Set changes version.
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. While it runs a job, it reports the job's log and state
every second instead, two requests a second. So one runner busy all day sends every second instead, two requests a second. So one runner busy all day sends
120 requests a minute and 172,800 a day, under the request limits. Two such 172,800 requests, under the request limits. Two such runners on one address,
runners on one address, each busy all day, make 345,600 and pass the day limit each busy all day, make 345,600 and pass the day limit of 300,000; one stays
of 300,000. Three busy at once make 360 a minute and pass the minute limit under it. Their address is then banned, their running jobs fail, and each
of 300. Their address is then banned, their running jobs fail, and each repeat repeat within a day makes the ban longer. Newer runners ask for work less
within a day makes the ban longer. Newer runners ask for work less often. Put often. Put the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
## What the Core Rule Set lets through ## What the Core Rule Set lets through
+7 -9
View File
@@ -99,20 +99,18 @@ 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 `300`, 5 a second): the most requests a - `SWWAF_RATE_LIMIT_PER_MINUTE` (default `off`): the most requests a client may
client may make in a minute. A count or `off`. The default catches, within make in a minute. A count or `off`. It is off by default so that a short burst
seconds, a client firing requests as fast as it can. It leaves room for an of API requests breaks no limit on its own.
automation fleet's busiest minute and a few quick first visits with an empty
cache.
- `SWWAF_RATE_LIMIT_PER_HOUR` (default `30000`, about 8 a second): the most - `SWWAF_RATE_LIMIT_PER_HOUR` (default `30000`, about 8 a second): the most
requests a client may make in an hour. A count or `off`. The default leaves requests a client may make in an hour. A count or `off`. The default leaves
room for an automation fleet at work. With the minute limit raised or off, it room for an automation fleet at work, and catches a crawler that keeps up 10 a
catches a crawler that keeps up 10 a second. second.
- `SWWAF_RATE_LIMIT_PER_DAY` (default `300000`, about 3.5 a second): the most - `SWWAF_RATE_LIMIT_PER_DAY` (default `300000`, about 3.5 a second): the most
requests a client may make in a day. A count or `off`. The default catches a requests a client may make in a day. A count or `off`. The default catches a
crawler that keeps up 4 a second around the clock. crawler that keeps up 4 a second around the clock.
- Counting cannot catch a client that stays under all three limits (see "Request - Counting cannot catch a client slower than these limits (see "Request rate
rate limits, per client" in [`SPEC.md`](../SPEC.md)). limits, per client" in [`SPEC.md`](../SPEC.md)).
- `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/`.
+1 -1
View File
@@ -506,7 +506,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
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", "300"), RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", off),
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "30000"), RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "30000"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "300000"), RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "300000"),
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""), RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
+2 -2
View File
@@ -262,7 +262,7 @@ func TestDefaults(t *testing.T) {
UpstreamResponseTimeout: 30 * time.Minute, UpstreamResponseTimeout: 30 * time.Minute,
RequestMaxBytes: 100 << 20, RequestMaxBytes: 100 << 20,
ResponseMaxBytes: 1 << 30, ResponseMaxBytes: 1 << 30,
RateLimitPerMinute: 300, RateLimitPerMinute: 0,
RateLimitPerHour: 30000, RateLimitPerHour: 30000,
RateLimitPerDay: 300000, RateLimitPerDay: 300000,
BanResponse: 403, BanResponse: 403,
@@ -2375,7 +2375,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
allowNets: "", allowNets: "",
rateLimitExemptNets: "", rateLimitExemptNets: "",
denyNets: "", denyNets: "",
rateLimitPerMinute: "300", rateLimitPerMinute: off,
rateLimitPerHour: "30000", rateLimitPerHour: "30000",
rateLimitPerDay: "300000", rateLimitPerDay: "300000",
rateLimitExemptPaths: "", rateLimitExemptPaths: "",
+5 -3
View File
@@ -54,6 +54,8 @@ const (
greeting = "hello from the app" greeting = "hello from the app"
// placed is the client the tests' lookup databases place. // placed is the client the tests' lookup databases place.
placed = "203.0.113.9" placed = "203.0.113.9"
// off is the value that switches a setting off.
off = "off"
) )
// output collects what smallwebwaf writes on stdout. // output collects what smallwebwaf writes on stdout.
@@ -118,7 +120,7 @@ func run(ctx context.Context, env map[string]string, out *output) int {
LookupEnv: func(name string) (string, bool) { LookupEnv: func(name string) (string, bool) {
value, ok := env[name] value, ok := env[name]
if !ok && name == "SWWAF_LOOKUP_SOURCE" { if !ok && name == "SWWAF_LOOKUP_SOURCE" {
return "off", true return off, true
} }
return value, ok return value, ok
@@ -502,7 +504,7 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
rulesDir: dir, rulesDir: dir,
// The requests sent until the rule takes effect must not break a // The requests sent until the rule takes effect must not break a
// rate limit, whose ban would refuse them too. // rate limit, whose ban would refuse them too.
"SWWAF_RATE_LIMIT_PER_MINUTE": "off", "SWWAF_RATE_LIMIT_PER_MINUTE": off,
} }
out := runUntilStopped(t, env, func(url string) { out := runUntilStopped(t, env, func(url string) {
@@ -1244,7 +1246,7 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) {
"SWWAF_ALLOW_NETS": "", "SWWAF_ALLOW_NETS": "",
rateLimitExemptNets: "", rateLimitExemptNets: "",
"SWWAF_DENY_NETS": "", "SWWAF_DENY_NETS": "",
"SWWAF_RATE_LIMIT_PER_MINUTE": "300", "SWWAF_RATE_LIMIT_PER_MINUTE": off,
"SWWAF_RATE_LIMIT_PER_HOUR": "30000", "SWWAF_RATE_LIMIT_PER_HOUR": "30000",
rateLimitPerDay: "300000", rateLimitPerDay: "300000",
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "", "SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",