8 Commits
Author SHA1 Message Date
sneak a62909dbf4 Last pass: the sixth README review and the fifth SPEC review
check / check (push) Canceled after 0s
The spec names every fault that stops the start, runs step 15 for every
request but the health check, and states each of the fourteen repeated
requirements once. docs/alerts.md says which alerts have an empty client
and netblock and what a summary's detail holds; docs/settings.md lists
the settings that need a lookup and points at step 3 for the two static
lists. The README and the spec now travel in one branch, so the README
never names a spec section that does not exist yet.

Model: fable-5-1
2026-10-22 23:02:00 +00:00
sneak ef03d15ee2 README rewritten from scratch so a newcomer can follow it (closes #154, closes #155)
The old README opened with project history and one list of a hundred
settings. The new one leads with what smallwebwaf is, what it protects
against, how to build and deploy it, what happens to a request, the
settings most deployments touch, how to check it works, the entrypoints,
the rationale and the design, as REPO_POLICIES.md requires. The reference
lists, every setting, the rule file format, the endpoints, the request
log's fields, the alert events, the metrics, the state files, the image
and the gitea notes, move to files under docs/, each linked from the
README, with SPEC.md as the one place for behaviour. Every statement was
checked against the code on next.

Model: fable-5-1
2026-10-22 22:54:08 +00:00
sneak 23908b0e33 SPEC: false statements fixed, repeats removed, dropped requirements restored
check / check (push) Canceled after 0s
The bounds sentence, the blocklist URL sentence, the deny list sentence,
the limit action's scope and the observe mode sentence now say what the
code does. Each of the twenty-one requirements the fourth review found
twice is stated once, with a link from the other place. The request id
header, redirect_uri's case, change 4's reason and the live archive's
InRelease files are back. The biased thresholds are defined where they
are introduced. The docs files apply the fifth README review's findings
and stay identical to the README branch.

Model: fable-5-1
2026-10-22 22:35:29 +00:00
sneak 4ea0a975c8 SPEC: the one place for behaviour and reasons; docs/ as lookup lists only
check / check (push) Canceled after 0s
docs/core-rule-set.md, docs/lookup.md, docs/reputation.md and docs/bans.md
restated the spec and are folded back into it and removed. The remaining
docs files hold only lists: settings with their defaults and value forms,
the request log's fields, the metrics, the endpoints, the state files and
their contents, the rule file format and default file, the alert events
and messages, the image's contents and pins, and the gitea notes. Every
behaviour each of them held moves to the spec, said once. The settings
groups have the same names in both files. Stale comments in
internal/state and script/example-app point at the right places.

Model: fable-5-1
2026-10-22 21:51:39 +00:00
sneak d712bb1496 SPEC: requirements and reasons only, reference material in docs/
check / check (push) Canceled after 0s
docs/settings.md is now the one settings reference; the spec keeps the
conventions, the off list and each group's requirements and reasons, and
links to it for the per-setting entries. The endpoint list, the ban
notes, the state file contents, the rule file format and the alert
object likewise live in docs/ and are linked from the spec. The package
list from the README's Design section joins "Architecture". The nine
docs files are byte-identical to those on the README branch. Package
comments that named "Alert webhook schema", "Request log" and "Metrics
endpoint" for their field lists now name the docs files.

Model: fable-5-1
2026-10-22 21:08:26 +00:00
sneak eff99f0b13 SPEC: each requirement once, lists moved to docs/, repeats and sections dropped
check / check (push) Canceled after 0s
Every requirement the second review found in two to five places is now
stated once, with links from the other places. "Risks the design has to
handle", "Rollout per service" and most of "Architecture" are gone, their
two points of their own moved to "Attack detection" and "Anomaly
thresholds". The request log's fields, the metrics and the image's
contents move to docs/request-log.md, docs/metrics.md and docs/image.md,
each linked from the spec. The off rule names SWWAF_LOOKUP_SOURCE and
SWWAF_WAF_MODE, the reputation bound is given, and the six Core Rule Set
changes are a numbered list.

Model: fable-5-1
2026-10-22 20:43:32 +00:00
sneak e44ba7ee5e SPEC rework after review: each requirement once, gitea notes to docs/
check / check (push) Canceled after 0s
Every requirement is stated once, where it belongs, and linked from
elsewhere. The notes on gitea move to docs/gitea.md, which
script/gitea-requests points at. The list of settings that take off
is exact, the non-goal that contradicted the planned item is gone,
and the sentences the review named are split or reordered. The
package comments in internal/waf and internal/anomaly point at the
renamed sections.

Model: fable-5-1
2026-10-22 19:43:03 +00:00
sneak 7fb8662e69 SPEC rewritten from scratch, short and plain, built and planned apart (closes #156)
check / check (push) Canceled after 0s
The old SPEC opened with a draft status and milestone narrative, said
several requirements twice, and ended with a build order that is all
built. The new one keeps every requirement the old one states, said
once, in short sentences with bulleted lists in place of comma chains.
What the code on next does is in the main sections; the two points the
old SPEC deferred, sharing bans between instances and RELP, are in a
last section, "Planned, not built", each marked as planned. The status
line, the milestones and the build order are dropped as history. Where
the old SPEC disagreed with the code, the new one follows the code; the
PR body lists each such point.

Model: fable-5-1
2026-10-22 18:52:51 +00:00
9 changed files with 245 additions and 195 deletions

No files matched your search

+132 -59
View File
@@ -8,7 +8,8 @@ scanners and other abusers before they reach the app.
A web application firewall reads each request on its way to a web app and
refuses the ones that look like abuse. `smallwebwaf` is also a reverse proxy: it
accepts requests on the app's behalf, passes them to the app, and passes the
app's answers back.
app's answers back. A request it does not refuse reaches the app as it came,
apart from a few headers (see "Design" below).
It needs no setting. Every setting has a default chosen for a service on the
open internet. [`SPEC.md`](SPEC.md) says how `smallwebwaf` behaves and why. The
@@ -17,10 +18,10 @@ reference documents under [`docs/`](docs/) are listed under "Documents" below.
## What it protects against
- 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
limits give it 4 GiB a minute, 20 GiB an hour and 200 GiB a day. A client over
a limit is refused and banned. The first ban lasts an hour. Each repeat within
a day makes the next ban three times as long. A ban that would last more than
client 1000 requests a minute, 10,000 an hour and 50,000 a day. Byte limits
give it 10 GiB a minute, 20 GiB an hour and 50 GiB a day. A client over a
limit is refused and banned. The first ban lasts an hour. 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
default rule file bans such a client for seven days at its first probe. Its
@@ -29,8 +30,8 @@ reference documents under [`docs/`](docs/) are listed under "Documents" below.
of your own, or an entry in `SWWAF_TRAP_PATHS`, bans those the same way.
- Attacks carried in a request's URL or headers, such as SQL injection and path
traversal. The OWASP Core Rule Set is a published set of rules that recognise
common web attacks. Coraza, a Go engine, runs those rules (see "Attack
detection" in [`SPEC.md`](SPEC.md)). A request the rules take for an attack is
common web attacks. Coraza, a Go engine, runs those rules (step 11 under "What
happens to a request" below). A request the rules take for an attack is
refused.
- Abuse from networks and countries you do not want. You can:
- refuse whole netblocks and countries;
@@ -141,32 +142,89 @@ while working on it, see "Entrypoints" below.
## What happens to a request
`smallwebwaf` handles each request in fifteen steps, in this order. Once a step
refuses a request, the steps after it are skipped, apart from step 15, and
nothing of the request reaches the app. "What happens to a request" in
[`SPEC.md`](SPEC.md) gives each step in full, under the same numbers.
Two terms recur below. A clear sign of attack is a request that matches a rule
whose action is `ban`, or asks for a trap path (steps 9 and 10). An offence is a
request that counts against its client in its history. It is one of:
1. The health check, `GET /_smallwebwaf/healthz`, is answered at once.
2. The client is identified.
3. The static lists, `SWWAF_ALLOW_NETS` and `SWWAF_DENY_NETS`.
4. The bans.
5. The lookup of the client's AS number and country.
6. The country lists.
7. The reputation sources.
8. The client's limit percentage.
9. The rate limits.
10. The trap paths.
11. The rule files.
12. The Core Rule Set.
13. `smallwebwaf`'s own endpoints, under `/_smallwebwaf/`.
14. The request is passed to the app.
15. After the answer: the byte limits, the error burst and the client's history.
- a clear sign of attack;
- a broken limit;
- a refusal by a rule, by the Core Rule Set or for a missing or wrong token.
A refused visitor is answered with `SWWAF_BAN_RESPONSE`, `403` by default (see
"Ban lengths, response and scope" in [`SPEC.md`](SPEC.md)). A `block` rule or a
Core Rule Set match answers `403`. A missing or wrong token gets `401`. A
request over a size or time limit gets the status for that limit, such as `413`
for a body too large (see "Size and time limits" in [`SPEC.md`](SPEC.md)).
`smallwebwaf` handles each request in this order. Once a step refuses a request,
the steps after it are skipped, apart from step 13. Nothing of a refused request
reaches the app. "What happens to a request" in [`SPEC.md`](SPEC.md) gives each
step in full.
1. `GET /_smallwebwaf/healthz` is answered `200` with `ok` at once, before any
check and without asking the app.
2. The client is worked out. A netblock is a range of addresses, written as an
address and a prefix length: `203.0.113.0/24`. `SWWAF_TRUSTED_PROXIES` lists
the netblocks traefik comes from, by default the private address ranges. For
a TCP peer inside them, `X-Forwarded-For` is read from the right, and the
first address outside those netblocks is the client. A peer outside them is
the client itself. An IPv6 client is its group: the whole netblock around its
address, a /64 by default; `SWWAF_IPV6_GROUP_PREFIX` sets its length.
3. A client in `SWWAF_ALLOW_NETS` skips steps 4 to 11, the byte limits and the
error burst, but still needs a token at step 12. A client in
`SWWAF_DENY_NETS` is refused.
4. A client under a ban is refused. If the ban was made for a clear sign of
attack, this request makes it permanent.
5. The client's AS number and country are looked up, unless
`SWWAF_LOOKUP_SOURCE` is `off`. An AS number names the network an address
belongs to, such as `AS64496`.
6. The country lists are checked. A client from a country in
`SWWAF_DENIED_COUNTRIES` is refused. When
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set, a client from any other country
is refused, and so is a client without a country. Such a refusal is not an
offence.
7. The reputation sources are checked: the blocklists, the CrowdSec decision
list, the DNS blocklists and AbuseIPDB. A client on the CrowdSec decision
list is banned until the decision ends. Any other source refuses the client,
lowers its limits or only notes it, as `SWWAF_BLOCKLIST_ACTION` and
`SWWAF_REPUTATION_ACTION` say. No request waits for a query.
8. Rate limiting counts the request, unless the client or the path is exempt. A
request that takes the client over a limit is refused and bans the client.
9. A request for a path in `SWWAF_TRAP_PATHS` is refused and bans the client, as
a clear sign of attack.
10. The rule files are checked. A `log` rule notes the match. A `block` rule
refuses the request. A `ban` rule refuses it and bans the client, as a clear
sign of attack.
11. The Core Rule Set inspects the request's method, URL and headers, and, once
`SWWAF_WAF_BODY_LIMIT` is set, form, JSON and XML bodies. A request it takes
for an attack is refused while `SWWAF_WAF_MODE` is `block`, the default, and
let through while it is `detect`. It bans no one by itself.
12. A request under `/_smallwebwaf/` is answered here by `smallwebwaf`, after
its token is checked. Any other request is passed to the app. The app's
answer is streamed back within the size and time limits.
13. Once the answer has ended, a request passed to the app has its bytes counted
for the byte limits. If a rule, a trap path, the Core Rule Set or a token
check refused the request, the refusal is counted for the error burst. The
client's history and the anomaly counters are updated. More than
`SWWAF_ERROR_BURST_THRESHOLD` refusals of one client in a minute ban it.
In `observe` mode (`SWWAF_MODE=observe`), `smallwebwaf` makes no ban of its own.
It refuses a request only for a size or time limit, or for a missing or wrong
token. A request it would otherwise have refused is passed to the app, and its
log line says what `enforce` mode would have done. Observe mode is for trying a
configuration before enforcing it.
What a refused visitor sees:
- `SWWAF_BAN_RESPONSE`, by default `403` with the plain text `Forbidden`. It can
be `429` instead, or `close`, which closes the connection, so that traefik
answers `502`. It answers:
- a ban or a broken rate limit;
- a `ban` rule or a trap path;
- `SWWAF_DENY_NETS` and the country lists;
- a reputation source whose action is `deny`.
- `403` for a `block` rule and the Core Rule Set.
- For a size or time limit:
- `431` for headers over their limit;
- `413` for a body over its limit;
- `408` for a client too slow to send its body;
- `502` or `504` when the app passes a limit or cannot be reached.
- `401` from an endpoint of `smallwebwaf`'s own, for a request without its
token.
## Settings you are likely to touch
@@ -175,11 +233,8 @@ Each setting is an environment variable of the app's container, such as
[`docs/settings.md`](docs/settings.md) lists every setting, its default and the
values it takes. The ones most deployments touch:
- `SWWAF_MODE=observe`: `smallwebwaf` makes no ban of its own, and refuses a
request only for a size or time limit or a missing or wrong token. A request
it would have refused is passed to the app, and its log line says what
`enforce` mode would have done. For trying a configuration before enforcing
it.
- `SWWAF_MODE=observe`: log what would have been refused, refuse almost nothing,
ban no one. For trying a configuration.
- `SWWAF_ALLOW_NETS`: netblocks you trust, such as your monitoring. Their
clients are refused only for a size or time limit or a missing or wrong token.
- `SWWAF_RATE_LIMIT_EXEMPT_NETS`: netblocks exempt from rate limiting and the
@@ -321,32 +376,50 @@ nixpkgs fixed at one release. An app's `Dockerfile` installs a package with
[`docs/image.md`](docs/image.md) lists what the image holds and how each part is
pinned.
- runit, a service supervisor, runs `smallwebwaf` and the app side by side, each
as its own user, and starts either again when it exits. The app cannot touch
the state files or the process of `smallwebwaf`.
- `smallwebwaf` listens on port 8080 and passes requests to the app on
`127.0.0.1:8081`. The app listens on `127.0.0.1` only, so nothing reaches it
except through `smallwebwaf`. `smallwebwaf`'s own endpoints are under
`/_smallwebwaf/`, on the same port.
- The app sees the headers traefik sent, with traefik's address added to
`X-Forwarded-For`, from a TCP peer on `127.0.0.1`. So the app must trust
`127.0.0.1` and `::1` for forwarded headers, besides the private address
ranges.
- Both processes start with the container's environment variables, so every
setting of `smallwebwaf` starts with `SWWAF_`, and the app can read a token
given as one. A token given as a file that only the `smallwebwaf` user can
read is out of its reach (see "Settings given as files" in
- The container's first process is `runsvinit`, which starts runit, a service
supervisor. runit runs the `run` script of each directory under
`/etc/service`, and runs it again a second after its program exits.
`smallwebwaf` runs as the `smallwebwaf` user (uid 65532) and the app as a user
of its own. So the app cannot touch the state files or the process of
`smallwebwaf`.
- `smallwebwaf` listens on port 8080, on every address. traefik sends the app's
requests there. `smallwebwaf` passes them to the app on `127.0.0.1:8081`. The
app listens on `127.0.0.1` only, so nothing reaches it except through
`smallwebwaf`. The health check, the metrics and the ban management are on the
same port, under `/_smallwebwaf/`. A request for that prefix never reaches the
app.
- The app sees what it would see from traefik directly: the same `Host`, the
same `X-Forwarded-Proto`, and `X-Forwarded-For` with traefik's address added
at the end. The app's TCP peer is `smallwebwaf` on `127.0.0.1`. So the app
must trust `127.0.0.1` and `::1` for forwarded headers, besides the private
address ranges. An app that keeps its usual trusted proxies sees every visitor
as `127.0.0.1`. Each request also carries its id in `X-Request-ID`. Any
`X-Client-ASN` or `X-Client-Country` header a client sent is removed.
- Both processes start with the container's environment variables. That is why
every setting of `smallwebwaf` starts with `SWWAF_`. A token given as an
environment variable is readable by the app. A token given as a file that only
the `smallwebwaf` user can read is not (see "Settings given as files" in
[`docs/settings.md`](docs/settings.md)).
- The state files live in `/var/lib/smallwebwaf`. A volume mounted there keeps
them across a deploy.
- `docker stop` stops both processes at once. `smallwebwaf` gives the requests
in progress five seconds to finish, so the app must keep answering for five
- The state files live in `/var/lib/smallwebwaf`. Without a volume there, they
survive a restart of the container and are lost when a deploy replaces it. The
`run` script gives that directory and every file in it to the `smallwebwaf`
user at each start, so a host directory mounted there needs no change of
owner.
- The image's health check passes while `smallwebwaf` answers
`GET /_smallwebwaf/healthz` and the app accepts connections. traefik sends a
container no requests until it is healthy.
- When `smallwebwaf` or the app exits, runit starts it again a second later. The
other process keeps running. While either is down, the client gets `502`. A
`smallwebwaf` that stops at start, as on an invalid setting, is started again
every second until the cause is fixed, and logs its message each time.
- `docker stop` has runit send `SIGTERM` to both processes at once.
`smallwebwaf` gives the requests in progress five seconds to finish and writes
its state files. To let them finish, the app must keep answering for five
seconds after `SIGTERM`.
"Deployment" in [`SPEC.md`](SPEC.md) has the rest: the health check, what
happens when a process stops, moving `smallwebwaf` to another port, and the
forwarded headers. "Architecture" in [`SPEC.md`](SPEC.md) lists the packages and
what each does.
"Deployment" in [`SPEC.md`](SPEC.md) has the rest: moving `smallwebwaf` to
another port, the health check's timing, and the forwarded headers.
"Architecture" in [`SPEC.md`](SPEC.md) lists the packages and what each does.
## TODO
+64 -78
View File
@@ -164,8 +164,8 @@ its rules.
thresholds are evaluated.
In `SWWAF_MODE=observe`, every refusal above is logged as what would have
happened, nothing is refused, and `smallwebwaf` makes no ban of its own. The
size and time limits and the token check still apply.
happened, nothing is refused, and no ban is made. The size and time limits and
the token check still apply.
## Counting method
@@ -183,8 +183,8 @@ size and time limits and the token check still apply.
GeoJS answers, the reputation verdicts and scores, the DNS queries under way
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
longest ago goes first, and the verdict or score fetched longest ago. Past the
bound on DNS queries under way, a zone is asked at the client's next request.
longest ago goes first, and the verdict or score fetched longest ago. Past
that bound, a zone is asked at the client's next request.
## Bans
@@ -215,12 +215,9 @@ to a ban, each by its own rule:
permanent this way.
- Once that ban has run out, the netblock is served like any other. Its next
clear sign of attack bans it permanently at once.
- A broken limit, one of:
- a request over a rate limit;
- 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:
- A broken limit: a request over a rate limit, a response that takes the
client's byte total past a byte limit, or more than
`SWWAF_ERROR_BURST_THRESHOLD` refusals within a minute.
- The first such ban lasts `SWWAF_LIMIT_BAN_DURATION` (default `1h`), with
the cause `limit`. So does one that comes more than
`SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`) after the last such ban
@@ -236,7 +233,7 @@ to a ban, each by its own rule:
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
does the same. Those refusals count toward the error burst, so a client that
keeps setting them off is banned for a broken limit.
keeps setting them off is banned under the second rule.
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
@@ -331,8 +328,8 @@ Conventions:
- `SWWAF_ADMIN_TOKEN` switches on the ban endpoints and
`/_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.
The two are separate, so the metrics collector, which holds the metrics token,
cannot manage bans.
The two are separate, so a scraper that holds the metrics token cannot manage
bans.
- 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
the same environment variables as `smallwebwaf`, so it can read a token set as
@@ -368,23 +365,16 @@ Conventions:
- `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
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
through Tor. They are one client, and share its limits and its bans. The
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`.
- Many clients share one address (mobile carriers, offices, Tor), and so share
its limits and its bans. `SWWAF_ALLOW_NETS` and `SWWAF_RATE_LIMIT_EXEMPT_NETS`
take known shared addresses out.
### Request rate limits, per client
Breaking one bans the client, so the defaults sit about twice above what 40
people busy with heavy web apps make together on one address. Each such person
makes about 75 requests in a busy minute, and 1,250 an hour for ten hours a day.
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`.
Breaking one bans the client, so the defaults sit several times above what one
busy person produces. A browser loading a heavy page makes a few hundred
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`.
`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
@@ -397,12 +387,9 @@ matched the same way.
### Byte limits, per client
Each of the same 40 people moves about 256 MiB an hour, for ten hours a day. The
hour and day limits sit about twice above what they move together. A response's
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.
A response's bytes are counted when it ends (see step 15). So every default sits
above the largest request and answer together, and no single download breaks a
limit.
### Size and time limits
@@ -466,8 +453,8 @@ service that keeps its visitors' addresses on its own host.
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
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` leaves
the country unknown, and the `asn` `64512` leaves the AS number unknown.
and `organization_name` are read. An answer without a `country_code`, or with
the `asn` `64512`, counts as unknown.
- 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
dropped from that table. The answer reaches the client's history and ban notes
@@ -541,12 +528,12 @@ service's abuse comes from.
fetched and kept like a blocklist (see "Reputation"). Where it and
`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
number's, its country's, or a reputation hit's `limit:<percent>`. A client
without a country gets `SWWAF_UNKNOWN_LIMIT_PERCENT` in place of a country's.
The percentage is 100 if none applies. Clients given a lower percentage are
banned after fewer requests, first for a while and then, for repeated abuse,
permanently. The log line of each counted request, and the notes of each ban,
say which setting lowered the limit.
number's, its country's, or a reputation hit's `limit:<percent>`. A client the
lookup cannot place gets `SWWAF_UNKNOWN_LIMIT_PERCENT` in place of an AS
number's or a country's. The percentage is 100 if none applies. Clients given
a lower percentage are banned after fewer requests, first for a while and
then, for repeated abuse, permanently. The log line of each counted request,
and the notes of each ban, say which setting lowered the limit.
- 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
one this tool exists to prevent. Refusing a whole country is left to the
@@ -557,14 +544,15 @@ service's abuse comes from.
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:
injection in query strings, path traversal, attacks carried in headers,
scanners' user agents. It reads no request body. In front of a code forge such
as gitea, bodies carry what people write and publish, such as issue text and
scanners' user agents. It reads no request body. On a code forge, the example
deployment, 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
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
sends it.
- The Core Rule Set skips a path under `SWWAF_WAF_EXEMPT_PATHS`.
- The Core Rule Set 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
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.
@@ -645,12 +633,12 @@ every app, and no setting undoes them:
- `ref`, `sha`, `branch` and `workflow`;
- `artifactName` and `redirect_to`.
gitea puts into them file paths, branch and workflow names, and the page to
return to after signing in. It reads these values as names in a repository
gitea puts file paths, branch and workflow names, and the page to return to
after signing in into them. 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
name on those lists can be an ordinary file or branch, such as `.gitignore`
or `docker-build`. In these parameters, what only these three rules would
catch reaches the app, such as the command `|cat /etc/passwd`. Path
or `docker-build`. In these parameters, anything only these three rules
catch reaches the app, commands such as `|cat /etc/passwd` included. Path
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
that uses one of these parameters as a file on the server, or passes it to a
@@ -659,8 +647,8 @@ every app, and no setting undoes them:
5. Two cookies are left out, and `Referer` keeps fewer rules:
- 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
`gitea_flash`. It puts into `redirect_to` the page to return to after
signing in. The Core Rule Set takes file names in them, such as
`gitea_flash`. It puts the page to return to after signing in into
`redirect_to`. The Core Rule Set takes file names in them, such as
`package.json`, for attacks. A browser sends such a cookie with every
request until gitea replaces it. gitea cannot replace it while those
requests are refused, so one refusal would keep the browser out of the
@@ -784,8 +772,7 @@ too close together.
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
in `reputation.json`. A source that is down or over quota gives no verdict.
The service continues. Each failure raises a `source_failure` alert (see
"Alerting").
The service continues, with one `source_failure` alert per cooldown.
### Alerting
@@ -874,11 +861,11 @@ and the default file.
wrong for every app, with its path rules anchored at the site root. In front
of gitea, `/.env` is a probe, while
`/<owner>/<repo>/src/branch/main/.env.example` is a file in a repository that
any visitor may open. Anchor the paths of your own `ban` rules at the site
root too. Anything app-specific, such as a rule banning WordPress probes in
front of gitea, belongs in a file the app's Dockerfile adds or the deployer
mounts. A directory mounted over `SWWAF_RULES_DIR` replaces the default file;
single files mounted into it add to it.
any visitor may open. `ban` rules of your own follow the same rule. Anything
app-specific, such as a rule banning WordPress probes in front of gitea,
belongs in a file the app's Dockerfile adds or the deployer mounts. A
directory mounted over `SWWAF_RULES_DIR` replaces the default file; single
files mounted into it add to it.
- 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
container keeps seeing the old one when the file alone is mounted. The lookup
@@ -887,11 +874,11 @@ and the default file.
## Persistent state
All state lives in memory, and the files in `SWWAF_STATE_DIR` hold a copy of it,
so an orderly stop loses nothing. The files are meant for people as well: an
admin can read or edit them at any time, and the running `smallwebwaf` takes the
edit in. `docs/state-files.md` lists the files, what each holds, and their
sizes.
All state lives in memory, and the files in `SWWAF_STATE_DIR` hold a copy of all
of it, so an orderly stop loses nothing (the remote log is the exception; see
"Request log"). The files are meant for people as well: an admin can read or
edit them at any time, and the running `smallwebwaf` takes the edit in.
`docs/state-files.md` lists the files, what each holds, and their sizes.
- Each file holds one kind of state, as JSON with:
- a top-level `version` number;
@@ -1032,14 +1019,14 @@ what the image holds and how each part is pinned.
digest.
- 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
directly; no Nix daemon runs. `nix-bin` creates no build users, so
`/etc/nix/nix.conf` sets `build-users-group =`, which lets root build without
them. Root's Nix profile is last on the `PATH`, so that no package hides the
image's own commands; busybox's `sv`, for one, looks for services elsewhere.
An app built on the image gets the same packages each time. Security fixes
from Ubuntu and nixpkgs come with a newer `smallwebwaf` image, which an app
takes by changing the digest in its `FROM` line. The image moves to each new
nixpkgs release, every six months.
directly; no Nix daemon runs, and since `nix-bin` creates no build users,
`build-users-group =` in `/etc/nix/nix.conf` lets root build without them.
Root's Nix profile is last on the `PATH`, so that no package hides the image's
own commands; busybox's `sv`, for one, looks for services elsewhere. An app
built on the image gets the same packages each time. Security fixes from
Ubuntu and nixpkgs come with a newer `smallwebwaf` image, which an app takes
by changing the digest in its `FROM` line. The image moves to each new nixpkgs
release, every six months.
- runit supervises the two processes, with `runsvinit` as the entrypoint, as the
owner's code style guide asks for service containers. No package carries
`runsvinit`, so the image builds it from its archived source, unchanged since
@@ -1088,10 +1075,10 @@ It sets no `ENTRYPOINT` or `USER` of its own. The container must start
- an unwritable `SWWAF_STATE_DIR`;
- a `SWWAF_RULES_DIR` that does not exist or cannot be read;
- a `SWWAF_LISTEN_ADDR` it cannot listen on.
- A `smallwebwaf` that stops at start logs its message at each try until the
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.
- A `smallwebwaf` that stops at start is therefore started again every second,
logging its message each time, until the 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
services at once, each with `SIGTERM`, and exits once they have stopped.
`smallwebwaf` stops taking requests, gives those in progress five seconds to
@@ -1160,10 +1147,9 @@ all: `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1/32,::1/128`.
### upaas
An app deployed by upaas, which rebuilds and restarts an app's Docker container
when its repository changes, takes this shape with no change to upaas. upaas
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
An app deployed by upaas takes this shape with no change to upaas. upaas 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
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,
+5 -5
View File
@@ -42,7 +42,7 @@ on one line:
"asn": "",
"as_name": "",
"country": "",
"reason": "requests per minute over the limit of 6000",
"reason": "requests per minute over the limit of 1000",
"detail": {
"ban_expires": "2026-10-06T13:00:00.123Z",
"cause": "limit",
@@ -51,9 +51,9 @@ on one line:
"as_name": "",
"country": "",
"kind": "requests",
"limit": 6000,
"limit": 1000,
"window": "minute",
"count": 6001,
"count": 1001,
"request": {
"time": "2026-10-06T12:00:00.123456789Z",
"method": "GET",
@@ -62,7 +62,7 @@ on one line:
"status": 403,
"user_agent": "scraper/1.0"
},
"requests": 8210,
"requests": 5210,
"refused": 0,
"earlier_bans": {
"limit": 0,
@@ -89,7 +89,7 @@ on one line:
- `asn`, `as_name` and `country`: the client's, as known when the alert was
raised. They are empty when GeoJS had not answered yet.
- `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:
- for a ban, its `cause`, `ban_expires` and `notes` (the notes are listed
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
by it.
- A clone is a response. It fits the defaults of 30 minutes and 1 GiB unless the
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 clone is a response. It fits the defaults of 30 minutes and 5 GiB for all
but the largest repositories and slowest links.
- A push is a request, and so is every other upload:
- a git push;
- 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.
- 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,
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
172,800 requests, under the request limits. Six such runners on one address,
each busy all day, pass the day limit of 1,000,000 within two days; five stay
under it. Their address is then banned, their running jobs fail, and each
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`.
43,200 requests a day, and reports a running job's log and state every second.
So it passes the day limit of 50,000 after about an hour and a quarter of
jobs. It is then banned, the job it is running fails, and each repeat within a
day makes the ban longer. Newer runners ask less often, but a busy one, or
several on one address, can still pass it. Put the runners' addresses in
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
## What the Core Rule Set lets through
+8 -7
View File
@@ -99,16 +99,16 @@ that take `off`, the `_FILE` form, and what each group does and why.
## Request rate limits, per client
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `6000`), `SWWAF_RATE_LIMIT_PER_HOUR`
(default `100000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `1000000`): the
most requests a client may make in each window. A count or `off`.
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `1000`), `SWWAF_RATE_LIMIT_PER_HOUR`
(default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`): the most
requests a client may make in each window. A count or `off`.
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
rate limiting and the byte limits, such as `/assets/`.
## Byte limits, per client
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `4G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `200G`): the most
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `10G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `50G`): the most
bytes counted for a client in each window. A size or `off`.
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
`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`.
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body. A size
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`.
## Lookup of AS number and country
@@ -196,7 +196,8 @@ 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
the Core Rule Set reads, a size of at most `1G`.
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client
in a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
in a minute for a rule match, a trap path, a Core Rule Set match or a failed
token check. A count or `off`.
## Ban lengths, response and scope
+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"),
UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"),
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", ""),
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""),
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "6000"),
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "100000"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "1000000"),
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "1000"),
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"),
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"),
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"),
LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"),
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""),
+12 -12
View File
@@ -261,10 +261,10 @@ func TestDefaults(t *testing.T) {
UpstreamRequestTimeout: time.Minute,
UpstreamResponseTimeout: 30 * time.Minute,
RequestMaxBytes: 100 << 20,
ResponseMaxBytes: 1 << 30,
RateLimitPerMinute: 6000,
RateLimitPerHour: 100000,
RateLimitPerDay: 1000000,
ResponseMaxBytes: 5 << 30,
RateLimitPerMinute: 1000,
RateLimitPerHour: 10000,
RateLimitPerDay: 50000,
BanResponse: 403,
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour,
@@ -284,8 +284,8 @@ func TestDefaults(t *testing.T) {
LookupSource: defaultLookupSource, LookupTimeout: time.Second,
})
wantByteLimitSettings(t, cfg, config.Config{
BytesLimitPerMinute: 4 << 30, BytesLimitPerHour: 20 << 30,
BytesLimitPerDay: 200 << 30, BytesCount: "both",
BytesLimitPerMinute: 10 << 30, BytesLimitPerHour: 20 << 30,
BytesLimitPerDay: 50 << 30, BytesCount: "both",
})
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
@@ -2371,17 +2371,17 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
upstreamRequestTimeout: "60s",
upstreamResponseTimeout: "30m",
requestMaxBytes: "100M",
responseMaxBytes: "1G",
responseMaxBytes: "5G",
allowNets: "",
rateLimitExemptNets: "",
denyNets: "",
rateLimitPerMinute: "6000",
rateLimitPerHour: "100000",
rateLimitPerDay: "1000000",
rateLimitPerMinute: "1000",
rateLimitPerHour: "10000",
rateLimitPerDay: "50000",
rateLimitExemptPaths: "",
bytesLimitPerMinute: "4G",
bytesLimitPerMinute: "10G",
bytesLimitPerHour: "20G",
bytesLimitPerDay: "200G",
bytesLimitPerDay: "50G",
bytesCount: "both",
lookupSource: defaultLookupSource,
lookupDBPath: "",
+6 -13
View File
@@ -60,19 +60,8 @@ func TestHealthCheck(t *testing.T) {
}, 0, "")
}
// Another test can take a closed server's port at once, so the app's
// 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: ")
app.Close()
wantHealthCheck(t, env, 1, "unhealthy: connect to the app: ")
stop()
@@ -81,6 +70,10 @@ func TestHealthCheck(t *testing.T) {
case <-time.After(waitLimit):
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) {
+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_RESPONSE_TIMEOUT": "30m",
"SWWAF_REQUEST_MAX_BYTES": "100M",
"SWWAF_RESPONSE_MAX_BYTES": "1G",
"SWWAF_RESPONSE_MAX_BYTES": "5G",
"SWWAF_ALLOW_NETS": "",
rateLimitExemptNets: "",
"SWWAF_DENY_NETS": "",
"SWWAF_RATE_LIMIT_PER_MINUTE": "6000",
"SWWAF_RATE_LIMIT_PER_HOUR": "100000",
rateLimitPerDay: "1000000",
"SWWAF_RATE_LIMIT_PER_MINUTE": "1000",
"SWWAF_RATE_LIMIT_PER_HOUR": "10000",
rateLimitPerDay: "50000",
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
"SWWAF_DENIED_COUNTRIES": "",
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",