SPEC and README: bodies unread by default, one listener, GeoJS by default (closes #6)

Address the third review of the spec update and sneak's two new rulings.
By default the Core Rule Set reads the URL, query string and headers but
no request body, since a code forge's bodies carry code it takes for
attacks; `WAF_BODY_LIMIT` switches body inspection on. `Content-Encoding`
is allowed only on unread bodies, and OAuth sign-in from local tools
passes. The default rule file bans common probes for secrets, version
control, backups and logs at the site root. The spec names the Core Rule
Set 4.25.0. One listener answers the sidecar's own endpoints under
`/_smallwebwaf/`, behind tokens. GeoJS is the default lookup source. Size
suffixes are powers of 1024.

Model: opus-5-5
This commit is contained in:
2026-09-28 18:50:00 +00:00
parent 2d15e7e746
commit 4020ff83c6
2 changed files with 312 additions and 199 deletions
+35 -27
View File
@@ -55,15 +55,16 @@ goes through the candidates one by one.
counted by /64 by default.
- Size and time limits on requests and responses, both between the client and
`smallwebwaf` and between `smallwebwaf` and the app: by default a request may
take 60 seconds and 100 MB, a response 30 minutes and 5 GB.
take 60 seconds and 100 MiB, a response 30 minutes and 5 GiB.
- Rate limits per client on requests per minute, per hour and per day, and on
bytes per minute, per hour and per day, on by default and set well above what
real visitors need.
- Netblocks that bypass rate limiting, netblocks that bypass everything, and
netblocks that are always refused.
- AS number and country lookup for every client, off until you choose a source:
the IPinfo Lite database file, which you download and mount, or the free GeoJS
web service (see "Country and AS number lookup" below).
- AS number and country lookup for every client, on by default through the free
GeoJS web service, which is sent the address of every new visitor. The IPinfo
Lite database file, which you download and mount, can be used instead, or
lookups switched off (see "Country and AS number lookup" below).
- Country lists: `DENIED_COUNTRIES` refuses every request from the countries
listed, `EXCLUSIVELY_ALLOWED_COUNTRIES` every request from anywhere else. Such
a request gets the answer a banned client gets as soon as the client's address
@@ -78,7 +79,9 @@ goes through the candidates one by one.
scanning and penetration probes; easy to edit by hand, and picked up while
running;
- the OWASP Core Rule Set, run by the Coraza engine, refusing the requests
it flags;
it flags; it reads the URL and headers of every request, and request
bodies only once you switch that on, since on a code forge they are full
of code it would take for attacks;
- trap paths, and a ban for a client that the rule files or the Core Rule
Set refuse again and again.
- Bans:
@@ -102,14 +105,17 @@ goes through the candidates one by one.
- Request log: one JSON object per request on stdout with the usual web log
fields, the decision taken and why, AS number and country, and timings.
Optionally also sent to a remote syslog server.
- Prometheus metrics on their own port.
- Prometheus metrics, for a scraper that holds the metrics token.
- State (bans with their notes, each client's counters and history, the GeoJS
answers, the reputation cache, the alerting state) held in memory and kept in
readable JSON files, written regularly and at every stop, so a restart loses
nothing. Edit a file, or add a rule file, and the running `smallwebwaf` picks
up the change. Nothing is read from disk while serving a request.
- A small admin endpoint for health checks, listing, adding and lifting bans,
and asking why a given address was refused.
- Health checks, the metrics, and listing, adding and lifting bans or asking why
a given address was refused, all on the one port every request uses: under
`/_smallwebwaf/` on the app's own address, through traefik like any other
request. The metrics need the metrics token, and the ban management the admin
token.
Not planned: TLS termination, routing for several apps, browser challenges
(captcha or proof of work), a web console, or defence against floods large
@@ -182,30 +188,32 @@ failure behaviour and the build order.
## Country and AS number lookup
AS number and country lookups, and the country lists and biased limits that use
them, are off until you choose one of two sources with `LOOKUP_SOURCE`.
`LOOKUP_SOURCE=file` reads the free IPinfo Lite database (`ipinfo_lite.mmdb`).
You download it with your own IPinfo account, mount the directory that holds it
into the container, point `LOOKUP_DB_PATH` at the file and refresh it when you
choose; `smallwebwaf` never downloads it itself, and reads it again when you
replace it. It has to be the directory rather than the file itself: docker does
not show a single mounted file being replaced, so a refresh would go unseen.
IPinfo releases it under the Creative Commons Attribution-ShareAlike 4.0
International License and asks for attribution, in its own words on
https://ipinfo.io/lite: "The attribution requirements can be met by giving our
service credit as your data source. Simply place a link to IPinfo on the
website, application, or social media account that uses our data." Its example
of such a credit is a link mentioning "IP address data is powered by IPinfo". A
service that uses the database through `smallwebwaf` should carry that link.
`LOOKUP_SOURCE=geojs` asks the free GeoJS web service instead, with no account
and no file. Every new visitor's address is sent to GeoJS. Each answer is kept
`smallwebwaf` looks up the AS number and country of every client, for the
request log, the metrics and the ban notes, and for the country lists and biased
limits when you set them. It works with no setup: by default it asks the free
GeoJS web service, which needs no account and no file. This means that, by
default, the address of every new visitor is sent to GeoJS. Each answer is kept
for seven days, across restarts, and many addresses are asked about in one
request. GeoJS publishes no rate limit but may block a caller it thinks asks too
much; while it is not answering, new visitors count as coming from an unknown
country, which `EXCLUSIVELY_ALLOWED_COUNTRIES` refuses.
To keep your visitors' addresses on your own host, set `LOOKUP_SOURCE=off`, or
use the database file instead of GeoJS: `LOOKUP_SOURCE=file` reads the free
IPinfo Lite database (`ipinfo_lite.mmdb`). You download it with your own IPinfo
account, mount the directory that holds it into the container, point
`LOOKUP_DB_PATH` at the file and refresh it when you choose; `smallwebwaf` never
downloads it itself, and reads it again when you replace it. It has to be the
directory rather than the file itself: docker does not show a single mounted
file being replaced, so a refresh would go unseen. IPinfo releases it under the
Creative Commons Attribution-ShareAlike 4.0 International License and asks for
attribution, in its own words on https://ipinfo.io/lite: "The attribution
requirements can be met by giving our service credit as your data source. Simply
place a link to IPinfo on the website, application, or social media account that
uses our data." Its example of such a credit is a link mentioning "IP address
data is powered by IPinfo". A service that uses the database through
`smallwebwaf` should carry that link.
Neither source can place a private address, so a client on one, such as a
visitor on your local network, another container or your monitoring, has no
country: `EXCLUSIVELY_ALLOWED_COUNTRIES` refuses it unless you list it in
+277 -172
View File
@@ -30,8 +30,9 @@ directory of hand-editable text files.
- Defence against traffic floods that saturate the host's network link. That
needs help upstream of the host.
- A web UI or a configuration file. Settings are environment variables. Apart
from its own state files and the lookup database, the only files read are the
rule files, which hold one regex per line and nothing more elaborate.
from settings given as files (`NAME_FILE`, `LOG_REMOTE_TLS_CA_FILE`), its own
state files and the lookup database, the only files read are the rule files,
which hold one regex per line and nothing more elaborate.
- Sharing bans between sidecars in the first version: each sidecar keeps its
own. Running one CrowdSec engine per host, which every sidecar would report
its bans to, is reconsidered once real ban volumes are known. Reading a
@@ -44,17 +45,16 @@ directory of hand-editable text files.
that volume (`/data`), so docker supplies an anonymous one when none is
mounted, and a sidecar started with only `UPSTREAM_URL` has somewhere to
write.
- Three listeners, of which only the first is routed by traefik:
- the proxy listener;
- a metrics listener serving Prometheus metrics, which can be switched off;
- an admin listener for health and ban management.
- One listener, which traefik routes to. It forwards requests to the app, and it
answers the sidecar's own endpoints for health, metrics and ban management,
under `/_smallwebwaf/`, which never reach the app (see "Admin endpoints").
- Components inside the process:
- Client identification: works out the real client IP from
`X-Forwarded-For`, trusting only the proxy netblocks in `TRUSTED_PROXIES`,
by default the private address ranges.
- Lookup: AS number and country, from one of two sources the operator
chooses: a database file the operator supplies, held in memory, or the
GeoJS web service, whose answers are kept for seven days.
- Lookup: AS number and country, by default from the GeoJS web service,
whose answers are kept for seven days, or instead from a database file the
operator supplies, held in memory.
- Reputation: static blocklists fetched on a schedule; DNSBL and reputation
API queries made in the background and cached.
- Counters: per-client request and byte counts per minute, hour and day,
@@ -70,8 +70,7 @@ directory of hand-editable text files.
any kind is used.
- Request log: one JSON object per request on stdout, optionally also sent
to a remote syslog server (see "Request log").
- Metrics: Prometheus counters and gauges on their own listener (see
"Metrics endpoint").
- Metrics: Prometheus counters and gauges (see "Metrics endpoint").
- Alerting: a queue with de-duplication feeding webhook, Slack and ntfy
senders.
- Proxy: the standard library's `net/http/httputil.ReverseProxy`, streaming
@@ -82,7 +81,12 @@ directory of hand-editable text files.
- standard library for the proxy, HTTP clients, DNS, logging (`log/slog`),
state files (`encoding/json`, `os.Rename`);
- `github.com/corazawaf/coraza/v3` and
`github.com/corazawaf/coraza-coreruleset` for attack detection;
`github.com/corazawaf/coraza-coreruleset/v4` at `v4.25.0`, which carries
the Core Rule Set 4.25.0, for attack detection. The sidecar's changes to
the Core Rule Set and the default `WAF_DISABLED_RULES` (see "Configuration
surface", attack detection) are built for that version, since rule ids and
what each rule matches change between versions, and are checked again
before the version changes;
- `github.com/oschwald/maxminddb-golang/v2`, the current major version, to
read the lookup database;
- `github.com/fsnotify/fsnotify` to notice files that are edited or added
@@ -99,6 +103,7 @@ Steps run in this order; the first step that produces a final answer ends
processing. The size and time limits (see "Configuration surface") apply to the
whole exchange.
- A `GET /_smallwebwaf/healthz` is answered at once (see "Admin endpoints").
- Identify the client.
- If the TCP peer is inside `TRUSTED_PROXIES`, walk `X-Forwarded-For` from
the right and take the first address not inside `TRUSTED_PROXIES`. If
@@ -112,17 +117,22 @@ whole exchange.
counting and banning, because one abuser usually controls a whole /64. A
client is therefore one IPv4 address or one IPv6 group, a /64 by default.
- Static lists.
- In `ALLOW_NETS`: skip every check below and forward. Still counted for
anomaly alerts.
- In `ALLOW_NETS`: skip every check below and forward, or answer a request
under `/_smallwebwaf/` (see "Admin endpoints"). Still counted for anomaly
alerts.
- In `DENY_NETS`: refuse.
- Ban ledger. An active ban on the client's netblock: refuse with
`BAN_RESPONSE`. If a clear sign of attack caused the ban, the ban becomes
permanent (see "Bans").
- Look up the AS number and country, unless `LOOKUP_SOURCE` is `off`. With
GeoJS, a client whose answer is not yet kept waits for it, up to
`LOOKUP_TIMEOUT`. A client the lookup cannot place, which includes every
private, loopback and link-local address, has an unknown country and AS
number: the exclusive country list refuses it, the biased thresholds give it
GeoJS, the default, a request waits for its client's first answer, up to
`LOOKUP_TIMEOUT`, only when a setting needs it before the request goes on: the
country lists, the biased thresholds or `ADD_LOOKUP_HEADERS`. Otherwise the
request goes on at once; the answer is added to the client's history and ban
notes when it comes, and a request that ends before then is logged without it.
A client the lookup cannot place, which includes every private, loopback and
link-local address, has an unknown country and AS number: the exclusive
country list refuses it, the biased thresholds give it
`UNKNOWN_LIMIT_PERCENT`, and nothing else treats it differently.
- Country lists. A client whose country is in `DENIED_COUNTRIES`, or, when
`EXCLUSIVELY_ALLOWED_COUNTRIES` is set, is not in it, is refused with
@@ -145,13 +155,17 @@ whole exchange.
in file name order then line order. Each rule that matches takes its action:
only log, refuse with 403, or refuse and ban. Matching stops at the first rule
that refuses or bans.
- Core Rule Set inspection of the request: its headers, its URL, and its body up
to `WAF_BODY_LIMIT` when the body is form data, multipart, JSON or XML, the
kinds the Core Rule Set can read. Any other body, such as a git push or a
container image layer, reaches the app uninspected, and so does a JSON or XML
body larger than `WAF_BODY_LIMIT`, which cannot be read in part. Responses are
not inspected. In `block` mode, the default, a match at or over the anomaly
threshold is refused with 403; in `detect` mode it is only logged and alerted.
- Core Rule Set inspection of the request: its method, its URL with the query
string, and its headers. By default no body is read. When `WAF_BODY_LIMIT` is
set to a size, a body is read up to that size if it is form data, multipart,
JSON or XML, the kinds the Core Rule Set can read. Any other body, such as a
git push or a container image layer, reaches the app uninspected, and so does
a JSON or XML body larger than `WAF_BODY_LIMIT`, which cannot be read in part.
Responses are not inspected. In `block` mode, the default, a match at or over
the anomaly threshold is refused with 403; in `detect` mode it is only logged
and alerted.
- A request under `/_smallwebwaf/` is answered by the sidecar here and goes no
further (see "Admin endpoints").
- Forward to `UPSTREAM_URL`, streaming. Add `X-Forwarded-For` and, if enabled,
`X-Client-ASN` and `X-Client-Country` for the app's own logs.
- After the response.
@@ -159,7 +173,8 @@ whole exchange.
whose byte total passes a byte limit times the percentage has broken that
limit and is banned. The response in progress is not cut off.
- Count the client's requests that the sidecar refused after a rule file or
Core Rule Set match; answers the app gave are not counted. More than
Core Rule Set match, or for a missing or wrong token (see "Admin
endpoints"); answers the app gave are not counted. More than
`ERROR_BURST_THRESHOLD` of them within a minute breaks a limit and bans
the client.
- Update the client's history and the anomaly counters, and evaluate alert
@@ -216,7 +231,8 @@ to a ban, each by its own rule:
next clear sign of attack bans it permanently at once.
- A broken limit: a request over a request limit, a response that takes the
client's byte total past a byte limit, or more than `ERROR_BURST_THRESHOLD`
requests within a minute refused after a rule file or Core Rule Set match.
requests within a minute refused after a rule file or Core Rule Set match, or
for a missing or wrong token.
- The first such ban, or one that comes more than `LIMIT_BAN_REPEAT_WINDOW`
(default `24h`) after the last such ban ended, lasts `LIMIT_BAN_DURATION`
(default `1h`).
@@ -237,9 +253,10 @@ ban's notes.
Every ban carries notes with what an admin needs to decide whether to lift it
(see "Persistent state"). An admin can add or lift a ban at any time by editing
`bans.json`, or through the admin listener when `ADMIN_TOKEN` is set. A ban
lifted before it ends is kept, marked as lifted, and does not count toward a
longer ban; deleting its entry from `bans.json` forgets it entirely.
`bans.json`, or through the ban endpoints when `ADMIN_TOKEN` is set (see "Admin
endpoints"). A ban lifted before it ends is kept, marked as lifted, and does not
count toward a longer ban; deleting its entry from `bans.json` forgets it
entirely.
Clients of the AS numbers and countries listed in the biased thresholds, and
clients listed by a reputation source whose action is `limit:<percent>`, get
@@ -256,15 +273,15 @@ nothing in `bans.json`.
Conventions: lists are comma separated; netblocks are CIDR (a bare address means
/32 or /128); durations use Go syntax plus `d` for days (`90s`, `15m`, `24h`,
`7d`); byte sizes accept `K`, `M`, `G` suffixes. Countries are two-letter ISO
`7d`); byte sizes accept `K`, `M`, `G` suffixes, which are powers of 1024: `1K`
is 1024 bytes, `1M` is `1024K` and `1G` is `1024M`. Countries are two-letter ISO
codes in either case (`de` and `DE` are the same); a code that is not a country
code, such as `nk` (North Korea is `kp`), stops the start with a message naming
it.
- Only `UPSTREAM_URL` is required. Every other setting has a default chosen for
a service facing the internet in 2026, or stays off until the operator
supplies or chooses what it needs: an alert destination, an account key, a
lookup source.
supplies what it needs: an alert destination, an account key, a token.
- Any limit or threshold can be switched off with the value `off`.
- A list set to an empty value is an empty list, and replaces the default.
- Every variable may instead be given as `NAME_FILE` pointing at a file holding
@@ -280,11 +297,12 @@ The settings, by group:
- Core
- `UPSTREAM_URL` (required): the application, for example
`http://gitea:3000`.
- `LISTEN_ADDR` (default `:8080`): proxy listener.
- `ADMIN_LISTEN_ADDR` (default `127.0.0.1:9090`): admin listener.
- `ADMIN_TOKEN`: bearer token required for the ban management endpoints.
Unset by default, which switches those endpoints off; bans are then
managed by editing `bans.json`.
- `LISTEN_ADDR` (default `:8080`): the one listener, for the requests the
sidecar forwards and for its own endpoints (see "Admin endpoints").
- `ADMIN_TOKEN`: bearer token for the ban endpoints and
`/_smallwebwaf/clients/<ip>`, a long random value, since they can be
reached from the internet. Unset by default, which switches them off; bans
are then managed by editing `bans.json`.
- `INSTANCE_NAME` (default: the host name in `UPSTREAM_URL`, for example
`gitea`): included in every log line, metric and alert. Set it, for
example to `fsn1app1/gitea`, when several sidecars report to one place.
@@ -332,14 +350,11 @@ The settings, by group:
- `LOG_REMOTE_FACILITY` (default `local0`), `LOG_REMOTE_APP_NAME` (default
`INSTANCE_NAME`): syslog header fields.
- Metrics
- `METRICS_ENABLED` (default `true`).
- `METRICS_LISTEN_ADDR` (default `:9100`): its own listener, so it can be
bound to a monitoring network without exposing the admin endpoints.
- `METRICS_PATH` (default `/metrics`).
- `METRICS_TOKEN`: bearer token a scraper sends for `/_smallwebwaf/metrics`,
a long random value. Unset by default, which switches the metrics off,
since they would otherwise be open to anyone on the internet.
- `METRICS_TOP_N` (default `50`): how many AS numbers and countries get
their own series; the rest are summed as `other`.
- `METRICS_TOKEN`: optional bearer token; unset means no authentication,
which is the usual arrangement for a scraper on a private network.
- Static lists
- `ALLOW_NETS`: bypass everything (monitoring, the owner's own networks).
- `RATE_LIMIT_EXEMPT_NETS`: bypass request and byte limits only; the error
@@ -355,7 +370,7 @@ The settings, by group:
health checks).
- Byte limits, per client. A response's bytes are counted when it ends, so every
default sits above the largest response allowed (`CLIENT_RESPONSE_MAX_BYTES`,
5 GB) and no single download breaks one.
5 GiB) and no single download breaks one.
- `BYTES_LIMIT_PER_MINUTE` (default `10G`), `BYTES_LIMIT_PER_HOUR` (default
`20G`), `BYTES_LIMIT_PER_DAY` (default `50G`).
- `BYTES_COUNT` (default `both`): `response`, `request` or `both`.
@@ -397,9 +412,11 @@ The settings, by group:
started it can only be cut off, and the connection is closed.
- A WebSocket connection leaves these limits behind once it is upgraded: it
stays open until either side closes it.
- Lookup of AS number and country (R7). Off by default: the file needs an
account only the operator can hold, and GeoJS is told every visitor's address.
- `LOOKUP_SOURCE` (default `off`): `off`, `file` or `geojs`.
- Lookup of AS number and country (R7). On by default through GeoJS, which needs
no account or file, so that country lookup works with no setup. The file is
the alternative for a service that keeps its visitors' addresses on its own
host.
- `LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`.
- `LOOKUP_DB_PATH`: the file for `file`, the IPinfo Lite database in its
`.mmdb` form (`ipinfo_lite.mmdb`), one file that carries both country and
AS number; the sidecar reads its `asn`, `as_name` and `country_code`
@@ -418,20 +435,23 @@ The settings, by group:
`country_code`, `asn` and `organization_name` (the AS name). A
`country_code` of `null`, which GeoJS gives for some ranges, and an `asn`
of `64512`, which it gives when it knows none, count as unknown. GeoJS is
told the address of every new visitor, except private, loopback and
link-local addresses, which no source can place and which are never sent.
told the address of every new visitor, whether or not a setting uses the
answer, since the request log, the metrics, the client's history and ban
notes carry the AS number and country. Private, loopback and link-local
addresses, which no source can place, are never sent.
- Each GeoJS answer is kept for 7 days in memory and in `lookups.json`,
apart from the table of clients, so it survives a restart and outlasts a
client dropped from that table; after 7 days the client's next request
asks again. Up to 100,000 answers are kept, about 15 MiB; when that many
are held, the answer used longest ago goes first.
- `LOOKUP_TIMEOUT` (default `1s`): how long a client with no kept answer
waits for one; GeoJS normally answers in a fraction of that. At most one
request to GeoJS is under way at a time, the addresses that arrive
meanwhile are asked about together in the next one, and a request that
takes longer than `LOOKUP_TIMEOUT` is abandoned. A client whose answer has
not come in time counts as unknown until it comes: its later requests do
not wait, and its address is asked about again in the background.
- `LOOKUP_TIMEOUT` (default `1s`): how long a request waits for its client's
first answer when a setting needs it (see "Data flow for one request");
GeoJS normally answers in a fraction of that. At most one request to GeoJS
is under way at a time, the addresses that arrive meanwhile are asked
about together in the next one, and a request that takes longer than
`LOOKUP_TIMEOUT` is abandoned. A client whose answer has not come in time
counts as unknown until it comes: its later requests do not wait, and its
address is asked about again in the background.
- GeoJS publishes no rate limit, but its terms forbid "an excessive amount
of API requests", judged by GeoJS alone, and let it block a caller. While
GeoJS is slow, down or refusing the sidecar, clients with a kept answer
@@ -440,24 +460,25 @@ The settings, by group:
asking with backoff and sends one `source_failure` alert per cooldown. A
service that cannot accept this uses the file.
- One source at a time: `LOOKUP_SOURCE=file` without `LOOKUP_DB_PATH`, or
`LOOKUP_DB_PATH` with any other `LOOKUP_SOURCE`, stops the start with a
message naming both. So does a setting that needs lookups while
`LOOKUP_SOURCE` is `off`: the country lists and the biased thresholds
below, `ADD_LOOKUP_HEADERS`, and the per-AS-number anomaly thresholds.
`LOOKUP_DB_PATH` with any other `LOOKUP_SOURCE`, the default `geojs`
included, stops the start with a message naming both. So does a setting
that needs lookups while `LOOKUP_SOURCE` is `off`: the country lists and
the biased thresholds below, `ADD_LOOKUP_HEADERS`, and the per-AS-number
anomaly thresholds.
- `ADD_LOOKUP_HEADERS` (default `false`): pass `X-Client-ASN` and
`X-Client-Country` to the app.
- Country lists. Both are empty by default, since they need a lookup source.
Clients in `ALLOW_NETS` are not checked.
- Country lists. Both are empty by default: the defaults judge a client by what
it does, not by where it comes from. Clients in `ALLOW_NETS` are not checked.
- `DENIED_COUNTRIES`: for example `cn,ru,kp,ir,ua,by`. Every request from a
listed country is refused.
- `EXCLUSIVELY_ALLOWED_COUNTRIES`: for example `us,de`. Every request from
any other country is refused, and so is every request from a client the
lookup cannot place: one whose address is missing from the database, one
GeoJS has not answered for in time, and any client on a private address,
such as a visitor on the local network, another container or internal
monitoring. Those that should reach the app go in `ALLOW_NETS`. A list
that let unplaced clients through would let every new client in whenever
GeoJS stops answering.
- `EXCLUSIVELY_ALLOWED_COUNTRIES`: for example `us,de`. Only clients placed
in a listed country get through. Every request from any other country is
refused, and so is every request from a client the lookup cannot place:
one whose address is missing from the database, one GeoJS has not answered
for in time, and any client on a private address, such as a visitor on the
local network, another container or internal monitoring. Those that should
reach the app go in `ALLOW_NETS`. A list that let unplaced clients through
would let every new client in whenever GeoJS stops answering.
- Both may be set. `DENIED_COUNTRIES` then adds nothing, since the exclusive
list already refuses every other country, and a code on both lists stops
the start with a message naming it.
@@ -467,8 +488,9 @@ The settings, by group:
Core Rule Set, is logged with the action `country_denied`, and is counted
in the metrics. It is a refusal, not a ban: it is not an offence and makes
no ban record.
- Biased thresholds (R8). The lists are empty by default: they need a lookup
source, which only the operator can choose.
- Biased thresholds (R8). 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 service's abuse comes from.
- `ASN_LIMIT_PERCENT`: for example `AS14061:50,AS16276:50,AS45102:25`.
Clients in a listed AS number get that percentage of every request and
byte limit, so the rules in "Bans" ban them after fewer requests than
@@ -491,7 +513,16 @@ The settings, by group:
- `ASN_LIMIT_PERCENT_URL`: optional URL of a text file of `AS:percent`
lines, so one abuse-source list can be shared by every sidecar in the
fleet. It is fetched and refreshed like the blocklists.
- Attack detection (R9)
- Attack detection (R9). By default the Core Rule Set reads the method, the URL
with its query string, and the headers of every request, which is where
automated attacks show: injection in query strings, path traversal, attacks
carried in headers, 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 and comment text, wiki pages, files saved in the web editor and
package descriptions, and when these hold shell commands or code the Core Rule
Set takes 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, and `WAF_BODY_LIMIT` switches body inspection on.
- `RULES_DIR` (default `/etc/smallwebwaf/rules.d`): directory of rule files,
read at start and again whenever a file in it changes; format under "Rule
files".
@@ -501,55 +532,71 @@ The settings, by group:
- `WAF_PARANOIA_LEVEL` (default `1`), `WAF_ANOMALY_THRESHOLD` (default `5`):
the Core Rule Set's own two tuning values, at the Core Rule Set's own
defaults.
- The sidecar also changes the Core Rule Set in four ways that no setting
undoes, since in front of gitea each would otherwise refuse ordinary
requests:
- The sidecar also changes the Core Rule Set 4.25.0 in four ways that no
setting undoes, since in front of gitea each would otherwise refuse
ordinary requests:
- PUT, PATCH and DELETE are allowed methods besides GET, HEAD, POST and
OPTIONS; APIs, container image pushes and package uploads use them.
Other methods stay refused.
- The request headers `Content-Encoding` and `Expect` are allowed: git
compresses a fetch request over 1 KiB and says so in
`Content-Encoding`, and curl and git send `Expect` before some large
uploads. The other headers the Core Rule Set refuses, such as `Proxy`,
stay refused.
- Only a body the Core Rule Set can read is inspected (see "Data flow
for one request"). It would read any other body as form data, where
binary content such as a git push trips rules written for text.
- The request header `Expect` is allowed, since curl and git send it
before some large uploads. `Content-Encoding` is allowed on a request
whose body the Core Rule Set does not read, which by default is every
request: git compresses a fetch request over 1 KiB and says so in that
header. On a body the Core Rule Set does read, the header stays
refused, since a compressed body cannot be inspected. The other
headers the Core Rule Set refuses, such as `Proxy`, stay refused.
- The `redirect_uri` parameter is not checked for a URL naming an IP
address or `localhost` (931100, 934110). Git Credential Manager,
git-credential-oauth and tea, which gitea registers for OAuth sign-in
out of the box, ask to be sent back to `http://127.0.0.1` on the
user's own machine, and the server never fetches that address.
- Responses are not inspected. A raw file from a repository, such as a
shell script, looks to the response rules like source code leaking
from the server.
- `WAF_DISABLED_RULES` (default
`920340,920420,920440,920640,930130,930140,932180`): rule ids to switch
off when an app trips a false positive. The default switches off the rules
that refuse a request for the type of its body, when it is missing or not
on the Core Rule Set's short list (920340, 920420, 920640); for its file
extension, such as `.sh` or `.sql` (920440); for a file or directory name
in its path, such as `.git/`, `.gitignore`, `Dockerfile`, `package.json`
or an editor's settings directory (930130, 930140); and for the name of an
uploaded file, such as `debug.log` or `config.yml` (932180). In front of a
code forge these refuse git over HTTP, container image and package
uploads, views of ordinary files in a repository, and logs attached to
issues. The default rule file still bans probes for `.env` and `.git` at
the site root (see "Rule files"). A list given replaces the default, so
include them in it.
`920340,920420,920440,920640,930130,930140`): rule ids to switch off when
an app trips a false positive. The default switches off the rules that
refuse a request for the type of its body, when it is missing or not on
the Core Rule Set's short list (920340, 920420, 920640); for its file
extension, such as `.sh` or `.sql` (920440); and for a file or directory
name in its path, such as `.git/`, `.gitignore`, `Dockerfile`,
`package.json` or an editor's settings directory (930130, 930140). In
front of a code forge these refuse git over HTTP, container image and
package uploads, and views of ordinary files in a repository. At the site
root, where no app serves such files, the default rule file bans the
common probes these rules caught, such as `/.env`, `/.git/config`,
`/.aws/credentials`, `/.ssh/id_rsa`, `/.htpasswd` and `/wp-config.php.bak`
(see "Rule files"). A list given replaces the default, so include them in
it.
- `WAF_EXEMPT_PATHS`: path prefixes not inspected.
- `WAF_BODY_LIMIT` (default `128K`): bodies the Core Rule Set reads are
inspected up to this size and streamed beyond it without buffering, so
large uploads are not held in memory.
- `WAF_BODY_LIMIT` (default `off`): `off` reads no request body. A size,
such as `128K`, has the Core Rule Set read form data and multipart bodies
up to that size, streaming the rest of a longer one on without holding it
in memory, and JSON and XML bodies no larger than it, since those cannot
be read in part. Any other body is not read, since the Core Rule Set would
read it as form data, where binary content such as a git push trips rules
written for text. Body inspection suits apps whose forms carry no code. In
front of gitea it refuses issue and comment text, wiki pages and files
saved in the web editor that hold shell commands or code (932125, 932235,
932250 and others), package descriptions that show code, PyPI uploads
(922130), and attachments named like `debug.log` or `config.yml` (932180),
until the rule ids the request log names are added to
`WAF_DISABLED_RULES`.
- `TRAP_PATHS`: paths the app never serves and only scanners ask for, for
example `/wp-login.php,/xmlrpc.php` in front of gitea. A request for one
is a clear sign of attack. This is the env-var short form of a `path` rule
with the `ban` action, for deployments that mount no rule files.
- `ERROR_BURST_THRESHOLD` (default `30`): requests per client per minute
that the sidecar refused after a rule file or Core Rule Set match; more
than this breaks a limit (see "Bans"). A client trying one attack 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, container image and
package clients are answered 404 by design, for each layer a push checks
and each package a lookup asks about, often hundreds of times a minute,
and git and registry clients are answered 401 at the start of every push,
every fetch from a private repository and every image pull.
that the sidecar refused after a rule file or Core Rule Set match, or for
a missing or wrong token; 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, container image and package clients are answered 404 by
design, for each layer a push checks and each package a lookup asks about,
often hundreds of times a minute, and git and registry clients are
answered 401 at the start of every push, every fetch from a private
repository and every image pull.
- Bans (R5), following the rules under "Bans"
- `ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
attack.
@@ -565,14 +612,16 @@ The settings, by group:
traefik answers `502`, as it does whenever its backend drops a connection.
- `BAN_SCOPE_V4_PREFIX` (default `32`): widen to for example `24` to ban the
surrounding netblock.
- Reputation (R6). No source is on by default. A DNS blocklist would be told the
address of every visitor. AbuseIPDB and CrowdSec need an account or an engine
of the operator's own. A downloaded list reveals nothing about visitors, but
the list most fit to be a default, Spamhaus DROP, must not be downloaded
automatically more than once an hour, and Spamhaus may block an address that
downloads it too often. Each sidecar fetches its own copy, so on a host that
runs several sidecars, which share one address, downloads would often come
less than an hour apart.
- Reputation (R6). No source is on by default, for the reason the country lists
and the biased thresholds are empty: a source refuses the clients that a list
kept elsewhere names, or lowers their limits, while the defaults judge a
client only by what it does to this service. AbuseIPDB and CrowdSec also need
an account or an engine of the operator's own. Besides, the list best suited
to be on by default, Spamhaus DROP, must not be downloaded automatically more
than once an hour, and Spamhaus may block an address that downloads it too
often. Each sidecar fetches its own copy, so on a host that runs several
sidecars, which share one address, downloads would often come less than an
hour apart.
- `BLOCKLIST_URLS`: text files of addresses and netblocks, one per line;
anything after a `;` or `#` on a line is ignored. For example the Spamhaus
DROP list, `https://www.spamhaus.org/drop/drop.txt`. Spamhaus's terms for
@@ -676,8 +725,8 @@ removed there takes effect while the sidecar runs.
so an encoded probe cannot slip past.
- `method`, `host`, `user_agent`, `referer`.
- `header:<Name>`: any one request header.
- Request bodies are not available to rule files; body inspection is the
Core Rule Set's job.
- Request bodies are not available to rule files; reading them is the Core
Rule Set's job, once `WAF_BODY_LIMIT` switches it on.
- `action`:
- `log`: note the match in the request log and do nothing else.
- `block`: refuse the request with 403. The client is not banned for it, but
@@ -712,27 +761,37 @@ removed there takes effect while the sidecar runs.
nothing is refused.
- Clients in `ALLOW_NETS` are not checked.
Example file:
The default file the image ships:
```
# 00-default.rules: probes no real visitor sends
# 00-default.rules: probes no real visitor sends, anchored at the site root
# id target action regex
env-file path ban (?i)^/\.env(\.[a-z]+)?$
git-dir path ban ^/\.git/(config|HEAD|index)$
vcs-dir path ban (?i)^/\.(git|svn|hg|bzr)(/|$)
secrets-dir path ban (?i)^/\.(aws|ssh|docker|kube)/
secret-file path ban (?i)^/\.(htpasswd|htaccess|npmrc|netrc|pgpass|git-credentials|bash_history|DS_Store)$
editor-dir path ban (?i)^/\.(vscode|idea)/
backup-file path ban (?i)^/[^/]+\.(php(\.[a-z0-9]+|~)|sql(\.[a-z0-9]+)?)$
log-file path ban (?i)^/(debug|error|access)\.log$
compose-file path ban (?i)^/(docker-)?compose\.ya?ml$
php-shell path ban (?i)^/(shell|c99|r57|wso|alfa)\.php$
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|masscan|zgrab|wpscan)\b
path-traversal uri block (\.\./){2,}
empty-agent user_agent log ^$
```
The image ships one default file of this kind. It is kept short and limited to
patterns that are wrong for every app, and its path rules are 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 or search crawler may open. Anything app-specific belongs in a file
the deployer mounts: a request for `/wp-login.php`, for example, is a clear sign
of attack in front of gitea and an ordinary login in front of WordPress.
It is kept short and limited to patterns that are wrong for every app, and its
path rules are 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 or search crawler may open. Its path rules ban the common
probes for secrets, version control directories, backups and logs. The Core Rule
Set's rules for file names and extensions (930130, 920440) refuse such names
anywhere in a path, which is why the default `WAF_DISABLED_RULES` switches them
off: a code forge serves such files deeper in its paths. Anything app-specific
belongs in a file the deployer mounts: a request for `/wp-login.php`, for
example, is a clear sign of attack in front of gitea and an ordinary login in
front of WordPress.
```
# 50-gitea.rules: WordPress probes, which gitea never serves
@@ -760,7 +819,7 @@ and the running sidecar takes the edit in.
each direction, how many requests were forwarded and how many refused,
responses by status class, and offences by kind. Clients that were never
banned are kept too, so every client's history survives a restart;
`GET /clients/<ip>` shows it.
`GET /_smallwebwaf/clients/<ip>` shows it.
- `lookups.json`: GeoJS answers, one per client: the AS number, AS name and
country, when GeoJS was asked and when the answer was last used; up to
100,000, each for 7 days.
@@ -776,7 +835,8 @@ and the running sidecar takes the edit in.
- Ban notes. The notes on a ban hold what an admin needs to decide whether to
lift it, drawn from what the sidecar already knows; nothing is looked up to
fill them:
- the AS number, AS name and country (empty when lookups are off);
- the AS number, AS name and country, added when the lookup answers (empty
when lookups are off);
- what was broken: the rule ids and target that matched, or the limit, its
window, the count reached and the client's limit percentage with what set
it; and any reputation sources that listed the client;
@@ -881,7 +941,8 @@ handling while `docker logs` keeps working.
redirects, `aborted` when the client went away early.
- Decision: `action` (`forward`, `banned`, `denied`, `country_denied`,
`rate_limited`, `rule_blocked`, `waf_blocked`, `too_large`, `timed_out`,
`upstream_error`), `would_action` in `observe` mode, `limit_percent` and which
`upstream_error`, and `admin` for a request the sidecar answered at one of its
own endpoints), `would_action` in `observe` mode, `limit_percent` and which
rule set it, `counts` (the client's minute, hour and day request and byte
totals after this request), `limit_hit` (which window), `rule_ids` (rule file
rules that matched), `waf_rule_ids`, `waf_score`, `reputation` (sources that
@@ -904,9 +965,10 @@ handling while `docker logs` keeps working.
## Metrics endpoint
Prometheus text format on `METRICS_LISTEN_ADDR` at `METRICS_PATH`, on by
default, switched off with `METRICS_ENABLED=false`. It has its own listener so
it can be reached by a scraper without exposing ban management.
Prometheus text format at `/_smallwebwaf/metrics`, for a request carrying
`METRICS_TOKEN` (see "Admin endpoints"). While the token is unset, the metrics
are off. The token is separate from `ADMIN_TOKEN`, so a scraper that holds it
cannot manage bans.
- Traffic: requests and bytes in and out, by status class and `action`; request
duration and upstream duration histograms; requests in flight.
@@ -928,17 +990,40 @@ it can be reached by a scraper without exposing ban management.
by destination; remote log lines sent, dropped and buffer depth; the standard
Go runtime and process metrics.
- No metric carries a client IP address as a label; per-address questions are
answered by the request log and `GET /clients/<ip>`.
answered by the request log and `GET /_smallwebwaf/clients/<ip>`.
## Admin listener
## Admin endpoints
- `GET /healthz`: for the container health check.
- `GET /bans`, `POST /bans` (client or netblock, duration, reason),
`DELETE /bans/<client>`: need `ADMIN_TOKEN`, and are switched off while it is
unset. Editing `bans.json` does the same without a token.
- `GET /clients/<ip>`: current counters, history, lookup result, reputation,
offences, and bans with their notes; for answering "why was this address
refused" and "what has this netblock been doing".
The sidecar has one listener. A request whose path starts with `/_smallwebwaf/`
is for the sidecar itself: 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 through traefik, like any other request.
- `GET /_smallwebwaf/healthz`: answers `200` with `ok` to anyone, without a
token, while the sidecar is running; it does not ask the app. It is answered
before any check, so a health checker never needs an exemption and is never
refused, for example by `EXCLUSIVELY_ALLOWED_COUNTRIES`.
- `GET /_smallwebwaf/metrics`: the metrics (see "Metrics endpoint"); needs
`METRICS_TOKEN`.
- `GET /_smallwebwaf/bans`, `POST /_smallwebwaf/bans` (client or netblock,
duration, reason), `DELETE /_smallwebwaf/bans/<client>`: need `ADMIN_TOKEN`.
Editing `bans.json` does the same without a token.
- `GET /_smallwebwaf/clients/<ip>`: needs `ADMIN_TOKEN`. Current counters,
history, lookup result, reputation, offences, and bans with their notes; for
answering "why was this address refused" and "what has this netblock been
doing".
- A token is sent as `Authorization: Bearer <token>`. While a token is unset,
the endpoints that need it answer `404`, as does any other path under the
prefix.
- Apart from the health check, these requests go through every check that any
other request goes through, and are answered at the point where another
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
wrong token is answered `401`, in `MODE=observe` too, and counts toward the
error burst, so a client guessing tokens is banned once it passes
`ERROR_BURST_THRESHOLD` guesses in a minute. A client in `ALLOW_NETS` skips
the checks but still needs the token.
- An admin whose own address is banned lifts the ban by editing `bans.json`.
## Alert webhook schema
@@ -995,9 +1080,10 @@ networks:
hostname, for example over a docker network the two share.
- The application container leaves the traefik network, so the sidecar cannot be
bypassed.
- Traefik routes only port 8080. The metrics port is reached by the scraper over
a docker network it shares with the sidecar; the admin port stays on loopback
inside the container and is used through `docker exec`.
- Traefik routes port 8080, the sidecar's one listener. The sidecar's own
endpoints are reached through traefik like any other request, for example
`https://git.example.invalid/_smallwebwaf/metrics` for a scraper that sends
`METRICS_TOKEN` (see "Admin endpoints").
- The state volume holds the JSON state files, a few tens of MiB at most with
the defaults (see "Persistent state"); it needs no backup beyond whatever the
host already does.
@@ -1009,27 +1095,35 @@ networks:
refused. A real visitor refused by mistake is let through with an exclusion
(`WAF_DISABLED_RULES`, `WAF_EXEMPT_PATHS`, `RATE_LIMIT_EXEMPT_PATHS`) or an
exemption (`RATE_LIMIT_EXEMPT_NETS`, `ALLOW_NETS`), and its ban is lifted in
`bans.json`. Additions to consider at any time: an alert destination, a lookup
source with country lists or biased thresholds, reputation sources, a remote
log endpoint, and an app-specific rule file.
`bans.json`. Additions to consider at any time: an alert destination, country
lists or biased thresholds, reputation sources, the metrics and admin tokens,
a remote log endpoint, and an app-specific rule file.
- Notes specific to gitea:
- A clone is a response, and fits the defaults of 30 minutes and 5 GB for
- A clone is a response, and fits the defaults of 30 minutes and 5 GiB for
all but the largest repositories and slowest links. A push is a request:
at the defaults, one larger than 100 MB or taking more than 60 seconds to
at the defaults, one larger than 100 MiB or taking more than 60 seconds to
upload is cut off, so a gitea that takes large pushes needs
`CLIENT_REQUEST_MAX_BYTES`, `UPSTREAM_REQUEST_MAX_BYTES`,
`CLIENT_REQUEST_TIMEOUT` and `UPSTREAM_REQUEST_TIMEOUT` raised to fit. The
Core Rule Set does not read a pack upload, which streams through without
being held in memory.
- With the sidecar's changes to the Core Rule Set and the default
`WAF_DISABLED_RULES` (see "Configuration surface", attack detection),
git's clone, fetch and push over HTTP, the API, container images,
packages, and views of ordinary files pass the Core Rule Set. Requests
that carry text people write, such as issues, pull requests, searches, the
web editor and uploads, can still trip other rules, and so can a file
whose name the Core Rule Set takes for an attack, such as an `.xhtml`
page. Such a match refuses only that request, and the request log names
the rule in `waf_rule_ids` for `WAF_DISABLED_RULES`.
- At the defaults (see "Configuration surface", attack detection), the Core
Rule Set lets gitea's ordinary use through: browsing and views of files in
a repository, git's clone, fetch and push over HTTP, signing in, including
with Git Credential Manager, git-credential-oauth or tea, the API, pushing
and pulling container images and packages, and posting issues, pull
requests, comments, wiki pages and files saved in the web editor, code
included, since no body is read. It can still refuse a request whose URL
reads to it as an attack: a search, or another query string, holding a
shell command with its options or a system path (`ls -la`, `sed -i`,
`/bin/sh`), a command in backticks, script code (`fetch(`, `${VAR}`), HTML
(`<img src=`), an SQL statement (`SELECT * FROM users WHERE`), or a URL
naming an IP address or `localhost`; or a path such as a file name ending
in `~`, or an `.xhtml` file whose path holds a space. Such a refusal
answers only that request, with 403, and bans no one by itself: it counts
toward the error burst, which a person searching does not reach. The
request log names the rule in `waf_rule_ids`, which `WAF_DISABLED_RULES`
can switch off.
- Archive download and blame or history pages are what scrapers hammer;
request limits do most of the work there.
@@ -1081,8 +1175,9 @@ networks:
- A `ban` rule that matches a real visitor bans it for seven days, and the
visitor's next request during those days makes the ban permanent. So the
default rule file keeps to requests no real visitor sends, with its paths
anchored at the site root, where no app serves `.env` files or web shells. A
visitor banned by mistake is let back in by lifting the ban in `bans.json`.
anchored at the site root, where no app serves secrets, version control
directories, backups or web shells. A visitor banned by mistake is let back in
by lifting the ban in `bans.json`.
- IPv6 address rotation inside a /64: handled by grouping.
- Widely distributed scrapers using thousands of addresses at low rates each:
per-client limits do not see them. The AS number and netblock anomaly alerts
@@ -1090,14 +1185,24 @@ networks:
that AS number is the response: it lowers the limits of each of its clients.
There is no shared budget for a whole AS number or country, which one abuser
could use up and so lock out everyone else there.
- Core Rule Set false positives against real apps (gitea's editor, API
payloads): a match refuses only that request and bans no one by itself; the
request log names the rule, and exclusions by rule id and path fix it.
- GeoJS as the lookup source: every new visitor's address goes to a third party,
and a swarm of fresh addresses, when lookups peak, is when GeoJS may slow down
or block the sidecar. Keeping answers for 7 days and asking about many
addresses in one request keep the number of requests low; the file source has
neither risk.
- Core Rule Set false positives against real apps (a search for code, and any
body once `WAF_BODY_LIMIT` is set): a match refuses only that request and bans
no one by itself; the request log names the rule, and exclusions by rule id
and path fix it.
- Attacks carried in request bodies: not refused by default, since on a code
forge bodies are full of code the Core Rule Set takes for attacks. What shows
in URLs and headers is still refused, the client that sends an attack is still
subject to the rule files, the limits and the bans, and `WAF_BODY_LIMIT`
switches body inspection on for apps whose forms carry no code.
- The admin endpoints can be reached from the internet: all but the health check
need a token and are off while it is unset, and a missing or wrong token
counts toward the error burst, so a client guessing tokens is soon banned.
Tokens are meant to be long random values.
- GeoJS, the default lookup source: every new visitor's address goes to a third
party, and a swarm of fresh addresses, when lookups peak, is when GeoJS may
slow down or block the sidecar. Keeping answers for 7 days and asking about
many addresses in one request keep the number of requests low; the file source
has neither risk.
- Slow-request attacks: `CLIENT_REQUEST_TIMEOUT` bounds how long a request may
take to arrive, `CLIENT_REQUEST_HEADER_MAX_BYTES` how large its headers may
be, and `CLIENT_IDLE_TIMEOUT` closes a kept-open connection that sends nothing