5 Commits
Author SHA1 Message Date
clawbot fdabf4974b Health check test uses addresses nothing can connect to (closes #158)
check / check (push) Canceled after 0s
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 21:00:16 +01:00
clawbot a9dc59546b New defaults for the request and byte limits (closes #161)
check / check (push) Canceled after 0s
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
sneak 2ac2b671f6 Merge branch 'main' into next
check / check (push) Canceled after 0s
2026-10-26 13:10:59 +01:00
clawbot f408f42b28 SPEC, README and docs rewritten from scratch (closes #154, closes #155, closes #156)
check / check (push) Canceled after 0s
SPEC.md is the only place that says how smallwebwaf behaves and why; docs/ holds lists linking to it; the README is short and links to both. Passed independent review in round 8.

Model: fable-5-1 (writing); opus-5-5 (review, merge)
2026-10-26 03:06:28 +01:00
sneak acceecc7f8 Merge pull request 'Milestones 1 and 2: proxy with timeouts and size limits, rate limits, country lists and the image' (#40) from next into main
check / check (push) Canceled after 0s
Reviewed-on: #40
2026-10-22 19:51:30 +02:00
10 changed files with 524 additions and 2550 deletions

No files matched your search

+296 -2347
View File
File diff suppressed because it is too large. Load diff
+150 -143
View File
@@ -100,10 +100,12 @@ to them.
## What happens to a request ## What happens to a request
The steps run in this order. The first step that produces a final answer ends The steps run in this order. The first step that produces a final answer ends
the checks; step 15 runs for every request. The size and time limits apply to the checks; step 15 runs for every request but the health check. The size and
the whole exchange. Each step names the section that holds its rules. time limits apply to the whole exchange. Each step names the section that holds
its rules.
1. `GET /_smallwebwaf/healthz` is answered at once, before any check. 1. `GET /_smallwebwaf/healthz` is answered at once, before any check, so a
health checker never needs an exemption and is never refused.
2. The client is identified. 2. The client is identified.
- If the TCP peer is inside `SWWAF_TRUSTED_PROXIES`, `X-Forwarded-For` is - If the TCP peer is inside `SWWAF_TRUSTED_PROXIES`, `X-Forwarded-For` is
walked from the right, and the first address outside those netblocks is walked from the right, and the first address outside those netblocks is
@@ -116,9 +118,9 @@ the whole exchange. Each step names the section that holds its rules.
around its address, a /64 by default, since one abuser usually controls a around its address, a /64 by default, since one abuser usually controls a
whole /64. A client is therefore one IPv4 address or one IPv6 group. whole /64. A client is therefore one IPv4 address or one IPv6 group.
3. The static lists. A client in `SWWAF_ALLOW_NETS` skips steps 4 to 12 and the 3. The static lists. A client in `SWWAF_ALLOW_NETS` skips steps 4 to 12 and the
byte limits and the error burst at step 15. It is still counted for the byte limits and the error burst at step 15. It still needs a token at
anomaly thresholds, and still needs a token at step 13. A client in step 13. A client in `SWWAF_DENY_NETS` is refused; a client in both lists is
`SWWAF_DENY_NETS` is refused; a client in both lists is let through. let through.
4. Bans. A client under a ban is refused (see "Bans"). 4. Bans. A client under a ban is refused (see "Bans").
5. The lookup, unless `SWWAF_LOOKUP_SOURCE` is `off` (see "Lookup of AS number 5. The lookup, unless `SWWAF_LOOKUP_SOURCE` is `off` (see "Lookup of AS number
and country"). The request goes on at once. It waits for the first GeoJS and country"). The request goes on at once. It waits for the first GeoJS
@@ -134,8 +136,6 @@ the whole exchange. Each step names the section that holds its rules.
gets `SWWAF_BLOCKLIST_ACTION`. A client on the CrowdSec decision list is gets `SWWAF_BLOCKLIST_ACTION`. A client on the CrowdSec decision list is
banned. A client listed by a DNS blocklist, or scored at least banned. A client listed by a DNS blocklist, or scored at least
`SWWAF_ABUSEIPDB_MIN_SCORE` by AbuseIPDB, gets `SWWAF_REPUTATION_ACTION`. `SWWAF_ABUSEIPDB_MIN_SCORE` by AbuseIPDB, gets `SWWAF_REPUTATION_ACTION`.
With no cached verdict, a query is queued in the background and the request
goes on, so a first request is never delayed by a reputation query.
8. The client's limit percentage (see "Biased thresholds"). 8. The client's limit percentage (see "Biased thresholds").
9. Rate limiting (see "Request rate limits, per client"). A request over a limit 9. Rate limiting (see "Request rate limits, per client"). A request over a limit
times the percentage is refused and bans the client. times the percentage is refused and bans the client.
@@ -164,8 +164,8 @@ the whole exchange. Each step names the section that holds its rules.
thresholds are evaluated. thresholds are evaluated.
In `SWWAF_MODE=observe`, every refusal above is logged as what would have In `SWWAF_MODE=observe`, every refusal above is logged as what would have
happened, nothing is refused, and no ban is made. The size and time limits and happened, nothing is refused, and `smallwebwaf` makes no ban of its own. The
the token check still apply. size and time limits and the token check still apply.
## Counting method ## Counting method
@@ -183,8 +183,8 @@ the token check still apply.
GeoJS answers, the reputation verdicts and scores, the DNS queries under way GeoJS answers, the reputation verdicts and scores, the DNS queries under way
and the anomaly counters have fixed bounds, listed under "Fixed limits" in and the anomaly counters have fixed bounds, listed under "Fixed limits" in
`docs/settings.md`. Past its bound, the GeoJS answer or anomaly counter used `docs/settings.md`. Past its bound, the GeoJS answer or anomaly counter used
longest ago goes first, and the verdict or score fetched longest ago. Past longest ago goes first, and the verdict or score fetched longest ago. Past the
1,000 DNS queries under way, a zone is asked at the client's next request. bound on DNS queries under way, a zone is asked at the client's next request.
## Bans ## Bans
@@ -193,8 +193,7 @@ Until a ban ends, every request from its netblock is refused with
`SWWAF_BAN_SCOPE_V4_PREFIX`, or its IPv6 group. At the defaults a ban covers one `SWWAF_BAN_SCOPE_V4_PREFIX`, or its IPv6 group. At the defaults a ban covers one
address or one /64, so a permanent ban does not reach neighbours who did address or one /64, so a permanent ban does not reach neighbours who did
nothing. Requests refused under a ban are not counted against limits. They are nothing. Requests refused under a ban are not counted against limits. They are
counted in the client's history and in the ban's notes. A banned client is not counted in the client's history and in the ban's notes.
looked up.
An offence is a request `smallwebwaf` holds against the client. It is one of: An offence is a request `smallwebwaf` holds against the client. It is one of:
@@ -216,9 +215,12 @@ to a ban, each by its own rule:
permanent this way. permanent this way.
- Once that ban has run out, the netblock is served like any other. Its next - Once that ban has run out, the netblock is served like any other. Its next
clear sign of attack bans it permanently at once. clear sign of attack bans it permanently at once.
- A broken limit: a request over a rate limit, a response that takes the - A broken limit, one of:
client's byte total past a byte limit, or more than - a request over a rate limit;
`SWWAF_ERROR_BURST_THRESHOLD` refusals within a minute. - a response that takes the client's byte total past a byte limit;
- more than `SWWAF_ERROR_BURST_THRESHOLD` refusals within a minute.
The bans for a broken limit:
- The first such ban lasts `SWWAF_LIMIT_BAN_DURATION` (default `1h`), with - The first such ban lasts `SWWAF_LIMIT_BAN_DURATION` (default `1h`), with
the cause `limit`. So does one that comes more than the cause `limit`. So does one that comes more than
`SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`) after the last such ban `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`) after the last such ban
@@ -234,7 +236,7 @@ to a ban, each by its own rule:
A Core Rule Set match refuses only the request it matched, with `403` and A Core Rule Set match refuses only the request it matched, with `403` and
without a ban, because the Core Rule Set has false positives. A `block` rule without a ban, because the Core Rule Set has false positives. A `block` rule
does the same. Those refusals count toward the error burst, so a client that does the same. Those refusals count toward the error burst, so a client that
keeps setting them off is banned under the second rule. keeps setting them off is banned for a broken limit.
A client on the CrowdSec decision list is banned until the decision ends, with A client on the CrowdSec decision list is banned until the decision ends, with
the cause `crowdsec`. Only a client that sends a request is banned, so a long the cause `crowdsec`. Only a client that sends a request is banned, so a long
@@ -329,8 +331,8 @@ Conventions:
- `SWWAF_ADMIN_TOKEN` switches on the ban endpoints and - `SWWAF_ADMIN_TOKEN` switches on the ban endpoints and
`/_smallwebwaf/clients/<ip>`. The metrics are off until `SWWAF_METRICS_TOKEN` `/_smallwebwaf/clients/<ip>`. The metrics are off until `SWWAF_METRICS_TOKEN`
is set, since without a token they would be open to anyone on the internet. is set, since without a token they would be open to anyone on the internet.
The two are separate, so a scraper that holds the metrics token cannot manage The two are separate, so the metrics collector, which holds the metrics token,
bans. cannot manage bans.
- A token is a long random value, since the endpoints can be reached from the - A token is a long random value, since the endpoints can be reached from the
internet. One shorter than 32 characters stops the start. The app starts with internet. One shorter than 32 characters stops the start. The app starts with
the same environment variables as `smallwebwaf`, so it can read a token set as the same environment variables as `smallwebwaf`, so it can read a token set as
@@ -366,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. The defaults sit well above one person's use, and the through Tor. They are one client, and share its limits and its bans. The
first two lists 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
@@ -388,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
@@ -442,12 +454,8 @@ service that keeps its visitors' addresses on its own host.
- Only one source is used. The start stops, naming both settings, when `file` is - Only one source is used. The start stops, naming both settings, when `file` is
set without `SWWAF_LOOKUP_DB_PATH`, or `SWWAF_LOOKUP_DB_PATH` with any other set without `SWWAF_LOOKUP_DB_PATH`, or `SWWAF_LOOKUP_DB_PATH` with any other
source. The start also stops when the source is `off` and one of these needs source. The start also stops when the source is `off` and a setting that needs
lookups: a lookup is set (`docs/settings.md` lists them).
- a country list;
- a biased threshold;
- `SWWAF_ADD_LOOKUP_HEADERS`;
- an anomaly threshold per AS number.
- GeoJS is told the address of every new visitor, whether or not a setting uses - GeoJS is told the address of every new visitor, whether or not a setting uses
the answer, since the request log, the metrics and the ban notes carry it. the answer, since the request log, the metrics and the ban notes carry it.
These addresses are never sent: These addresses are never sent:
@@ -458,8 +466,8 @@ service that keeps its visitors' addresses on its own host.
Addresses are asked about together, up to 200 in one request Addresses are asked about together, up to 200 in one request
(`https://get.geojs.io/v1/ip/geo.json?ip=a,b,c`), one request at a time, which (`https://get.geojs.io/v1/ip/geo.json?ip=a,b,c`), one request at a time, which
keeps the number of requests to GeoJS low. Each answer's `country_code`, `asn` keeps the number of requests to GeoJS low. Each answer's `country_code`, `asn`
and `organization_name` are read. An answer without a `country_code`, or with and `organization_name` are read. An answer without a `country_code` leaves
the `asn` `64512`, counts as unknown. the country unknown, and the `asn` `64512` leaves the AS number unknown.
- Each answer is kept for seven days, in memory and in `lookups.json`. Answers - Each answer is kept for seven days, in memory and in `lookups.json`. Answers
are kept apart from the table of clients, so that an answer outlasts a client are kept apart from the table of clients, so that an answer outlasts a client
dropped from that table. The answer reaches the client's history and ban notes dropped from that table. The answer reaches the client's history and ban notes
@@ -476,8 +484,8 @@ service that keeps its visitors' addresses on its own host.
API requests", judged by GeoJS alone, and let it block a caller. While GeoJS API requests", judged by GeoJS alone, and let it block a caller. While GeoJS
is slow, down or refusing `smallwebwaf`, new clients count as unknown. GeoJS is slow, down or refusing `smallwebwaf`, new clients count as unknown. GeoJS
is then left alone for a second, and twice as long after each further failure, is then left alone for a second, and twice as long after each further failure,
up to five minutes, and one `source_failure` alert is sent per cooldown. A up to five minutes. Each failure raises a `source_failure` alert (see
service that cannot accept this uses the file. "Alerting"). A service that cannot accept this uses the file.
- The file is the free IPinfo Lite database (`ipinfo_lite.mmdb`), whose `asn`, - The file is the free IPinfo Lite database (`ipinfo_lite.mmdb`), whose `asn`,
`as_name` and `country_code` fields are read. The operator downloads it with a `as_name` and `country_code` fields are read. The operator downloads it with a
free IPinfo account and mounts it read-only; `smallwebwaf` never fetches it free IPinfo account and mounts it read-only; `smallwebwaf` never fetches it
@@ -486,9 +494,9 @@ service that keeps its visitors' addresses on its own host.
on the site that uses it, such as "IP address data is powered by IPinfo". on the site that uses it, such as "IP address data is powered by IPinfo".
- The file is read whole at start; one that is missing or unreadable stops the - The file is read whole at start; one that is missing or unreadable stops the
start. It is read again once it has gone 2 seconds without a change after start. It is read again once it has gone 2 seconds without a change after
being replaced. One that cannot be read is logged, sent as one `file_error` being replaced. A replacement that cannot be read is logged, sent as one
alert and ignored. The database already loaded stays in use. A client whose `file_error` alert and ignored. The database already loaded stays in use. A
address the file does not hold counts as unknown. client whose address the file does not hold counts as unknown.
### Country lists ### Country lists
@@ -508,9 +516,8 @@ where it comes from.
exclusive list already refuses every other country. A code on both lists stops exclusive list already refuses every other country. A code on both lists stops
the start with a message naming it. the start with a message naming it.
- A refused request is answered with `SWWAF_BAN_RESPONSE`, as soon as the - A refused request is answered with `SWWAF_BAN_RESPONSE`, as soon as the
client's address has been looked up, before its body is read. It skips the client's address has been looked up, before its body is read. It is logged
reputation checks, the limits, the rule files and the Core Rule Set. It is with the action `country_denied`, and counted in the metrics.
logged with the action `country_denied`, and counted in the metrics.
### Biased thresholds ### Biased thresholds
@@ -519,10 +526,11 @@ countries. The lists are empty by default, for the same reason as the country
lists. The request log and the metrics show which AS numbers and countries a lists. The request log and the metrics show which AS numbers and countries a
service's abuse comes from. service's abuse comes from.
- Clients in a listed AS number or country get that percentage of every request - Each list gives AS numbers or countries a percentage, such as `AS14061:50`.
and byte limit (`SWWAF_ASN_LIMIT_PERCENT`, `SWWAF_COUNTRY_LIMIT_PERCENT`). A Their clients get that percentage of every request and byte limit
lowered limit is rounded down. `0` is a zero allowance: the client's first (`SWWAF_ASN_LIMIT_PERCENT`, `SWWAF_COUNTRY_LIMIT_PERCENT`). A lowered limit is
request breaks a limit and bans it. rounded down. `0` is a zero allowance: the client's first request breaks a
limit and bans it.
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` set the byte - `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` set the byte
limits' percentage alone, when it should differ from the request limits'. For limits' percentage alone, when it should differ from the request limits'. For
an AS number they list, they replace its percentage from an AS number they list, they replace its percentage from
@@ -533,12 +541,12 @@ service's abuse comes from.
fetched and kept like a blocklist (see "Reputation"). Where it and fetched and kept like a blocklist (see "Reputation"). Where it and
`SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. `SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies.
- A client's limit percentage is the lowest of those that apply to it: its AS - A client's limit percentage is the lowest of those that apply to it: its AS
number's, its country's, or a reputation hit's `limit:<percent>`. A client the number's, its country's, or a reputation hit's `limit:<percent>`. A client
lookup cannot place gets `SWWAF_UNKNOWN_LIMIT_PERCENT` in place of an AS without a country gets `SWWAF_UNKNOWN_LIMIT_PERCENT` in place of a country's.
number's or a country's. The percentage is 100 if none applies. Clients given The percentage is 100 if none applies. Clients given a lower percentage are
a lower percentage are banned after fewer requests, first for a while and banned after fewer requests, first for a while and then, for repeated abuse,
then, for repeated abuse, permanently. The log line of each counted request, permanently. The log line of each counted request, and the notes of each ban,
and the notes of each ban, say which setting lowered the limit. say which setting lowered the limit.
- No budget is shared by a whole country or AS number. One abuser could use it - No budget is shared by a whole country or AS number. One abuser could use it
up and lock out everyone else there: a denial of service nobody chose, and the up and lock out everyone else there: a denial of service nobody chose, and the
one this tool exists to prevent. Refusing a whole country is left to the one this tool exists to prevent. Refusing a whole country is left to the
@@ -549,16 +557,14 @@ service's abuse comes from.
By default the Core Rule Set reads the method, the URL with its query string, By default the Core Rule Set reads the method, the URL with its query string,
and the headers of every request. That is where automated attacks show: and the headers of every request. That is where automated attacks show:
injection in query strings, path traversal, attacks carried in headers, injection in query strings, path traversal, attacks carried in headers,
scanners' user agents. It reads no request body. On a code forge, the example scanners' user agents. It reads no request body. In front of a code forge such
deployment, bodies carry what people write and publish, such as issue text and as gitea, bodies carry what people write and publish, such as issue text and
files saved in the web editor. The Core Rule Set takes the shell commands and files saved in the web editor. The Core Rule Set takes the shell commands and
code in them for attacks. The default gives up refusing an attack carried in a code in them for attacks. The default gives up refusing an attack carried in a
body. The rule files, the limits and the bans still apply to the client that body. The rule files, the limits and the bans still apply to the client that
sends it. sends it.
- The Core Rule Set inspects only a request that every earlier check let - The Core Rule Set skips a path under `SWWAF_WAF_EXEMPT_PATHS`.
through, and skips a path under `SWWAF_WAF_EXEMPT_PATHS`. An engine error on a
request forwards the request, and is logged and counted.
- `SWWAF_WAF_PARANOIA_LEVEL` and `SWWAF_WAF_ANOMALY_THRESHOLD` are the Core Rule - `SWWAF_WAF_PARANOIA_LEVEL` and `SWWAF_WAF_ANOMALY_THRESHOLD` are the Core Rule
Set's own two tuning values, at its own defaults. Each level up runs more Set's own two tuning values, at its own defaults. Each level up runs more
rules, which find more attacks and take more ordinary requests for attacks. rules, which find more attacks and take more ordinary requests for attacks.
@@ -601,14 +607,18 @@ sends it.
percent-decoded: `/wp-login.php` matches `/wp-login.php?x=1` and percent-decoded: `/wp-login.php` matches `/wp-login.php?x=1` and
`/%77p-login.php`, and does not match `/wp-login.php/` or `/%77p-login.php`, and does not match `/wp-login.php/` or
`/blog/wp-login.php`. `/blog/wp-login.php`.
- `SWWAF_ERROR_BURST_THRESHOLD` counts a client's refusals per minute for a rule - `SWWAF_ERROR_BURST_THRESHOLD` counts a client's refusals per minute of these
file match, a trap path, a Core Rule Set match, or a missing or wrong token; kinds, and of no other:
no other refusal counts. More than this breaks a limit (see "Bans"). A client - a rule file match;
trying one attack or token after another is refused many times a minute; a - a trap path;
person rarely more than a few times. Answers the app gives are not counted, - a Core Rule Set match;
since they do not tell a scanner from an ordinary client. In front of gitea, - a missing or wrong token.
registry and git clients get `404` and `401` by design, hundreds of times a
minute. More than this breaks a limit (see "Bans"). A client trying one attack or
token after another is refused many times a minute; a person rarely more
than a few times. Answers the app gives are not counted, since they do not
tell a scanner from an ordinary client. In front of gitea, registry and git
clients get `404` and `401` by design, hundreds of times a minute.
`smallwebwaf` changes the Core Rule Set 4.25.0 in six ways, since in front of `smallwebwaf` changes the Core Rule Set 4.25.0 in six ways, since in front of
gitea it would otherwise refuse ordinary requests. The changes hold in front of gitea it would otherwise refuse ordinary requests. The changes hold in front of
@@ -635,12 +645,12 @@ every app, and no setting undoes them:
- `ref`, `sha`, `branch` and `workflow`; - `ref`, `sha`, `branch` and `workflow`;
- `artifactName` and `redirect_to`. - `artifactName` and `redirect_to`.
gitea puts file paths, branch and workflow names, and the page to return to gitea puts into them file paths, branch and workflow names, and the page to
after signing in into them. It reads these values as names in a repository return to after signing in. It reads these values as names in a repository
or in its own records, or as a page of its own site, never as a command. Any or in its own records, or as a page of its own site, never as a command. Any
name on those lists can be an ordinary file or branch, such as `.gitignore` name on those lists can be an ordinary file or branch, such as `.gitignore`
or `docker-build`. In these parameters, anything only these three rules or `docker-build`. In these parameters, what only these three rules would
catch reaches the app, commands such as `|cat /etc/passwd` included. Path catch reaches the app, such as the command `|cat /etc/passwd`. Path
traversal, SQL and script injection, and PHP, Java and Node.js code are traversal, SQL and script injection, and PHP, Java and Node.js code are
still refused there. Every other parameter keeps all three rules. An app still refused there. Every other parameter keeps all three rules. An app
that uses one of these parameters as a file on the server, or passes it to a that uses one of these parameters as a file on the server, or passes it to a
@@ -649,8 +659,8 @@ every app, and no setting undoes them:
5. Two cookies are left out, and `Referer` keeps fewer rules: 5. Two cookies are left out, and `Referer` keeps fewer rules:
- The Core Rule Set reads the request without the `gitea_flash` and - The Core Rule Set reads the request without the `gitea_flash` and
`redirect_to` cookies. gitea puts a message naming what was just done into `redirect_to` cookies. gitea puts a message naming what was just done into
`gitea_flash`. It puts the page to return to after signing in into `gitea_flash`. It puts into `redirect_to` the page to return to after
`redirect_to`. The Core Rule Set takes file names in them, such as signing in. The Core Rule Set takes file names in them, such as
`package.json`, for attacks. A browser sends such a cookie with every `package.json`, for attacks. A browser sends such a cookie with every
request until gitea replaces it. gitea cannot replace it while those request until gitea replaces it. gitea cannot replace it while those
requests are refused, so one refusal would keep the browser out of the requests are refused, so one refusal would keep the browser out of the
@@ -773,9 +783,9 @@ too close together.
by default. Such verdicts are less certain than a blocklist, so a listed by default. Such verdicts are less certain than a blocklist, so a listed
client gets a quarter of every request and byte limit rather than a refusal. client gets a quarter of every request and byte limit rather than a refusal.
- Verdicts and scores are kept for `SWWAF_REPUTATION_CACHE_TTL`, in memory and - Verdicts and scores are kept for `SWWAF_REPUTATION_CACHE_TTL`, in memory and
in `reputation.json`, with the fixed bounds under "Fixed limits" in in `reputation.json`. A source that is down or over quota gives no verdict.
`docs/settings.md`. A source that is down or over quota gives no verdict. The The service continues. Each failure raises a `source_failure` alert (see
service continues, with one `source_failure` alert per cooldown. "Alerting").
### Alerting ### Alerting
@@ -787,9 +797,8 @@ too close together.
the ban's alert, with `mode` set to `observe` in its `detail`. the ban's alert, with `mode` set to `observe` in its `detail`.
- A repeat is an alert with the same event, and the same netblock, source, file - A repeat is an alert with the same event, and the same netblock, source, file
or anomaly scope, as one sent less than `SWWAF_ALERT_COOLDOWN` before. An or anomaly scope, as one sent less than `SWWAF_ALERT_COOLDOWN` before. An
anomaly scope is the thing counted: one client, netblock, AS number or named anomaly scope is one of the things counted under "Anomaly thresholds". A
netblock, or the whole service. A repeat is held back and counted, and the repeat is held back and counted, and the next such alert sent gives the count.
next such alert sent gives the count.
- Past `SWWAF_ALERT_MAX_PER_HOUR` alerts in an hour, the rest are held back and - Past `SWWAF_ALERT_MAX_PER_HOUR` alerts in an hour, the rest are held back and
counted by event, so a wide attack cannot flood the channel. Once the hour counted by event, so a wide attack cannot flood the channel. Once the hour
ends, one alert with the event `summary` gives those counts. It also counts ends, one alert with the event `summary` gives those counts. It also counts
@@ -820,7 +829,7 @@ addresses at low rates each, which the per-client limits do not see. A low
- Every request but the health check is counted, whatever was done with it, - Every request but the health check is counted, whatever was done with it,
clients in `SWWAF_ALLOW_NETS` and `SWWAF_RATE_LIMIT_EXEMPT_NETS` included, clients in `SWWAF_ALLOW_NETS` and `SWWAF_RATE_LIMIT_EXEMPT_NETS` included,
since an exempt client misbehaving is worth knowing about. The counters are since an exempt client misbehaving is worth knowing about. The counters are
kept in memory and in `alerts.json`, with a fixed bound. kept in memory and in `alerts.json`.
## Rule files ## Rule files
@@ -846,10 +855,10 @@ and the default file.
of every request; a rule naming one stops the start like any bad line. Request of every request; a rule naming one stops the start like any bad line. Request
bodies are not available to rule files; reading them is the Core Rule Set's bodies are not available to rule files; reading them is the Core Rule Set's
job. job.
- `log` only notes the match in the request log. `block` refuses the request - `log` only notes the match in the request log. `block` refuses the request;
without a ban; `ban` makes the request a clear sign of attack (see "Bans"), `ban` makes the request a clear sign of attack (see "Bans"), and is for
and is for requests no real visitor sends. A visitor banned by mistake is let requests no real visitor sends. A visitor banned by mistake is let back in by
back in by lifting the ban. lifting the ban.
- At start, these stop the process with a message naming the file and line: - At start, these stop the process with a message naming the file and line:
- a line that does not parse; - a line that does not parse;
- a regex that does not compile; - a regex that does not compile;
@@ -865,11 +874,11 @@ and the default file.
wrong for every app, with its path rules anchored at the site root. In front wrong for every app, with its path rules anchored at the site root. In front
of gitea, `/.env` is a probe, while of gitea, `/.env` is a probe, while
`/<owner>/<repo>/src/branch/main/.env.example` is a file in a repository that `/<owner>/<repo>/src/branch/main/.env.example` is a file in a repository that
any visitor may open. `ban` rules of your own follow the same rule. Anything any visitor may open. Anchor the paths of your own `ban` rules at the site
app-specific, such as a rule banning WordPress probes in front of gitea, root too. Anything app-specific, such as a rule banning WordPress probes in
belongs in a file the app's Dockerfile adds or the deployer mounts. A front of gitea, belongs in a file the app's Dockerfile adds or the deployer
directory mounted over `SWWAF_RULES_DIR` replaces the default file; single mounts. A directory mounted over `SWWAF_RULES_DIR` replaces the default file;
files mounted into it add to it. single files mounted into it add to it.
- Rules meant to be edited while `smallwebwaf` runs belong in a mounted - Rules meant to be edited while `smallwebwaf` runs belong in a mounted
directory, not a mounted file. Many editors save a file by replacing it, and a directory, not a mounted file. Many editors save a file by replacing it, and a
container keeps seeing the old one when the file alone is mounted. The lookup container keeps seeing the old one when the file alone is mounted. The lookup
@@ -878,11 +887,11 @@ and the default file.
## Persistent state ## Persistent state
All state lives in memory, and the files in `SWWAF_STATE_DIR` hold a copy of all All state lives in memory, and the files in `SWWAF_STATE_DIR` hold a copy of it,
of it, so an orderly stop loses nothing (the remote log is the exception; see so an orderly stop loses nothing. The files are meant for people as well: an
"Request log"). The files are meant for people as well: an admin can read or admin can read or edit them at any time, and the running `smallwebwaf` takes the
edit them at any time, and the running `smallwebwaf` takes the edit in. edit in. `docs/state-files.md` lists the files, what each holds, and their
`docs/state-files.md` lists the files, what each holds, and their sizes. sizes.
- Each file holds one kind of state, as JSON with: - Each file holds one kind of state, as JSON with:
- a top-level `version` number; - a top-level `version` number;
@@ -940,9 +949,8 @@ edit them at any time, and the running `smallwebwaf` takes the edit in.
## Request log ## Request log
Every request gets one JSON object on one line on stdout, refused ones included, Every request `smallwebwaf` sees gets one JSON object on one line on stdout,
apart from those Go's HTTP server ends before `smallwebwaf` sees them (see "Size refused ones included. `docs/request-log.md` lists the fields. Bodies are never
and time limits"). `docs/request-log.md` lists the fields. Bodies are never
logged, and no header but those listed there. Query strings are logged as logged, and no header but those listed there. Query strings are logged as
received; an app that carries secrets in query strings needs that fixed in the received; an app that carries secrets in query strings needs that fixed in the
app. The process's own messages (start, the settings, stop, errors) share the app. The process's own messages (start, the settings, stop, errors) share the
@@ -971,8 +979,7 @@ while `docker logs` keeps working.
## Metrics endpoint ## Metrics endpoint
`GET /_smallwebwaf/metrics` answers in the Prometheus text format, for a request `GET /_smallwebwaf/metrics` answers in the Prometheus text format, for a request
carrying `SWWAF_METRICS_TOKEN`. `docs/metrics.md` lists the metrics. Every carrying `SWWAF_METRICS_TOKEN`. `docs/metrics.md` lists the metrics. No metric
metric carries the label `instance`, set to `SWWAF_INSTANCE_NAME`. No metric
carries a client IP address as a label. Per-address questions are answered by carries a client IP address as a label. Per-address questions are answered by
the request log and `GET /_smallwebwaf/clients/<ip>`. the request log and `GET /_smallwebwaf/clients/<ip>`.
@@ -980,15 +987,14 @@ the request log and `GET /_smallwebwaf/clients/<ip>`.
A request whose path starts with `/_smallwebwaf/` is for `smallwebwaf` itself: A request whose path starts with `/_smallwebwaf/` is for `smallwebwaf` itself:
it answers it and never passes it to the app. The prefix carries the tool's it answers it and never passes it to the app. The prefix carries the tool's
name, so it takes no path an app uses. Admins and scrapers reach these endpoints name, so it takes no path an app uses. Admins and the metrics collector reach
through traefik, like any other request. `docs/admin-endpoints.md` lists the these endpoints through traefik, like any other request.
endpoints, what each takes and what each answers. `docs/admin-endpoints.md` lists the endpoints, what each takes and what each
answers.
- `GET /_smallwebwaf/healthz` answers `200` with `ok` to anyone, without a - `GET /_smallwebwaf/healthz` answers `200` with `ok` to anyone, without a
token, while `smallwebwaf` is running. It does not ask the app; the token, while `smallwebwaf` is running. It does not ask the app; the
container's health check does. It is answered before any check, so a health container's health check does.
checker never needs an exemption and is never refused, for example by
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES`.
- Every other endpoint needs its token, sent as `Authorization: Bearer <token>` - Every other endpoint needs its token, sent as `Authorization: Bearer <token>`
(see "Tokens" under "Settings"). While a token is unset, the endpoints that (see "Tokens" under "Settings"). While a token is unset, the endpoints that
need it answer `404`, as does any other path under the prefix. need it answer `404`, as does any other path under the prefix.
@@ -996,10 +1002,9 @@ endpoints, what each takes and what each answers.
other request goes through. They are answered at the point where another other request goes through. They are answered at the point where another
request would be forwarded to the app: a banned or refused client stays request would be forwarded to the app: a banned or refused client stays
refused, and each request counts toward the client's limits. A missing or refused, and each request counts toward the client's limits. A missing or
wrong token is answered `401`, in `SWWAF_MODE=observe` too, and counts toward wrong token is answered `401` (see "Attack detection" for what guessing
the error burst. A client guessing tokens is therefore banned once it passes costs).
`SWWAF_ERROR_BURST_THRESHOLD` guesses in a minute. - A ban made through the endpoints starts at once.
- A ban made through the endpoints starts at once, with the cause `admin`.
## Deployment ## Deployment
@@ -1027,40 +1032,40 @@ what the image holds and how each part is pinned.
digest. digest.
- nixpkgs comes from a release file on `releases.nixos.org`, named with its - nixpkgs comes from a release file on `releases.nixos.org`, named with its
hash, not from a GitHub archive, whose bytes can change. Root uses Nix hash, not from a GitHub archive, whose bytes can change. Root uses Nix
directly; no Nix daemon runs, and since `nix-bin` creates no build users, directly; no Nix daemon runs. `nix-bin` creates no build users, so
`build-users-group =` in `/etc/nix/nix.conf` lets root build without them. `/etc/nix/nix.conf` sets `build-users-group =`, which lets root build without
Root's Nix profile is last on the `PATH`, so that no package hides the image's them. Root's Nix profile is last on the `PATH`, so that no package hides the
own commands; busybox's `sv`, for one, looks for services elsewhere. An app image's own commands; busybox's `sv`, for one, looks for services elsewhere.
built on the image gets the same packages each time. Security fixes from An app built on the image gets the same packages each time. Security fixes
Ubuntu and nixpkgs come with a newer `smallwebwaf` image, which an app takes from Ubuntu and nixpkgs come with a newer `smallwebwaf` image, which an app
by changing the digest in its `FROM` line. The image moves to each new nixpkgs takes by changing the digest in its `FROM` line. The image moves to each new
release, every six months. nixpkgs release, every six months.
- runit supervises the two processes, with `runsvinit` as the entrypoint, as the - runit supervises the two processes, with `runsvinit` as the entrypoint, as the
owner's code style guide asks for service containers. No package carries owner's code style guide asks for service containers. No package carries
`runsvinit`, so the image builds it from its archived source, unchanged since `runsvinit`, so the image builds it from its archived source, unchanged since
2015, at a fixed commit hash, with a `go.mod` the build writes. `runsvinit` 2015, at a fixed commit hash, with a `go.mod` the build writes. `runsvinit`
starts runit's `runsvdir`, which runs the `run` script of each directory under starts runit's `runsvdir`, which runs the `run` script of each directory under
`/etc/service`. Ubuntu's `sv` looks for services there too. `/etc/service`. Ubuntu's `sv` looks for services there too.
- The image's `run` script for `smallwebwaf` waits one second and makes - The image's `run` script for `smallwebwaf` makes `SWWAF_STATE_DIR` and every
`SWWAF_STATE_DIR` and every file in it belong to the `smallwebwaf` user (uid file in it belong to the `smallwebwaf` user (uid and gid 65532). Then it
and gid 65532). Then it starts `smallwebwaf` as that user with runit's starts `smallwebwaf` as that user with runit's `chpst`.
`chpst`.
Every `run` script, the app's included, is a bash script. It starts with Every `run` script, the app's included, is a bash script. It starts with
`#!/usr/bin/env bash` and `set -euo pipefail`, and puts its code in a `main` `#!/usr/bin/env bash` and `set -euo pipefail`, and puts its code in a `main`
function, called on its last line, as the owner's code style guide asks. Ubuntu function, called on its last line, as the owner's code style guide asks. Ubuntu
ships bash. ships bash. Each waits one second (`sleep 1`) before starting its program, so a
process that stops at start is started again every second, not in a tight loop.
Beyond its `FROM` line, the app's Dockerfile adds: Beyond its `FROM` line, the app's Dockerfile adds:
- its binary; - its binary;
- any packages it needs, from nixpkgs, with `nix-env -iA nixpkgs.<name>`; - any packages it needs, from nixpkgs, with `nix-env -iA nixpkgs.<name>`;
- its runit service: a directory under `/etc/service` named after the app, never - its runit service: a directory under `/etc/service` named after the app, never
`smallwebwaf`. Its `run` script waits one second (`sleep 1`) and then starts `smallwebwaf`. Its `run` script starts the app with `chpst`, listening where
the app with `chpst`, listening where `SWWAF_UPSTREAM_URL` points (see `SWWAF_UPSTREAM_URL` points (see "Ports"). The app runs as a user of its own,
"Ports"). The app runs as a user of its own, which the Dockerfile creates with which the Dockerfile creates with `useradd`. That user is neither root nor
`useradd`. That user is neither root nor `smallwebwaf`, so that the app cannot `smallwebwaf`, so that the app cannot touch the state files or the process of
touch the state files or the process of `smallwebwaf`. `smallwebwaf`.
It sets no `ENTRYPOINT` or `USER` of its own. The container must start It sets no `ENTRYPOINT` or `USER` of its own. The container must start
`runsvinit`, as root, so that it can start each service as its own user. `runsvinit`, as root, so that it can start each service as its own user.
@@ -1072,20 +1077,21 @@ It sets no `ENTRYPOINT` or `USER` of its own. The container must start
script starts as root and hands over to its program's user with `chpst`. Both script starts as root and hands over to its program's user with `chpst`. Both
services start with the container's environment variables and write to the services start with the container's environment variables and write to the
container's output, which `docker logs` shows. container's output, which `docker logs` shows.
- When `smallwebwaf` or the app exits, runit runs its `run` script again, which - When `smallwebwaf` or the app exits, runit runs its `run` script again. The
waits a second before starting it. The other process keeps running, and the other process keeps running, and the container does not stop. While
container does not stop. While `smallwebwaf` is down, traefik answers `502`. `smallwebwaf` is down, traefik answers `502`. While the app is down,
While the app is down, `smallwebwaf` answers `502`, which is not a client `smallwebwaf` answers `502`, which is not a client offence.
offence. - Only faults found at start stop `smallwebwaf`:
- Only four things stop `smallwebwaf`, all at start:
- invalid configuration; - invalid configuration;
- a lookup database that is missing or unreadable; - a lookup database that is missing or unreadable;
- a rule file or state file that does not parse; - a rule file or state file that does not parse;
- an unwritable `SWWAF_STATE_DIR`. - an unwritable `SWWAF_STATE_DIR`;
- A `smallwebwaf` that stops at start is therefore started again every second, - a `SWWAF_RULES_DIR` that does not exist or cannot be read;
logging its message each time, until the cause is fixed; the health check - a `SWWAF_LISTEN_ADDR` it cannot listen on.
fails meanwhile. Once running, a failing GeoJS, reputation source or alert - A `smallwebwaf` that stops at start logs its message at each try until the
destination, or a broken edit, never takes the protected service down. cause is fixed; the health check fails meanwhile. Once running, a failing
GeoJS, reputation source or alert destination, or a broken edit, never takes
the protected service down.
- `docker stop` sends `SIGTERM` to `runsvinit`, which has runit stop both - `docker stop` sends `SIGTERM` to `runsvinit`, which has runit stop both
services at once, each with `SIGTERM`, and exits once they have stopped. services at once, each with `SIGTERM`, and exits once they have stopped.
`smallwebwaf` stops taking requests, gives those in progress five seconds to `smallwebwaf` stops taking requests, gives those in progress five seconds to
@@ -1154,9 +1160,10 @@ all: `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1/32,::1/128`.
### upaas ### upaas
An app deployed by upaas takes this shape with no change to upaas. upaas builds An app deployed by upaas, which rebuilds and restarts an app's Docker container
the app's Dockerfile and runs the one container as it runs any app. The traefik when its repository changes, takes this shape with no change to upaas. upaas
labels route to port 8080, and any `SWWAF_` settings go with the app's builds the app's Dockerfile and runs the one container as it runs any app. The
traefik labels route to port 8080, and any `SWWAF_` settings go with the app's
environment variables. The volume for `/var/lib/smallwebwaf` and the directory environment variables. The volume for `/var/lib/smallwebwaf` and the directory
of any token file go beside the app's own volumes. gitea is built on the image of any token file go beside the app's own volumes. gitea is built on the image
like any other app; `docs/gitea.md` records what the defaults do in front of it, like any other app; `docs/gitea.md` records what the defaults do in front of it,
+11 -8
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,
@@ -79,16 +79,17 @@ on one line:
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`: - `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
one of the events above. one of the events above.
- `client`: the address whose request raised the alert. Empty for - `client`: the address whose request raised the alert. Empty for
`source_failure` and `file_error`. `source_failure`, `file_error` and `summary`.
- `netblock`: empty for `source_failure` and `file_error`; otherwise one of: - `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
one of:
- the ban's netblock; - the ban's netblock;
- the netblock an anomaly counted; - the netblock an anomaly counted, empty in the `asn` and `total` scopes;
- for the other events, the client as rate limiting counts it: an IPv4 - for the other events, the client as rate limiting counts it: an IPv4
address as a /32, or an IPv6 group. address as a /32, or an IPv6 group.
- `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));
@@ -97,6 +98,8 @@ on one line:
- for a Core Rule Set match, the `rule_ids`, `score`, `method` and `path`; - for a Core Rule Set match, the `rule_ids`, `score`, `method` and `path`;
- for a source failure, the `source` and `error`; - for a source failure, the `source` and `error`;
- for a file error, the `file` and `error`; - for a file error, the `file` and `error`;
- for a summary, the `hour` it covers, the `count` of alerts held back, and
that count by event in `events`;
- `mode`, when the request was not refused: `detect` or `observe`. - `mode`, when the request was not refused: `detect` or `observe`.
- `suppressed_repeats`: how many repeats the cooldown held back before this - `suppressed_repeats`: how many repeats the cooldown held back before this
alert. alert.
+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
+3 -3
View File
@@ -1,8 +1,8 @@
# The metrics # The metrics
The metrics `GET /_smallwebwaf/metrics` answers with, as "Metrics endpoint" in What `GET /_smallwebwaf/metrics` answers with. "Metrics endpoint" in
[`SPEC.md`](../SPEC.md) describes it. Prometheus keeps the `instance` label as [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus keeps the `instance`
`exported_instance` unless the scrape sets `honor_labels: true`. label as `exported_instance` unless the scrape sets `honor_labels: true`.
## Traffic ## Traffic
+18 -13
View File
@@ -91,24 +91,24 @@ that take `off`, the `_FILE` form, and what each group does and why.
## Static lists ## Static lists
- `SWWAF_ALLOW_NETS` (default empty): netblocks you trust, such as your - `SWWAF_ALLOW_NETS` (default empty): netblocks you trust, such as your
monitoring. Their clients are refused only for a size or time limit or a monitoring (see step 3 in [`SPEC.md`](../SPEC.md)).
missing or wrong token.
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are - `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
exempt from rate limiting and the byte limits. exempt from rate limiting and the byte limits.
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are always refused. - `SWWAF_DENY_NETS` (default empty): netblocks whose clients are refused (see
step 3 in [`SPEC.md`](../SPEC.md)).
## 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,12 +131,18 @@ 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
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. - `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
the start while a setting that needs a lookup is set:
- the two country lists;
- `SWWAF_ADD_LOOKUP_HEADERS`;
- the four biased threshold lists and `SWWAF_ASN_LIMIT_PERCENT_URL`;
- `SWWAF_UNKNOWN_LIMIT_PERCENT` below `100`;
- an anomaly threshold per AS number.
- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its - `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
`.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`. `.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`.
- `SWWAF_LOOKUP_TIMEOUT` (default `1s`): how long a request waits for its - `SWWAF_LOOKUP_TIMEOUT` (default `1s`): how long a request waits for its
@@ -190,8 +196,7 @@ that take `off`, the `_FILE` form, and what each group does and why.
- `SWWAF_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body - `SWWAF_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body
the Core Rule Set reads, a size of at most `1G`. the Core Rule Set reads, a size of at most `1G`.
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client - `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client
in a minute for a rule match, a trap path, a Core Rule Set match or a failed in a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
token check. A count or `off`.
## Ban lengths, response and scope ## Ban lengths, response and scope
@@ -286,7 +291,7 @@ as `REQUESTS_PER_MINUTE`:
client; client;
- `SWWAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6 - `SWWAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
client. client.
- `SWWAF_ANOMALY_ASN_...`: per AS number. Needs a lookup source. - `SWWAF_ANOMALY_ASN_...`: per AS number.
- `SWWAF_ANOMALY_TOTAL_...`: the whole service. - `SWWAF_ANOMALY_TOTAL_...`: the whole service.
- `SWWAF_WATCH_...`: each netblock in `SWWAF_WATCH_NETS`, as a whole. - `SWWAF_WATCH_...`: each netblock in `SWWAF_WATCH_NETS`, as a whole.
`SWWAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such `SWWAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
+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": "",