check / check (push) Waiting to run
The old README opened with a narrative of milestones and one sentence listing every feature, and gave its settings as one list of a hundred entries. The new one leads with what smallwebwaf is, what it protects against, how to build and deploy it, what happens to a request, what a refused visitor and the operator see, and how to check it works. The settings follow, grouped by topic, each with its default and what a bad value does. The rest is reference: bans, rule files, the Core Rule Set, the admin endpoints, the request log, alerts, metrics, state files and the reputation sources. Every statement was checked against the code on next; nothing describes unbuilt behaviour. Short sentences, bulleted lists in place of comma chains, no tables, every identifier in backticks, no project history. Model: fable-5-1
1672 lines
89 KiB
Markdown
1672 lines
89 KiB
Markdown
# smallwebwaf
|
|
|
|
`smallwebwaf` is an MIT-licensed web application firewall, written in Go by
|
|
[@sneak](https://sneak.berlin), for one self-hosted web app. It runs inside the
|
|
app's own container, in front of the app. It refuses the requests of scrapers,
|
|
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. A request it does not refuse reaches the app as it came. The
|
|
only headers it changes are the ones that tell the app who the client is.
|
|
|
|
It needs no setting. Every setting has a default chosen for a service on the
|
|
open internet. The design is in [`SPEC.md`](SPEC.md). The survey of existing
|
|
tools that led to it is in [`EVALUATION.md`](EVALUATION.md).
|
|
|
|
## What it protects against
|
|
|
|
- Scrapers that fetch every page of a site, all day. Rate limiting gives each
|
|
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 and known weak spots: `/.env`, `/.git/config`,
|
|
or a WordPress login on a site that runs no WordPress. The default rule file
|
|
bans such a client for seven days at its first probe. Its next request during
|
|
the ban makes the ban permanent. You can add rules of your own, and name trap
|
|
paths that do the same.
|
|
- 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 that runs those rules, inspects every
|
|
request. 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;
|
|
- give the clients of chosen networks and countries a fraction of every
|
|
limit, so that they are banned sooner;
|
|
- refuse or limit clients that a blocklist, a DNS blocklist, AbuseIPDB or
|
|
your own CrowdSec engine has already seen misbehave.
|
|
- Slow and oversized requests. Every request and every answer has a time limit
|
|
and a size limit.
|
|
|
|
For the operator, it:
|
|
|
|
- writes one JSON log line for every request, saying what it did and why;
|
|
- sends alerts to a webhook, Slack or ntfy;
|
|
- serves Prometheus metrics;
|
|
- keeps its bans, counters and history in JSON files you can read and edit, so
|
|
that a restart loses nothing and a ban can be lifted with an editor.
|
|
|
|
Not built, and not planned:
|
|
|
|
- TLS and routing for several apps. Both stay with traefik, the reverse proxy in
|
|
front of the container.
|
|
- Browser challenges such as captchas.
|
|
- A web console.
|
|
- Defence against floods large enough to fill the host's network link.
|
|
|
|
## Getting started
|
|
|
|
Build the image from a clone. `make docker` runs the tests and the linter, then
|
|
builds the image, tagged `smallwebwaf`. It builds for amd64, on an amd64 host
|
|
only: the `Dockerfile` checks Ubuntu's package lists against the hashes of the
|
|
amd64 archive.
|
|
|
|
```sh
|
|
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
|
cd smallwebwaf
|
|
make docker
|
|
```
|
|
|
|
Push the image to a registry your hosts pull from. Then build the app's own
|
|
image on it. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named
|
|
by digest, and adds:
|
|
|
|
- the app's binary;
|
|
- the packages the app needs, from the nixpkgs in the image;
|
|
- a user for the app;
|
|
- a `run` script that starts the app listening on `127.0.0.1:8081`.
|
|
|
|
```dockerfile
|
|
# The smallwebwaf image, pinned by digest.
|
|
FROM <registry>/smallwebwaf@sha256:<digest>
|
|
|
|
# Packages the app needs, if any, from the nixpkgs in the image.
|
|
RUN nix-env -iA nixpkgs.git
|
|
|
|
# The app's binary, and a user of its own to run it.
|
|
COPY app /usr/local/bin/app
|
|
RUN useradd --system --no-create-home --shell /usr/sbin/nologin app
|
|
|
|
# The app's runit service.
|
|
COPY --chmod=755 app.run /etc/service/app/run
|
|
```
|
|
|
|
`app.run` sits beside the `Dockerfile`. `--listen` and `--trusted-proxies` stand
|
|
for the app's own options:
|
|
|
|
```bash
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
main() {
|
|
sleep 1
|
|
exec chpst -u app:app /usr/local/bin/app \
|
|
--listen 127.0.0.1:8081 \
|
|
--trusted-proxies 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1/32,::1/128
|
|
}
|
|
|
|
main "$@"
|
|
```
|
|
|
|
Leave `ENTRYPOINT` and `USER` out of the app's `Dockerfile`. The image's own
|
|
entrypoint starts `smallwebwaf` and the app side by side, each as its own user.
|
|
|
|
Deploy the app's image as you deploy any app. Point traefik's labels at port
|
|
8080 (`traefik.http.services.<name>.loadbalancer.server.port=8080`). Mount a
|
|
volume at `/var/lib/smallwebwaf`, so that bans and history survive a deploy. For
|
|
example, with no setting:
|
|
|
|
```sh
|
|
docker run --detach --name app \
|
|
--volume app-smallwebwaf:/var/lib/smallwebwaf \
|
|
<registry>/app@sha256:<digest>
|
|
```
|
|
|
|
`deploy/example-app` is such an app. `make example-app` builds it and checks it
|
|
(see "Checking that it works" below).
|
|
|
|
To work on the code, run `smallwebwaf` outside the image. `make build` builds
|
|
`bin/smallwebwaf` with the Go on your host. `make run` builds it and runs it
|
|
with the settings in your environment. It then listens on port 8080 in front of
|
|
an app at `SWWAF_UPSTREAM_URL`, by default `http://127.0.0.1:8081`. Its state
|
|
files go in `bin/state` unless `SWWAF_STATE_DIR` is set. Its rule files are
|
|
those of `share/rules.d` unless `SWWAF_RULES_DIR` is set.
|
|
|
|
## How it sits in front of the app
|
|
|
|
The `smallwebwaf` image is built on Ubuntu 26.04 LTS, pinned by digest. It holds
|
|
three packages from a dated snapshot of Ubuntu's archive: `ca-certificates`,
|
|
`nix-bin` and `runit`. It also holds nixpkgs, the package collection Nix
|
|
installs from, fixed at one commit. An app's `Dockerfile` installs a package
|
|
with `nix-env -iA nixpkgs.<name>`, and gets the same package each time it is
|
|
built. Such a package lands on the `PATH` of every process in the container,
|
|
after Ubuntu's own commands.
|
|
|
|
- The container's first process is `runsvinit`, which starts runit. runit is a
|
|
service supervisor. It runs the `run` script of each directory under
|
|
`/etc/service`, and runs it again a second after its program exits. The image
|
|
ships `/etc/service/smallwebwaf/run`, which starts `smallwebwaf` as the
|
|
`smallwebwaf` user (uid 65532). The app's `Dockerfile` adds a service
|
|
directory of its own beside it, named after the app. Its `run` script starts
|
|
the app as a user of its own. Neither `smallwebwaf` nor the app runs as root.
|
|
The app's user 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 now `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 left at the usual default, the private ranges alone,
|
|
sees every visitor as `127.0.0.1`. Each request also carries its id in
|
|
`X-Request-ID`, the same id as in its log line.
|
|
- 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" below).
|
|
- 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. At
|
|
each start, the `run` script gives that directory and every file in it to the
|
|
`smallwebwaf` user. A host directory mounted there needs no change of owner.
|
|
- The image's health check runs `smallwebwaf healthcheck`. It passes while
|
|
`smallwebwaf` answers `GET /_smallwebwaf/healthz` on `127.0.0.1` and the app
|
|
accepts connections at `SWWAF_UPSTREAM_URL`. It runs every second from the
|
|
container's start until it first passes, for up to a minute, and every 30
|
|
seconds after that. traefik sends a container no requests until it is healthy.
|
|
- `SWWAF_LISTEN_ADDR` can move `smallwebwaf` to another port. The app then
|
|
leaves that port free instead, the health check follows it, and traefik's
|
|
label must name it. Keep the address part empty: `:9000`, never
|
|
`127.0.0.1:9000`. traefik reaches `smallwebwaf` on the container's address,
|
|
and the health check on `127.0.0.1`.
|
|
- When `smallwebwaf` or the app exits, runit starts it again a second later. The
|
|
other process keeps running. While `smallwebwaf` is down, traefik answers
|
|
`502`. While the app is down, `smallwebwaf` answers `502`. A `smallwebwaf`
|
|
that stops at start, as on an invalid setting, is started again every second
|
|
until the cause is fixed. It logs its message each time.
|
|
- `docker stop` has runit send `SIGTERM` to both processes at once.
|
|
`smallwebwaf` stops taking requests, gives those in progress five seconds to
|
|
finish, and writes its state files. Those requests can finish only while the
|
|
app still answers. An app that exits at once on `SIGTERM` ends them: the
|
|
client gets `502`, or a cut-off answer. An app that should let them finish
|
|
keeps answering for the whole five seconds.
|
|
|
|
## What happens to a request
|
|
|
|
`smallwebwaf` handles each request in this order. The first step that refuses it
|
|
ends the handling. Nothing of a refused request reaches the app.
|
|
|
|
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` names
|
|
the netblocks traefik comes from, by default the private address ranges.
|
|
- A TCP peer outside those netblocks is the client. Its forwarded headers
|
|
are replaced.
|
|
- For a peer inside them, `X-Forwarded-For` is read from the right. The
|
|
first address outside those netblocks is the client.
|
|
- If every address in the header is inside them, the leftmost is the client.
|
|
With no header, the peer is.
|
|
- An IPv6 client is one whole group: the netblock around its address that
|
|
`SWWAF_IPV6_GROUP_PREFIX` sets, a /64 by default. One abuser usually holds
|
|
a whole /64.
|
|
3. A client in `SWWAF_ALLOW_NETS` skips every check below. 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`. The lookup asks the GeoJS web service by
|
|
default, or a database file you supply.
|
|
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.
|
|
7. The reputation sources are checked, in this order:
|
|
- the blocklists `SWWAF_BLOCKLIST_URLS` names;
|
|
- the decision list of the CrowdSec engine `SWWAF_CROWDSEC_LAPI_URL` names;
|
|
- the DNS blocklists `SWWAF_DNSBL_ZONES` names;
|
|
- AbuseIPDB, while `SWWAF_ABUSEIPDB_KEY` is set.
|
|
|
|
A source that lists the client refuses it, lowers its limits, or only notes
|
|
it, as `SWWAF_BLOCKLIST_ACTION` and `SWWAF_REPUTATION_ACTION` say. A
|
|
CrowdSec decision bans the client until the decision ends. No request waits
|
|
for a query. DNS blocklists and AbuseIPDB are asked in the background, and
|
|
their answer applies to the client's later requests.
|
|
|
|
8. Rate limiting counts the request, unless the client is in
|
|
`SWWAF_RATE_LIMIT_EXEMPT_NETS` or the path is under
|
|
`SWWAF_RATE_LIMIT_EXEMPT_PATHS`. The client's percentage of the limits is the
|
|
lowest that applies to it, from its AS number, its country or a reputation
|
|
source; otherwise 100. 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, in file name order and line order. 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. It
|
|
inspects the body too once `SWWAF_WAF_BODY_LIMIT` is set. A request it takes
|
|
for an attack is refused in `block` mode, the default, and let through in
|
|
`detect` mode. 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. A WebSocket, a
|
|
connection the app switches to another protocol, passes through, and the
|
|
timeouts do not cut it.
|
|
13. Once the answer has ended:
|
|
- The request's bytes are counted for the byte limits. A client whose bytes
|
|
pass a limit is banned, so its next request is refused.
|
|
- A refusal by a rule, a trap path, the Core Rule Set or a token check is
|
|
counted for the error burst. More than `SWWAF_ERROR_BURST_THRESHOLD` such
|
|
refusals of one client in a minute ban it.
|
|
- The request goes into the client's history and the anomaly counters, any
|
|
alert due is raised, and the log line is written.
|
|
|
|
In `observe` mode (`SWWAF_MODE=observe`), steps 3 to 11 refuse nothing and ban
|
|
no one. A request they would have refused is passed to the app, and its log line
|
|
says what `enforce` mode would have done. The size and time limits still apply,
|
|
and so does the token check. Observe mode is for trying a configuration before
|
|
enforcing it.
|
|
|
|
## What a refused visitor sees
|
|
|
|
- `SWWAF_BAN_RESPONSE` answers most refusals: a banned client, a broken rate
|
|
limit, a `ban` rule, a trap path, `SWWAF_DENY_NETS`, the country lists, and a
|
|
reputation source whose action is `deny`. By default it is `403` with the
|
|
plain text `Forbidden`. It can be `429` instead, or `close`, which closes the
|
|
connection without an answer. Behind traefik, `close` still answers the
|
|
client: traefik sends `502`, as it does whenever its backend drops a
|
|
connection.
|
|
- A `block` rule and the Core Rule Set always answer `403`.
|
|
- The size and time limits answer with their own statuses:
|
|
- `413` for a request body over `SWWAF_REQUEST_MAX_BYTES`;
|
|
- `408` for a client too slow to send its body;
|
|
- `504` for an app too slow to answer, or too slow to take the request;
|
|
- `502` for an answer over `SWWAF_RESPONSE_MAX_BYTES`, or an app that cannot
|
|
be reached.
|
|
|
|
Once an answer has started, a limit can only cut the connection.
|
|
|
|
- Go's HTTP server answers two cases before `smallwebwaf` sees the request, and
|
|
neither gets a log line. A request line and headers over
|
|
`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` get `431`. A client too slow to send
|
|
them gets an error answer, mostly `400`, or no answer.
|
|
- An endpoint of `smallwebwaf`'s own answers `401` to a request without its
|
|
token. While that token is unset, it answers `404`, as does any other path
|
|
under `/_smallwebwaf/`.
|
|
|
|
## What the operator sees
|
|
|
|
- Every request gets one JSON line on stdout, which `docker logs` shows. Its
|
|
`action` field says what was done, such as `forward`, `banned`, `rate_limited`
|
|
or `waf_blocked`. Other fields say why: the rules that matched, the Core Rule
|
|
Set's score, the limit broken, the reputation sources that listed the client,
|
|
and when its ban ends. See "Request log" below.
|
|
- Every ban is in `bans.json` in the state directory, with notes on why it was
|
|
made. See "Bans" below.
|
|
- Every ban, Core Rule Set match, reputation hit, anomaly and failure of a
|
|
source or a file raises an alert. See "Alerts" below.
|
|
- With `SWWAF_ADMIN_TOKEN` set, `GET /_smallwebwaf/clients/<ip>` answers with
|
|
everything `smallwebwaf` knows of a client, and `GET /_smallwebwaf/bans` with
|
|
every ban. See "Admin endpoints" below.
|
|
|
|
## Checking that it works
|
|
|
|
- `docker inspect --format '{{.State.Health.Status}}' <container>` says
|
|
`healthy` once `smallwebwaf` answers and the app accepts connections. A log
|
|
line with `"msg":"starting"` gives the version, the address and every setting
|
|
in effect, with secrets masked.
|
|
- A request to the site through traefik gets the app's answer, and its log line
|
|
has `"action":"forward"`. `https://<site>/_smallwebwaf/healthz` answers `ok`.
|
|
- To see a refusal without banning yourself, run the container with
|
|
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app. Its log
|
|
line has `"would_action":"banned"` and `"rule_ids":["env-file"]`. In `enforce`
|
|
mode the same request bans your address for seven days, and your next request
|
|
for good. Lift the ban by editing `bans.json` or through the ban endpoint (see
|
|
"Bans" below).
|
|
- With `SWWAF_METRICS_TOKEN` set,
|
|
`curl -H "Authorization: Bearer $TOKEN" https://<site>/_smallwebwaf/metrics`
|
|
answers with the metrics (see "Metrics" below).
|
|
- `make example-app` builds the image and the example app on it, runs the app,
|
|
and checks it. It needs network access. It checks:
|
|
- the health check passes;
|
|
- a request reaches the app through `smallwebwaf`;
|
|
- a second request in a minute bans the client (the check sets the rate
|
|
limit to one a minute);
|
|
- a probe for `/.env` bans another client, and its next request makes the
|
|
ban permanent;
|
|
- `sv stop` and `docker stop` stop it in order;
|
|
- a new container on the same volume still refuses the banned client;
|
|
- the state directory can be named by a file, and a missing one is reported.
|
|
|
|
## Settings
|
|
|
|
Every setting is an environment variable whose name starts with `SWWAF_`. Every
|
|
setting has a default, so none has to be set. Settings are read once, at start.
|
|
To change one, restart the container.
|
|
|
|
A setting that is set to a value it cannot take stops the start. The process
|
|
writes one line, `"type":"process"` with `"msg":"invalid setting"`, naming the
|
|
variable and what is wrong with the value. It exits with status 1, and runit
|
|
starts it again every second until the value is fixed. The entries below say
|
|
which values each setting takes. Any other value stops the start. At start, the
|
|
settings in effect are logged on one line. Tokens, keys and the paths of
|
|
webhooks are shown as `********`.
|
|
|
|
The forms of values:
|
|
|
|
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. Zero
|
|
and negative durations are refused.
|
|
- A size is a number of bytes with an optional `K`, `M` or `G`, which are powers
|
|
of 1024: `1K` is 1024 bytes, `512K`, `100M`, `5G`.
|
|
- A count is a whole number of requests, such as `1000`.
|
|
- `off` switches a setting off, where its entry below says it takes `off`.
|
|
- A list is comma separated, with spaces around items ignored. An empty item is
|
|
refused. A list set but empty is an empty list, and replaces the default.
|
|
- A netblock is an address and a prefix length, such as `10.0.0.0/8`. A bare
|
|
address stands for itself alone.
|
|
- A country is a two-letter code of ISO 3166-1, in either case: `de` and `DE`
|
|
are the same. `xk` stands for Kosovo. A code that is not a country is refused,
|
|
such as `nk` (North Korea is `kp`) or the withdrawn `su`.
|
|
- An AS number is `AS` and the number, in either case, such as `AS64496`.
|
|
- A percentage is a whole number from 0 to 100.
|
|
- A path prefix starts with `/`, such as `/assets/`.
|
|
|
|
### Where it listens and what it protects
|
|
|
|
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `smallwebwaf` listens, an
|
|
optional host and a port. Keep the host empty.
|
|
- `SWWAF_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or
|
|
`https` URL with a host and an optional port, and nothing more: no path, user
|
|
or query.
|
|
- `SWWAF_INSTANCE_NAME` (default: the container's host name, the first 12
|
|
characters of the container's id): the name every log line, alert and metric
|
|
gives as `instance`. Set it to a name that survives deploys and tells
|
|
instances apart, such as `fsn1app1/gitea`. It must be valid UTF-8. While
|
|
`SWWAF_ALERT_NTFY_URL` is set, it may hold no control character. While
|
|
`SWWAF_LOG_REMOTE_URL` is set and `SWWAF_LOG_REMOTE_APP_NAME` is not, it must
|
|
be 1 to 48 printable ASCII characters without a space.
|
|
- `SWWAF_MODE` (default `enforce`): `enforce`, or `observe` to refuse nothing
|
|
and log what would have been refused (see "What happens to a request" above).
|
|
- `SWWAF_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`,
|
|
the private address ranges): the netblocks whose `X-Forwarded-For` is
|
|
believed, normally where traefik comes from. Set but empty, it trusts nothing:
|
|
every TCP peer is then the client.
|
|
- `SWWAF_IPV6_GROUP_PREFIX` (default `64`): the length of the netblock that is
|
|
one IPv6 client, from 32 to 128. After a change, each IPv6 client starts
|
|
afresh. Existing bans keep refusing their netblocks until they end.
|
|
- `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept in memory
|
|
and in `clients.json`, a whole number above zero. Past it, the least recently
|
|
seen client is dropped first.
|
|
|
|
### Time and size limits
|
|
|
|
Each request has limits on both sides: between the client and `smallwebwaf`, and
|
|
between `smallwebwaf` and the app. Bodies stream through in both directions and
|
|
are never held whole in memory.
|
|
|
|
- `SWWAF_CLIENT_REQUEST_TIMEOUT` (default `60s`): how long a client may take to
|
|
send its request line and headers, and then, from the end of the headers, its
|
|
body. A duration or `off`.
|
|
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` (default `32K`): the largest request
|
|
line and headers. A size over `4K`. It cannot be `off`: Go's HTTP server
|
|
always has such a limit.
|
|
- `SWWAF_CLIENT_IDLE_TIMEOUT` (default `120s`): how long a kept-open connection
|
|
may wait for its next request before it is closed. A duration or `off`. The
|
|
default is longer than the 90 seconds after which traefik closes an unused
|
|
connection, so traefik closes first.
|
|
- `SWWAF_CLIENT_RESPONSE_TIMEOUT` (default `30m`): how long the answer may take
|
|
to reach the client, from the end of the request to the last byte. A duration
|
|
or `off`.
|
|
- `SWWAF_UPSTREAM_REQUEST_TIMEOUT` (default `60s`): how long connecting to the
|
|
app and sending it the whole request may take. A duration or `off`.
|
|
- `SWWAF_UPSTREAM_RESPONSE_TIMEOUT` (default `30m`): how long the app may take
|
|
to send its whole answer. A duration or `off`.
|
|
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body. A size
|
|
or `off`. A request that announces a larger body is refused before anything
|
|
reaches the app.
|
|
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body. A size
|
|
or `off`.
|
|
|
|
In front of a code forge, a clone is a response and a push is a request. At the
|
|
defaults, an upload over 100 MiB or slower than 60 seconds is cut off. A forge
|
|
that takes large uploads needs `SWWAF_REQUEST_MAX_BYTES`,
|
|
`SWWAF_CLIENT_REQUEST_TIMEOUT` and `SWWAF_UPSTREAM_REQUEST_TIMEOUT` raised to
|
|
fit.
|
|
|
|
### Netblock lists
|
|
|
|
- `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip every check,
|
|
such as your monitoring or your own network. The size and time limits still
|
|
apply to them. They are not looked up. They still need a token for the admin
|
|
endpoints.
|
|
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients rate
|
|
limiting and the byte limits neither count nor refuse, such as a machine that
|
|
talks to the app all day. Everything else still applies to them.
|
|
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are always refused.
|
|
A client in `SWWAF_ALLOW_NETS` too is let through.
|
|
|
|
### Rate limiting and byte limits
|
|
|
|
- `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`. The defaults are
|
|
several times what one busy person produces. A browser loading a heavy page
|
|
makes a few hundred requests, and several people often share one address.
|
|
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes rate limiting
|
|
does not count, such as `/assets/`. Their bytes are not counted either. The
|
|
path is matched as sent, before any query and not percent-decoded, character
|
|
for character. `/assets/` matches `/assets/app.js`. It does not match
|
|
`/Assets/app.js`, `/%61ssets/app.js` or `/static/assets/app.js`. There are no
|
|
wildcards. A path holding `..`, a backslash or an encoded slash (`%2F`) is
|
|
never exempt, since the app may read it as a path outside the prefix.
|
|
- `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 a client may have counted in each window. A size or `off`. Bytes are
|
|
counted once the answer has ended, so a byte limit never cuts an answer short.
|
|
The client's next request is refused instead. Each default is above the
|
|
largest request and answer together, so at the defaults no single download
|
|
breaks a limit.
|
|
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
|
`response`, `request` or `both`. For a WebSocket, what it carried each way
|
|
counts once it closes.
|
|
|
|
### Looking up the AS number and country
|
|
|
|
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `geojs`
|
|
asks the GeoJS web service, which is then told the address of every new
|
|
visitor. `file` looks clients up in the IPinfo Lite database file
|
|
`SWWAF_LOOKUP_DB_PATH` names, and sends nothing off the host. `off` looks up
|
|
no client. With `off`, a setting that needs lookups stops the start:
|
|
- a country list;
|
|
- `SWWAF_ADD_LOOKUP_HEADERS`;
|
|
- an entry in `SWWAF_ASN_LIMIT_PERCENT`, `SWWAF_COUNTRY_LIMIT_PERCENT`,
|
|
`SWWAF_ASN_BYTES_PERCENT` or `SWWAF_COUNTRY_BYTES_PERCENT`;
|
|
- `SWWAF_UNKNOWN_LIMIT_PERCENT` below 100;
|
|
- `SWWAF_ASN_LIMIT_PERCENT_URL`;
|
|
- an anomaly threshold per AS number.
|
|
- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
|
`.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`. `file` without it stops the
|
|
start, and so does setting it with any other source. A file that is missing or
|
|
cannot be read stops the start too.
|
|
- `SWWAF_LOOKUP_TIMEOUT` (default `1s`): how long a request waits for its
|
|
client's first answer from GeoJS when a setting needs it, and how long a
|
|
request to GeoJS may take. A duration; it cannot be `off`.
|
|
- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` passes the app the
|
|
client's AS number in `X-Client-ASN` and its country in `X-Client-Country`. A
|
|
header whose value is unknown is left out. Whatever this setting says, any
|
|
such header the client sent is removed.
|
|
|
|
See "GeoJS and the lookup database" below for what each source sends where, and
|
|
how long answers are kept.
|
|
|
|
### Country lists
|
|
|
|
- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
|
|
such as `cn,ru,kp`.
|
|
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
|
countries whose clients get through, such as `us,de`. A client whose country
|
|
cannot be found is refused too. Otherwise every new client would be let in
|
|
whenever GeoJS stops answering. A client on a private address has no country,
|
|
so it is refused unless it is in `SWWAF_ALLOW_NETS`.
|
|
|
|
A country on both lists stops the start. A refused request is answered as a
|
|
banned client is, before its body is read. It is not an offence and makes no
|
|
ban.
|
|
|
|
### Lower limits by AS number and country
|
|
|
|
Clients of the AS numbers and countries listed here get a percentage of every
|
|
rate limit and byte limit. The same rules then ban them after fewer requests. A
|
|
lowered limit is rounded down. `0` is a zero allowance: the client's first
|
|
request bans it. Each client is counted on its own. No budget is shared by a
|
|
whole AS number or country, which one abuser could use up.
|
|
|
|
- `SWWAF_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:`
|
|
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
|
|
listed twice is refused.
|
|
- `SWWAF_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
|
|
`cn:25,ru:50`.
|
|
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` (default empty):
|
|
the same for the byte limits alone. For the AS numbers and countries they
|
|
list, they take the place of the two settings above.
|
|
- `SWWAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage every client
|
|
without a country gets: one the lookup cannot place, one on a private address,
|
|
and one whose answer from GeoJS has not come in time.
|
|
- `SWWAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
|
text file of such entries, one to a line, such as `AS14061:50`. One list can
|
|
then serve several instances. It is fetched and kept as a blocklist is. Where
|
|
it and `SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. A
|
|
URL that `SWWAF_BLOCKLIST_URLS` names too stops the start.
|
|
|
|
A client to which several percentages apply gets the lowest. The log line of
|
|
each counted request, and the notes of each ban, say which setting lowered the
|
|
limit.
|
|
|
|
### Reputation sources
|
|
|
|
None is on by default. A reputation source judges a client by what others saw it
|
|
do. The defaults judge it by what it does to your service.
|
|
|
|
- `SWWAF_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
|
`https://www.spamhaus.org/drop/drop.txt`. Each is an `http` or `https` URL
|
|
without a user or a fragment. A URL listed twice is refused.
|
|
- `SWWAF_BLOCKLIST_REFRESH` (default `24h`): how long after a list was last
|
|
fetched, or a fetch failed, it is fetched again. A duration of `1h` or more;
|
|
it cannot be `off`. The Spamhaus lists may be fetched no more than once an
|
|
hour.
|
|
- `SWWAF_BLOCKLIST_ACTION` (default `deny`): what is done with a client a
|
|
blocklist lists. `deny` refuses its requests. `limit:<percent>`, such as
|
|
`limit:25`, gives it that percentage of every limit. `log` only notes the list
|
|
in the log line and raises an alert.
|
|
- `SWWAF_DNSBL_ZONES` (default empty): the DNS blocklists to ask about each
|
|
client, such as `dnsbl.dronebl.org`. A DNS blocklist lists an address by
|
|
answering a DNS query for a name made from it. A zone is a DNS name of at most
|
|
189 characters. A Spamhaus zone carries its key, as in
|
|
`<key>.xbl.dq.spamhaus.net`, and is shown with the key masked. A zone listed
|
|
twice is refused, whatever its case or key. Do not name a zone meant for mail:
|
|
such a zone lists the residential addresses ordinary visitors come from.
|
|
- `SWWAF_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
|
|
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional
|
|
port, 53 when none is given. Unset, the host's resolver is used. Several zones
|
|
refuse queries that come through a public resolver.
|
|
- `SWWAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account.
|
|
While it is set, a client that has committed an offence is checked. A control
|
|
character in it is refused.
|
|
- `SWWAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
|
|
from 0 to 100, that is a hit.
|
|
- `SWWAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day,
|
|
in UTC, a whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
|
- `SWWAF_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
|
|
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a
|
|
user or a fragment. Its decision list is at `v1/decisions` under that URL, and
|
|
is fetched every minute. Set without the key, it stops the start. So does a
|
|
decision list URL that `SWWAF_BLOCKLIST_URLS` or `SWWAF_ASN_LIMIT_PERCENT_URL`
|
|
names too.
|
|
- `SWWAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
|
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
|
URL, it stops the start.
|
|
- `SWWAF_REPUTATION_ACTION` (default `limit:25`): what is done with a client a
|
|
DNS blocklist lists, or whose AbuseIPDB score is a hit. `deny`,
|
|
`limit:<percent>` or `log`, as for `SWWAF_BLOCKLIST_ACTION`. Such a verdict is
|
|
less certain than a list like DROP, so by default the client gets a quarter of
|
|
every limit.
|
|
- `SWWAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
|
AbuseIPDB's score is used. A duration; it cannot be `off`.
|
|
- `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a
|
|
check with AbuseIPDB may take. A duration; it cannot be `off`.
|
|
|
|
See "Blocklists", "DNS blocklists", "AbuseIPDB" and "CrowdSec" below.
|
|
|
|
### Attack detection
|
|
|
|
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the
|
|
rule files (see "Rule files" below). A directory that cannot be read stops the
|
|
start. An empty one is no error.
|
|
- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
|
- `SWWAF_TRAP_PATHS` (default empty): paths the app never serves and only
|
|
scanners ask for, such as `/wp-login.php,/xmlrpc.php` in front of a code
|
|
forge. A request for one bans the client, as a `ban` rule does. Each starts
|
|
with `/` and holds no `?`. A trap path is matched whole, without the query,
|
|
against the path as sent and once percent-decoded. `/wp-login.php` matches
|
|
`/wp-login.php?x=1` and `/%77p-login.php`. It does not match `/wp-login.php/`
|
|
or `/blog/wp-login.php`.
|
|
- `SWWAF_WAF_MODE` (default `block`): what the Core Rule Set does with a request
|
|
it takes for an attack. `block` refuses it with `403`. `detect` lets it
|
|
through, logged and alerted. `off` inspects no request.
|
|
- `SWWAF_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
|
|
from 1 to 4. Each level up runs more rules. Those find more attacks, and take
|
|
more ordinary requests for attacks.
|
|
- `SWWAF_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
|
|
match. Each rule that matches adds to the request's score, and a rule rated
|
|
critical adds 5. So by default one such rule is enough. A whole number above
|
|
zero, or `off`. With `off` no request is a match, and scores are still logged.
|
|
- `SWWAF_WAF_DISABLED_RULES` (default
|
|
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
|
to switch off. The log line names the rules a request matched, so a rule that
|
|
refuses your app's ordinary requests can be found there. The default switches
|
|
off six rules that get in the way of a code forge:
|
|
- 920340, 920420 and 920640 refuse a request for the type of its body, when
|
|
the type is missing or not on the Core Rule Set's short list;
|
|
- 920440 refuses a request for its file extension, such as `.sh` or `.sql`;
|
|
- 930130 and 930140 refuse a file or directory name in the path, such as
|
|
`.git/`, `.gitignore` or `package.json`.
|
|
|
|
In front of a code forge these refuse git over HTTP, uploads and views of
|
|
ordinary files. The default rule file bans those probes at the site root
|
|
instead. A list given replaces the default, so include them. An id must be a
|
|
whole number above zero. An id from 900000 to 900999 is refused: those are
|
|
the rules that set the Core Rule Set up, and `smallwebwaf`'s own.
|
|
|
|
- `SWWAF_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
|
|
not inspect, matched as `SWWAF_RATE_LIMIT_EXEMPT_PATHS` is.
|
|
- `SWWAF_WAF_BODY_LIMIT` (default `off`): `off` reads no request body. A size,
|
|
at most `1G`, switches body inspection on. The Core Rule Set then reads a form
|
|
data or multipart body up to that size, and the rest streams on to the app. It
|
|
reads a JSON or XML body no larger than that size, since those cannot be read
|
|
in part. Any other body, such as a git push, reaches the app uninspected. See
|
|
"The Core Rule Set" below.
|
|
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client
|
|
in a minute after a rule match, a trap path, a Core Rule Set match or a token
|
|
check. One more bans the client, as a broken rate limit does. A count or
|
|
`off`. The app's own `401` and `404` answers are not counted.
|
|
|
|
### How long bans last and what they cover
|
|
|
|
- `SWWAF_BAN_RESPONSE` (default `403`): how a refused client is answered: `403`,
|
|
`429`, or `close` to close the connection without an answer. A `block` rule
|
|
and the Core Rule Set always answer `403`.
|
|
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken rate
|
|
limit, byte limit or error burst. A duration; it cannot be `off`.
|
|
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): a limit broken again within
|
|
this time after a ban ended bans for three times as long as that ban. A
|
|
duration; it cannot be `off`.
|
|
- `SWWAF_MAX_BAN_DURATION` (default `7d`): a ban for a broken limit that would
|
|
be longer is permanent instead. A duration; it cannot be `off`.
|
|
- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
|
attack. A duration; it cannot be `off`.
|
|
- `SWWAF_MAX_BANS` (default `5000`): the most bans `smallwebwaf` made that are
|
|
kept, past, active and permanent. A whole number above zero. Bans you make or
|
|
keep are kept besides, and never dropped.
|
|
- `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): the length of the netblock around
|
|
an IPv4 client that a ban covers, from 0 to 32. `24` bans the surrounding /24.
|
|
An IPv6 ban covers the client's group.
|
|
|
|
### Where and how often the state files are written
|
|
|
|
- `SWWAF_STATE_DIR` (default `/var/lib/smallwebwaf`): the directory of the state
|
|
files, an absolute path. A directory that does not exist or cannot be written
|
|
stops the start.
|
|
- `SWWAF_STATE_WRITE_DELAY` (default `10s`): how long after a ban is made,
|
|
lifted or made permanent `bans.json` is written, with every change in between.
|
|
A duration; it cannot be `off`.
|
|
- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
|
written. A duration; it cannot be `off`.
|
|
|
|
### Logging
|
|
|
|
- `SWWAF_LOG_REQUEST_HEADERS` (default
|
|
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
|
request headers whose values the log line gives, in either case.
|
|
`Authorization`, `Cookie` and `Set-Cookie` are never logged, even if listed.
|
|
`Host` and `Transfer-Encoding` are refused: Go's HTTP server takes them out of
|
|
the request. The host is the field `host`.
|
|
- `SWWAF_LOG_LEVEL` (default `info`): the least severe of `smallwebwaf`'s own
|
|
messages that are written: `debug`, `info`, `warn` or `error`. No message is
|
|
at `debug` yet. It holds back no request log line. `warn` and `error` hold
|
|
back the line of settings at start.
|
|
- `SWWAF_LOG_REMOTE_URL` (default unset): a syslog server every line on stdout
|
|
is also sent to, such as `syslog+tls://logs.example:6514`. The forms are
|
|
`syslog+udp://`, `syslog+tcp://` and `syslog+tls://`, each with a host and a
|
|
port and nothing more.
|
|
- `SWWAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates
|
|
that a `syslog+tls` server's certificate must chain to, instead of the host's
|
|
own. A file that cannot be read, or holds no certificate, stops the start.
|
|
- `SWWAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
|
|
wait to be sent, a whole number above zero.
|
|
- `SWWAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
|
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
|
`authpriv`, `ftp`, or `local0` to `local7`.
|
|
- `SWWAF_LOG_REMOTE_APP_NAME` (default: `SWWAF_INSTANCE_NAME`): the app name the
|
|
records carry, 1 to 48 printable ASCII characters without a space.
|
|
|
|
### Tokens and metrics
|
|
|
|
- `SWWAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
|
`/_smallwebwaf/clients/<ip>`. A long random value of at least 32 characters.
|
|
Unset, those endpoints are off. Give it as a file to keep it from the app (see
|
|
"Settings given as files" below).
|
|
- `SWWAF_METRICS_TOKEN` (default unset): the token a scraper sends for
|
|
`/_smallwebwaf/metrics`, at least 32 characters. Unset, the metrics are off.
|
|
It is separate from the admin token, so a scraper cannot manage bans.
|
|
- `SWWAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
|
|
countries get series of their own in the metrics. The rest count as `other`. A
|
|
whole number above zero.
|
|
|
|
### Alert destinations
|
|
|
|
- `SWWAF_ALERT_WEBHOOK_URL` (default unset): a webhook each alert is posted to
|
|
as a JSON object. An `http` or `https` URL without a user or a fragment. The
|
|
settings logged at start show its path and query as `********`, since many
|
|
webhooks carry their secret there.
|
|
- `SWWAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post,
|
|
such as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and
|
|
value. A value cannot hold a comma.
|
|
- `SWWAF_ALERT_SLACK_WEBHOOK_URL` (default unset): a Slack incoming webhook each
|
|
alert is posted to as a message. It is checked and masked as the webhook URL
|
|
is.
|
|
- `SWWAF_ALERT_NTFY_URL` (default unset): an ntfy topic each alert is published
|
|
to, as the topic's full URL, such as `https://ntfy.sh/my-alerts`. It is
|
|
checked and masked as the webhook URL is.
|
|
- `SWWAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token, sent as
|
|
`Authorization: Bearer <token>`. A control character in it is refused.
|
|
- `SWWAF_ALERT_EVENTS` (default
|
|
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
|
the events alerts are sent for. Any other name is refused.
|
|
- `SWWAF_ALERT_COOLDOWN` (default `15m`): how long a repeat of an alert is held
|
|
back. A duration or `off`.
|
|
- `SWWAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour.
|
|
The rest are rolled into one summary. A whole number above zero, or `off`.
|
|
|
|
### Anomaly thresholds
|
|
|
|
These only alert. They refuse and ban nothing. Each scope has four thresholds:
|
|
the most requests and the most bytes counted in a minute and in an hour. Their
|
|
names are the scope's prefix and `REQUESTS_PER_MINUTE`, `REQUESTS_PER_HOUR`,
|
|
`BYTES_PER_MINUTE` or `BYTES_PER_HOUR`. Requests are counts, bytes are sizes.
|
|
Each is `off` by default. What is unusual depends on each service's normal
|
|
traffic, which the metrics show.
|
|
|
|
- `SWWAF_ANOMALY_CLIENT_...`: per client.
|
|
- `SWWAF_ANOMALY_NET_...`: per netblock around a client. The netblock is
|
|
`SWWAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) long for an IPv4
|
|
client, and `SWWAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) long
|
|
for an IPv6 one.
|
|
- `SWWAF_ANOMALY_ASN_...`: per AS number. Needs lookups on.
|
|
- `SWWAF_ANOMALY_TOTAL_...`: the whole service.
|
|
- `SWWAF_WATCH_...`: each netblock `SWWAF_WATCH_NETS` names, as a whole.
|
|
`SWWAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
|
|
as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
|
name, or with a bad netblock, is refused. So is a name used twice.
|
|
|
|
Every request but the health check is counted, whatever was done with it,
|
|
clients in `SWWAF_ALLOW_NETS` included. At most 20,000 counters are kept.
|
|
`alerts.json` keeps them across a restart.
|
|
|
|
### Settings given as files
|
|
|
|
Any setting may be given as a file holding its value. The variable named like
|
|
the setting with `_FILE` added, such as `SWWAF_METRICS_TOKEN_FILE`, names the
|
|
file. The file's contents are the value, less one newline at the end. They are
|
|
checked as the value itself would be. Setting both forms stops the start, and so
|
|
does naming a file that cannot be read. `SWWAF_LOG_REMOTE_TLS_CA_FILE` names a
|
|
file already, and has no `_FILE` form.
|
|
|
|
A token given as a file is out of the app's reach only while the `smallwebwaf`
|
|
user alone can read the file. Make it on the host, owned by uid 65532 with mode
|
|
`0400`. Mount its directory into the container read-only:
|
|
|
|
```sh
|
|
mkdir -p /srv/app/tokens
|
|
openssl rand -hex 32 > /srv/app/tokens/metrics
|
|
chown 65532:65532 /srv/app/tokens/metrics
|
|
chmod 0400 /srv/app/tokens/metrics
|
|
```
|
|
|
|
and for the container, `-v /srv/app/tokens:/etc/smallwebwaf/tokens:ro` and
|
|
`-e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics`.
|
|
|
|
A few limits are fixed rather than settings:
|
|
|
|
- at most 100,000 GeoJS answers are kept, for 7 days each;
|
|
- at most 100,000 DNS blocklist verdicts and 100,000 AbuseIPDB scores are kept;
|
|
- at most 1,000 DNS blocklist queries are under way at once;
|
|
- the CrowdSec decision list is fetched again a minute after the last fetch.
|
|
|
|
## Bans
|
|
|
|
A ban refuses every request from a netblock until it ends, with
|
|
`SWWAF_BAN_RESPONSE`. The netblock is the client's IPv4 address, or the netblock
|
|
around it that `SWWAF_BAN_SCOPE_V4_PREFIX` sets, or the client's IPv6 group. A
|
|
banned client is not looked up and not counted by rate limiting.
|
|
|
|
`smallwebwaf` makes bans for three causes:
|
|
|
|
- `limit`: the client broke a rate limit, a byte limit or the error burst. The
|
|
first such ban lasts `SWWAF_LIMIT_BAN_DURATION`, an hour. A limit broken again
|
|
within `SWWAF_LIMIT_BAN_REPEAT_WINDOW`, a day, of a ban ending bans for three
|
|
times as long as that ban: 1, 3, 9, 27 and 81 hours. A ban that would last
|
|
longer than `SWWAF_MAX_BAN_DURATION`, seven days, is permanent. Such a ban
|
|
sets the client's counters back to zero.
|
|
- `attack`: the client matched a `ban` rule or asked for a trap path. The first
|
|
such ban lasts `SWWAF_ATTACK_BAN_DURATION`, seven days. Any request from the
|
|
netblock while it lasts makes it permanent. Once it has run out, the netblock
|
|
is served like any other. Its next clear sign of attack bans it permanently at
|
|
once.
|
|
- `crowdsec`: a decision of your CrowdSec engine lists the client. The ban lasts
|
|
until the decision ends.
|
|
|
|
A `block` rule and the Core Rule Set ban no one by themselves, since the Core
|
|
Rule Set takes some ordinary requests for attacks. Their refusals count toward
|
|
the error burst instead. `SWWAF_DENY_NETS`, the country lists and a reputation
|
|
source with the action `deny` make no ban either.
|
|
|
|
Each ban carries notes for deciding whether to lift it:
|
|
|
|
- the limit broken, what it was on, its window and the count reached; or the
|
|
rule id and target, or the trap path;
|
|
- the client's percentage of the limit and the setting that gave it, when a
|
|
setting lowered it;
|
|
- the request that caused the ban;
|
|
- the client's AS number, AS name and country;
|
|
- the reputation sources that listed the client;
|
|
- the netblock's requests since it was first seen, and how many this ban has
|
|
refused;
|
|
- how many earlier bans the netblock had, by cause.
|
|
|
|
At most `SWWAF_MAX_BANS` bans `smallwebwaf` made are kept. Past that, the
|
|
earliest ban of the netblock that has gone longest without a request is dropped.
|
|
Bans whose cause is `admin`, those you make or keep, are never dropped.
|
|
|
|
### Managing bans by editing `bans.json`
|
|
|
|
`bans.json` in the state directory holds every ban, indented to be read. A
|
|
permanent ban's `expires` is `null`. `smallwebwaf` watches the file and takes in
|
|
an edit as soon as it is saved.
|
|
|
|
To ban a netblock, add an entry with its `netblock`, `start` and `expires`.
|
|
`expires` is `null` for a ban that never ends. `reason`, `notes` and `cause` may
|
|
be left out; the cause is then `admin`:
|
|
|
|
```json
|
|
{
|
|
"version": 1,
|
|
"bans": [
|
|
{
|
|
"netblock": "203.0.113.0/24",
|
|
"start": "2026-10-06T12:00:00Z",
|
|
"expires": null,
|
|
"reason": "probes for logins"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
To keep a ban `smallwebwaf` made, so that it is never dropped, set its `cause`
|
|
to `admin`. To lift a ban, add `"lifted"` with the time you lift it, such as
|
|
`"lifted": "2026-10-06T13:00:00Z"`. The ban then refuses nothing and does not
|
|
make the netblock's next ban longer. It is kept with its notes. To forget a ban,
|
|
delete its entry. A ban whose cause is `attack` becomes permanent at the first
|
|
request it refuses. One whose cause is `admin` or `crowdsec` does not.
|
|
|
|
An edit that cannot be read does not stop the running `smallwebwaf`. It keeps
|
|
what it holds. At the file's next write it renames your file to `bans.json.bad`,
|
|
writes the file again from memory, and logs and alerts the error. Mend the
|
|
`.bad` file and move it back. See "State files" below.
|
|
|
|
### Managing bans through the admin endpoints
|
|
|
|
With `SWWAF_ADMIN_TOKEN` set, and the token in `$TOKEN`:
|
|
|
|
```sh
|
|
# Every ban.
|
|
curl -H "Authorization: Bearer $TOKEN" https://app.example/_smallwebwaf/bans
|
|
|
|
# Ban 203.0.113.0/24 for seven days.
|
|
curl -H "Authorization: Bearer $TOKEN" \
|
|
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
|
|
https://app.example/_smallwebwaf/bans
|
|
|
|
# Lift the bans on 203.0.113.9.
|
|
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
|
https://app.example/_smallwebwaf/bans/203.0.113.9
|
|
|
|
# What smallwebwaf knows of 203.0.113.9.
|
|
curl -H "Authorization: Bearer $TOKEN" \
|
|
https://app.example/_smallwebwaf/clients/203.0.113.9
|
|
```
|
|
|
|
See "Admin endpoints" below for what each answers.
|
|
|
|
## Rule files
|
|
|
|
`smallwebwaf` reads every `*.rules` file in `SWWAF_RULES_DIR`, in the order of
|
|
their names. It checks each request against their rules in that order. A file
|
|
whose name starts with `.`, such as an editor's lock file, is not a rule file. A
|
|
rule is a line of four fields separated by spaces or tabs: an id, a target, an
|
|
action and a regex. The regex runs to the end of the line. Blank lines and lines
|
|
starting with `#` are ignored.
|
|
|
|
```
|
|
# id target action regex
|
|
env-file path ban (?i)^/\.env(\.[a-z]+)?$
|
|
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
|
|
```
|
|
|
|
- The id is letters, digits, `-` and `_`. No two rules share one. The log line,
|
|
the metrics and a ban's notes name the rule by it.
|
|
- The target is what the regex is matched against:
|
|
- `path`: the path without the query;
|
|
- `uri`: the path and the query together;
|
|
- `query`: the query as sent;
|
|
- `method`, `host`, `user_agent` and `referer`;
|
|
- `header:<Name>`: any one request header but `Host` and
|
|
`Transfer-Encoding`.
|
|
|
|
`path` and `uri` are matched both as sent and once percent-decoded, so that
|
|
an encoded probe does not slip past. A header sent more than once is matched
|
|
with its values joined by `, `. A header not sent is empty text. No body is
|
|
read.
|
|
|
|
- The action is `log`, `block` or `ban`. `log` only notes the match in the log
|
|
line. `block` refuses the request with `403` and bans no one. `ban` refuses it
|
|
and bans the client, as a clear sign of attack. Matching stops at the first
|
|
rule that refuses. Keep `ban` for requests no real visitor sends. Anchor a
|
|
path at the site root with `^/`: a file of the same name deeper in a site can
|
|
be ordinary content.
|
|
- The regex is in Go's syntax, RE2. It has no backreferences or lookaround, and
|
|
takes time linear in the text it reads. It matches anywhere in the target
|
|
unless anchored with `^` and `$`. `(?i)` at its front makes it ignore case.
|
|
|
|
A line that is not a rule stops the start, with a message naming the file and
|
|
line. So does a bad `header:` name, a rule for `Host` or `Transfer-Encoding`, a
|
|
regex that does not compile, or an id used twice. While it runs, `smallwebwaf`
|
|
watches the directory. It reads the files again once the directory has had no
|
|
change for 2 seconds, so that a file still being written is read whole. If the
|
|
files then hold such an error, the rules stay as they were. The log and a
|
|
`file_error` alert name the file and line.
|
|
|
|
The image ships one file, `share/rules.d/00-default.rules`, installed as
|
|
`/etc/smallwebwaf/rules.d/00-default.rules`. Its rules:
|
|
|
|
- ban probes for secrets, version control directories, backups, logs and web
|
|
shells at the site root;
|
|
- ban the user agents of common scanners;
|
|
- block `../` twice in a row in the path or the query;
|
|
- note a request without a user agent.
|
|
|
|
An app's `Dockerfile` adds rules of its own in a file named to sort after it,
|
|
such as `50-gitea.rules`:
|
|
|
|
```
|
|
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
|
```
|
|
|
|
```dockerfile
|
|
COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules
|
|
```
|
|
|
|
A directory mounted over `/etc/smallwebwaf/rules.d` replaces the default file.
|
|
Single files mounted into it add to it. Docker does not show a single mounted
|
|
file being replaced, which is how many editors save. So rules to be edited while
|
|
`smallwebwaf` runs belong in a mounted directory. To run without rules, mount an
|
|
empty directory or set `SWWAF_RULES_ENABLED=false`.
|
|
|
|
## The Core Rule Set
|
|
|
|
The OWASP Core Rule Set 4.25.0 runs on each request after the rule files, unless
|
|
`SWWAF_WAF_MODE` is `off`. It reads the method, the URL with its query, and the
|
|
headers. Once `SWWAF_WAF_BODY_LIMIT` is set, it also reads a body that is form
|
|
data, multipart, JSON or XML. No answer is inspected. Each rule that matches
|
|
adds to the request's score. A score at or over `SWWAF_WAF_ANOMALY_THRESHOLD` is
|
|
a match. A match is refused with `403` in `block` mode, and let through in
|
|
`detect` mode. Either way the log line gives the rule ids and the score, and a
|
|
`waf_block` alert is sent. A request with more than 1000 query parameters or
|
|
body fields adds 5 to its score, since Coraza reads only the first 1000. Three
|
|
kinds of request are not inspected: one a rule file refused, one for a path
|
|
under `SWWAF_WAF_EXEMPT_PATHS`, and one from a client in `SWWAF_ALLOW_NETS`.
|
|
|
|
`smallwebwaf` changes the Core Rule Set in six ways, so that a code forge's
|
|
ordinary requests get through. The changes hold in front of every app. No
|
|
setting undoes them.
|
|
|
|
- `PUT`, `PATCH` and `DELETE` are allowed besides `GET`, `HEAD`, `POST` and
|
|
`OPTIONS`.
|
|
- The headers `Expect` and `Content-Encoding` are allowed. Once
|
|
`SWWAF_WAF_BODY_LIMIT` is set, `Content-Encoding` is refused again on a body
|
|
the Core Rule Set reads, since a compressed body cannot be inspected.
|
|
- The query parameter `redirect_uri` is not checked for a URL naming an IP
|
|
address or `localhost` (rules 931100 and 934110). Git credential helpers ask
|
|
to be sent back to such a URL.
|
|
- Ten query parameters are not checked against three lists: system files
|
|
(930120), shell paths (932160) and command names (932260). They are `path`,
|
|
`files`, `skip-to`, `sub_path`, `ref`, `sha`, `branch`, `workflow`,
|
|
`artifactName` and `redirect_to`. A file such as `.gitignore` or a branch such
|
|
as `docker-build` then gets through there. So does what only those three rules
|
|
refuse, such as `|cat /etc/passwd`. Path traversal and SQL injection are still
|
|
refused there. Names are matched without regard to case. An app that uses one
|
|
of these parameters as a file on the server, or passes it to a shell, has to
|
|
check it itself.
|
|
- The cookies `gitea_flash` and `redirect_to` are not read. `Referer` is not
|
|
checked for a Unix command given without arguments (932340), or for Java
|
|
starting a process (944110).
|
|
- Responses are not inspected.
|
|
|
|
Body inspection suits apps whose forms carry no code. In front of a code forge
|
|
it refuses issue and comment text, wiki pages and files saved in the web editor
|
|
that hold shell commands or code. The log line names the rule ids, which
|
|
`SWWAF_WAF_DISABLED_RULES` can switch off. A body the Core Rule Set cannot parse
|
|
adds 5 to the score. So does a multipart body the limit cuts in the middle of a
|
|
part's header. The client has until `SWWAF_CLIENT_REQUEST_TIMEOUT` runs out to
|
|
send the part that is read.
|
|
|
|
## Admin endpoints
|
|
|
|
`smallwebwaf` answers these requests itself, on the app's own address and
|
|
through traefik like any other request. Apart from the health check, each needs
|
|
a token, sent as `Authorization: Bearer <token>`. Each goes through every check
|
|
any other request goes through. It is answered where another request would be
|
|
passed to the app: a banned client stays refused, and each request counts toward
|
|
rate limiting. A missing or wrong token is answered `401`, in `observe` mode
|
|
too. Such a refusal counts toward the error burst, so a client guessing tokens
|
|
is soon banned. While a token is unset, its endpoints answer `404`.
|
|
|
|
- `GET /_smallwebwaf/healthz`: `200` and `ok`, to anyone, before any check and
|
|
without asking the app.
|
|
- `GET /_smallwebwaf/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see
|
|
"Metrics" below).
|
|
- `GET /_smallwebwaf/bans`: every ban held, under `bans`, each as `bans.json`
|
|
lists it. Needs `SWWAF_ADMIN_TOKEN`, as do the three below.
|
|
- `POST /_smallwebwaf/bans`: bans a netblock. The body is a JSON object of
|
|
`netblock`, `duration` and, if you like, `reason`. `netblock` is a netblock
|
|
such as `203.0.113.0/24`, or a client's address, which bans the netblock a ban
|
|
on that client covers. `duration` is a duration such as `1h` or `7d`, or
|
|
`permanent`. The ban starts at once, with the cause `admin`. A bad body is
|
|
answered `400`, saying what is wrong: one that is not such an object, has
|
|
another field, is longer than 4 KiB, or names an IPv4-mapped netblock or an
|
|
address with a zone.
|
|
- `DELETE /_smallwebwaf/bans/<client>`: lifts every active ban on a netblock the
|
|
address `<client>` is in. It answers `404` when none is active.
|
|
- `GET /_smallwebwaf/clients/<ip>`: what `smallwebwaf` knows of the client.
|
|
Under `client`, its counters and history as `clients.json` holds them, or
|
|
`null` when it has never been seen. Under `bans`, every ban on a netblock the
|
|
address is in, with its notes.
|
|
|
|
An admin whose own address is banned lifts that ban by editing `bans.json`.
|
|
|
|
## Request log
|
|
|
|
`smallwebwaf` writes one JSON object per line on stdout for every request,
|
|
refused ones included:
|
|
|
|
```
|
|
{"type":"request","time":"2026-10-03T12:00:00.123Z","instance":"fsn1app1/gitea","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"response_content_type":"text/html; charset=utf-8","upstream_status":200,"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"duration_total":3.217,"duration_checks":0.041,"duration_upstream_connect":0.052,"duration_upstream_first_byte":2.874,"duration_upstream_total":3.104}
|
|
```
|
|
|
|
A field that does not apply to a request is left out. Every line has `type`, the
|
|
fields from `time` to `user_agent`, `request_id`, `peer_ip`, `client_group`,
|
|
`asn`, `as_name`, `country`, `action` and `duration_total`.
|
|
|
|
- `time` is when the request arrived, in UTC. `instance` is
|
|
`SWWAF_INSTANCE_NAME`. `scheme` is the `X-Forwarded-Proto` a trusted proxy
|
|
sent, otherwise `http`. `path` and `query` are as the client sent them.
|
|
- `request_id` is the `X-Request-ID` a trusted proxy sent, or a new random one
|
|
of 26 letters and digits. It goes to the app in `X-Request-ID`.
|
|
- `peer_ip` is the TCP peer, normally traefik. `forwarded_for` is the
|
|
`X-Forwarded-For` header as received. `client_group` is the client as rate
|
|
limiting counts it: an IPv4 address as a /32, or an IPv6 group.
|
|
- `asn`, `as_name` and `country` are the client's, as looked up. Each is empty
|
|
when:
|
|
- lookups are off;
|
|
- the client is not looked up;
|
|
- the source cannot place the client;
|
|
- GeoJS had not answered by the time the request went on.
|
|
- `content_type` and `content_length` are the request's own. `request_headers`
|
|
holds the headers `SWWAF_LOG_REQUEST_HEADERS` names, by name in lower case.
|
|
`has_authorization` and `has_cookie` are true when the request had those
|
|
headers; their values are never logged. `websocket` is true when the app
|
|
switched the connection to another protocol.
|
|
- `status` is what the client was sent, `0` if nothing. `upstream_status` is
|
|
what the app answered. `response_content_type`, `cache_control` and `location`
|
|
are the answer's headers. `request_bytes` and `response_bytes` count body
|
|
bytes. `aborted` is true when the client went away early.
|
|
- `action` is what was done:
|
|
- `forward`: passed to the app;
|
|
- `denied`: refused for `SWWAF_DENY_NETS`, or for a reputation source with
|
|
the action `deny`;
|
|
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
|
|
CrowdSec decision, each of which bans the client;
|
|
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
|
refused by that check;
|
|
- `too_large` and `timed_out`: a size or time limit was passed;
|
|
- `upstream_error`: the app could not be reached, or its answer broke off;
|
|
- `admin`: answered at one of `smallwebwaf`'s own endpoints.
|
|
- `would_action` is there in `observe` mode. It names the action `enforce` mode
|
|
would have taken. `action` then names what was done, normally `forward`.
|
|
- `limit_percent` and `limit_percent_setting` give the client's percentage of
|
|
the rate limits and the setting that lowered it, when one did. `bytes_percent`
|
|
and `bytes_percent_setting` do the same for the byte limits.
|
|
- `counts` gives the client's requests in the minute, hour and day as rate
|
|
limiting counts them, this request included. `minute_bytes`, `hour_bytes` and
|
|
`day_bytes` give its bytes. Each window is counted in two fixed buckets, the
|
|
earlier one weighted by how much of it the window still covers, so a count can
|
|
have a fraction. `counts` is left out for a request rate limiting does not
|
|
count.
|
|
- `rule_ids` lists the rule file rules that matched, in order, up to the one
|
|
that refused. `waf_score` and `waf_rule_ids` give the Core Rule Set's score
|
|
and the rules that matched, for a request it inspected.
|
|
- `limit_hit` names the limit broken: `minute`, `hour` or `day` for a rate
|
|
limit; `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit; or
|
|
`error_burst`. `offence` is then `limit`. A request whose bytes broke a limit
|
|
was passed on whole, so its `action` stays `forward`.
|
|
- `reputation` lists the sources that list the client: blocklist URLs, the
|
|
CrowdSec decision list's URL, DNS blocklist zones, then `abuseipdb`.
|
|
- `ban_expires` is when the client's ban ends, for a request that made one or
|
|
was refused under one: a time, or `permanent`.
|
|
- The timings are in milliseconds. `duration_total` runs from the request's
|
|
headers being read to its log line. `duration_checks` runs to the end of the
|
|
checks, and `duration_waf` is the part of that the Core Rule Set took. The
|
|
three `duration_upstream_` timings run from the hand-over to the app:
|
|
`connect` until there was a connection, `first_byte` until the first byte of
|
|
the answer, and `total` until the end.
|
|
|
|
No body is logged, and no header but those above. `smallwebwaf`'s own messages
|
|
(start, the settings, stop, errors) share the stream as JSON lines marked
|
|
`"type":"process"`.
|
|
|
|
### Sending the log to a syslog server
|
|
|
|
While `SWWAF_LOG_REMOTE_URL` is set, every line on stdout is also sent to that
|
|
server, as the message of an RFC 5424 syslog record. Over UDP each record is one
|
|
datagram. Over TCP and TLS each record follows its length in bytes and a space.
|
|
The record carries the facility `SWWAF_LOG_REMOTE_FACILITY` names, the severity
|
|
informational, the time, the host's name and the app name
|
|
`SWWAF_LOG_REMOTE_APP_NAME` gives. stdout is unchanged.
|
|
|
|
Lines wait in a buffer of `SWWAF_LOG_REMOTE_BUFFER` lines, so a server that is
|
|
slow or down never holds up a request. When the buffer is full, the oldest line
|
|
is dropped. A failed connection is tried again a second later, and twice as long
|
|
after each further failure, up to a minute. A line too long for one UDP datagram
|
|
is dropped alone. As `smallwebwaf` stops, it sends the lines still waiting for
|
|
at most two seconds and gives up the rest. stdout has carried them.
|
|
|
|
## Alerts
|
|
|
|
Each alert goes to each destination you name. `SWWAF_ALERT_WEBHOOK_URL` gets it
|
|
as one JSON object. `SWWAF_ALERT_SLACK_WEBHOOK_URL` and `SWWAF_ALERT_NTFY_URL`
|
|
get a message made from it. An alert is one of these events, sent when
|
|
`SWWAF_ALERT_EVENTS` names it:
|
|
|
|
- `ban`: a ban `smallwebwaf` made.
|
|
- `permanent_ban`: a permanent ban it made, or a ban a request made permanent.
|
|
- `waf_block`: a request the Core Rule Set scored at or over the threshold. In
|
|
`block` mode the request was refused. In `detect` mode it was not, and the
|
|
alert's `detail` gives `mode`.
|
|
- `anomaly`: a count over an anomaly threshold.
|
|
- `reputation_hit`: a request whose client a blocklist, the CrowdSec decision
|
|
list or a DNS blocklist lists, or whose AbuseIPDB score is a hit. One alert
|
|
per source.
|
|
- `source_failure`: GeoJS failing, a list that cannot be fetched, a DNS
|
|
blocklist or AbuseIPDB failing or refusing a query, or the day's AbuseIPDB
|
|
checks used up.
|
|
- `file_error`: a rule file or state file edit with an error, a state file that
|
|
cannot be written, or a replacement lookup database that cannot be read.
|
|
|
|
The bans you make raise no alert. In `observe` mode, a request that would have
|
|
made a ban raises the alert `enforce` mode would have, with `mode` set to
|
|
`observe` in its `detail`. This is the alert for a ban for a broken rate limit,
|
|
shown indented; it is sent on one line:
|
|
|
|
```json
|
|
{
|
|
"instance": "fsn1app1/gitea",
|
|
"time": "2026-10-06T12:00:00.123461Z",
|
|
"event": "ban",
|
|
"client": "203.0.113.9",
|
|
"netblock": "203.0.113.9/32",
|
|
"asn": "",
|
|
"as_name": "",
|
|
"country": "",
|
|
"reason": "requests per minute over the limit of 1000",
|
|
"detail": {
|
|
"ban_expires": "2026-10-06T13:00:00.123Z",
|
|
"cause": "limit",
|
|
"notes": {
|
|
"asn": "",
|
|
"as_name": "",
|
|
"country": "",
|
|
"kind": "requests",
|
|
"limit": 1000,
|
|
"window": "minute",
|
|
"count": 1001,
|
|
"request": {
|
|
"time": "2026-10-06T12:00:00.123456789Z",
|
|
"method": "GET",
|
|
"host": "app.example",
|
|
"path": "/owner/repo/commits/branch/main?page=812",
|
|
"status": 403,
|
|
"user_agent": "scraper/1.0"
|
|
},
|
|
"requests": 5210,
|
|
"refused": 0,
|
|
"earlier_bans": {
|
|
"limit": 0,
|
|
"attack": 0,
|
|
"admin": 0,
|
|
"crowdsec": 0
|
|
}
|
|
}
|
|
},
|
|
"suppressed_repeats": 0
|
|
}
|
|
```
|
|
|
|
- `client` is the address whose request raised the alert. `netblock` is the
|
|
ban's netblock, the netblock an anomaly counted, or the client's group. Both
|
|
are empty for `source_failure` and `file_error`. `asn`, `as_name` and
|
|
`country` are the client's as known when the alert was raised. They are empty
|
|
when GeoJS had not answered yet.
|
|
- `reason` is a short sentence: a ban's reason, what was counted over which
|
|
threshold, `listed by a blocklist`, and so on.
|
|
- `detail` is what is particular to the event:
|
|
- for a ban, its `cause`, `ban_expires` and `notes`;
|
|
- for an anomaly, the `scope`, `window`, `kind`, `count` and `threshold`;
|
|
- for a reputation hit, the `source` and, for AbuseIPDB, the `score`;
|
|
- for a Core Rule Set match, the `rule_ids`, `score`, `method` and `path`;
|
|
- for a source failure, the `source` and `error`;
|
|
- for a file error, the `file` and `error`.
|
|
- `suppressed_repeats` is how many repeats the cooldown held back before this
|
|
alert.
|
|
|
|
Slack and ntfy get a message with a title and a text. The title is the instance
|
|
and the event, such as `fsn1app1/gitea: ban`. The text is the `reason`, then a
|
|
line for each of the client, netblock, country, file, source, error and mode the
|
|
alert has, and the suppressed repeats if any. Slack is posted the title in bold
|
|
and the text below it, with `&`, `<` and `>` escaped. ntfy is posted the text,
|
|
with the title in `Title`, the token if set, and a priority and a tag by event:
|
|
|
|
- `ban`: priority `default`, tag `no_entry`.
|
|
- `permanent_ban`: priority `high`, tag `no_entry`.
|
|
- `waf_block`: priority `default`, tag `shield`.
|
|
- `anomaly`: priority `high`, tag `chart_with_upwards_trend`.
|
|
- `reputation_hit`: priority `low`, tag `label`.
|
|
- `source_failure` and `file_error`: priority `high`, tag `warning`.
|
|
- `summary`: priority `default`, tag `bar_chart`.
|
|
|
|
A repeat is an alert for the same event on the same netblock, source, file or
|
|
anomaly scope, less than `SWWAF_ALERT_COOLDOWN` after the last. It is held back
|
|
and counted, and the next alert sent gives the count. Past
|
|
`SWWAF_ALERT_MAX_PER_HOUR` alerts in an hour, the rest are held back and counted
|
|
by event. Once the hour ends, one alert with the event `summary` sums them up,
|
|
with the repeats of the cooldowns that ran out.
|
|
|
|
Each destination has a queue of at most 1000 alerts, sent one at a time, so a
|
|
slow destination holds up nothing. A `2xx` answer takes an alert. A `4xx` other
|
|
than `408` and `429` refuses it, and it is dropped. Anything else is a failure:
|
|
the alert is sent again a second later, and twice as long after each further
|
|
failure, up to a minute. `alerts.json` keeps the queues and cooldowns across a
|
|
restart.
|
|
|
|
## Metrics
|
|
|
|
`GET /_smallwebwaf/metrics` answers in the Prometheus text format, for a request
|
|
that sends `SWWAF_METRICS_TOKEN`. No metric carries a client's address. Every
|
|
metric carries the label `instance`, set to `SWWAF_INSTANCE_NAME`. Prometheus
|
|
keeps it as `exported_instance` unless the scrape sets `honor_labels: true`.
|
|
|
|
- Traffic:
|
|
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
|
|
`smallwebwaf_response_bytes_total`, by `status_class` such as `2xx` and by
|
|
`action`;
|
|
- `smallwebwaf_request_duration_seconds` and
|
|
`smallwebwaf_upstream_duration_seconds`, as histograms;
|
|
- `smallwebwaf_requests_in_flight`.
|
|
- Limits and bans:
|
|
- `smallwebwaf_rate_limit_hits_total`, by `window` and by `kind`
|
|
(`requests`, `bytes` or `refusals`);
|
|
- `smallwebwaf_size_and_time_limit_hits_total`, by `limit`, the setting
|
|
passed;
|
|
- `smallwebwaf_offences_total`, by `kind`;
|
|
- `smallwebwaf_bans_made_total`, by `cause`;
|
|
- `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`.
|
|
- Attack detection:
|
|
- `smallwebwaf_rule_matches_total`, by `rule_id` and `action`, and
|
|
`smallwebwaf_rules_loaded`;
|
|
- `smallwebwaf_waf_matches_total`, by `mode` and `rule_id`.
|
|
- Countries and AS numbers, for the `SWWAF_METRICS_TOP_N` busiest of each, the
|
|
rest as `other`:
|
|
- `smallwebwaf_country_requests_total`,
|
|
`smallwebwaf_country_request_bytes_total`,
|
|
`smallwebwaf_country_response_bytes_total` and
|
|
`smallwebwaf_country_list_refusals_total`, by `country`;
|
|
- `smallwebwaf_asn_requests_total`, `smallwebwaf_asn_request_bytes_total`
|
|
and `smallwebwaf_asn_response_bytes_total`, by `asn`.
|
|
- Lookups:
|
|
- `smallwebwaf_geojs_requests_total` and `smallwebwaf_geojs_failures_total`;
|
|
- `smallwebwaf_geojs_unanswered_total`, the requests that needed an answer
|
|
and went on without it;
|
|
- with the file source,
|
|
`smallwebwaf_lookup_database_last_read_timestamp_seconds` and
|
|
`smallwebwaf_lookup_database_read_failures_total`.
|
|
- Reputation sources, by `source`: each list's URL, each zone, and `abuseipdb`:
|
|
- `smallwebwaf_reputation_hits_total`,
|
|
`smallwebwaf_reputation_queries_total` and
|
|
`smallwebwaf_reputation_failures_total`;
|
|
- `smallwebwaf_reputation_last_fetch_timestamp_seconds`, for a list;
|
|
- `smallwebwaf_reputation_daily_budget_remaining`, for AbuseIPDB.
|
|
- State files, by `file`:
|
|
- `smallwebwaf_state_file_writes_total`,
|
|
`smallwebwaf_state_file_write_failures_total`,
|
|
`smallwebwaf_state_file_last_write_timestamp_seconds` and
|
|
`smallwebwaf_state_file_size_bytes`;
|
|
- `smallwebwaf_state_file_edits_taken_in_total` and
|
|
`smallwebwaf_state_file_edits_set_aside_total`;
|
|
- `smallwebwaf_tracked_clients`, the clients in the table.
|
|
- The syslog server, while one is set:
|
|
`smallwebwaf_remote_log_lines_sent_total`,
|
|
`smallwebwaf_remote_log_lines_dropped_total` and
|
|
`smallwebwaf_remote_log_buffer_depth`.
|
|
- Alerts, by `destination`: `smallwebwaf_alerts_sent_total`,
|
|
`smallwebwaf_alerts_failed_total`, `smallwebwaf_alerts_suppressed_total` and
|
|
`smallwebwaf_alerts_dropped_total`.
|
|
- Go's own `go_` metrics and the process's `process_` metrics.
|
|
|
|
## State files
|
|
|
|
`smallwebwaf` keeps its state in memory, and a copy of it in five JSON files in
|
|
`SWWAF_STATE_DIR`. Each has a top-level `version` of 1.
|
|
|
|
- `bans.json`: every ban with its notes, indented to be read (see "Bans" above).
|
|
- `clients.json`: each client's counters of requests, bytes and refusals, and
|
|
its history. One client per line, so `grep` shows everything about one. The
|
|
history holds:
|
|
- when the client was first and last seen;
|
|
- its AS number, AS name and country;
|
|
- its requests, and how many were forwarded and refused;
|
|
- its bytes each way;
|
|
- its answers by status class;
|
|
- its offences by kind: `limit`, `attack`, `rule_blocked`, `waf_blocked` and
|
|
`token_refused`.
|
|
- `lookups.json`: GeoJS's answers, one per line.
|
|
- `reputation.json`: the last good copy of each list fetched from a URL, with
|
|
when it was last tried; each DNS blocklist verdict still in use; and
|
|
AbuseIPDB's scores and the checks spent today.
|
|
- `alerts.json`: the alert cooldowns, the hour under way, the alerts waiting for
|
|
each destination, and the anomaly counters.
|
|
|
|
`bans.json` is written `SWWAF_STATE_WRITE_DELAY` after a ban is made, lifted or
|
|
made permanent. Every file is written every `SWWAF_STATE_COUNTER_INTERVAL`, and
|
|
when `smallwebwaf` stops. Each write goes to a temporary file that then replaces
|
|
the file, so a crash leaves the old file or the new one, whole. A write that
|
|
fails is logged, raised as a `file_error` alert, and tried again at the next
|
|
write. A hard kill loses what changed since the last write.
|
|
|
|
At start the files are read back. Each client keeps its counts, so a restart
|
|
gives no one a fresh allowance. Each ban keeps refusing until it ends. A missing
|
|
file is empty state. A file that does not parse, or has another `version`, stops
|
|
the start with a message naming the file and the place. So does an entry without
|
|
a field it needs, a ban whose `cause` is not `limit`, `attack`, `admin` or
|
|
`crowdsec`, and a ban whose `expires` is not after its `start`.
|
|
|
|
While it runs, `smallwebwaf` watches the directory and takes in your edit of a
|
|
file as soon as you save it. What the file then holds replaces what it held. It
|
|
tells its own writes from yours by comparing the file with what it last read or
|
|
wrote. It takes in your edit before it writes, so your edit is not overwritten.
|
|
A change it made after you opened the file, such as a new ban, is lost when you
|
|
save over it. An edit with an error that would stop the start does not stop the
|
|
running `smallwebwaf`. It keeps what it holds. At the file's next write it
|
|
renames your file to `<name>.bad`, writes the file again from memory, and logs
|
|
and alerts the file and the place of the error. Mend the `.bad` file and move it
|
|
back. A file you remove is written again at its next write.
|
|
|
|
## GeoJS and the lookup database
|
|
|
|
By default `smallwebwaf` asks GeoJS, a free web service that needs no account,
|
|
about every new visitor's address. It asks whether or not a setting uses the
|
|
answer, since the log line, the history, the ban notes and the metrics carry the
|
|
AS number and country. Four kinds of visitor are not sent: those in
|
|
`SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS`, those a ban covers, and those on a
|
|
private, loopback or link-local address, which no source can place. An IPv6
|
|
visitor is asked about by the first address of its group. Each answer is kept
|
|
for seven days, in memory and in `lookups.json`.
|
|
|
|
A request waits for its client's first answer, up to `SWWAF_LOOKUP_TIMEOUT`,
|
|
only while a setting acts on the answer before the request goes on: a country
|
|
list, `SWWAF_ADD_LOOKUP_HEADERS`, or any setting under "Lower limits by AS
|
|
number and country" above. Otherwise it goes on at once, logged without the
|
|
answer. The answer reaches the client's history and ban notes when it comes.
|
|
Addresses are asked about together, up to 200 in one request, one request at a
|
|
time. While GeoJS fails, new visitors count as from an unknown country, which
|
|
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses. GeoJS is then left alone for a
|
|
second, and twice as long after each further failure, up to five minutes.
|
|
|
|
To keep your visitors' addresses on your own host, set
|
|
`SWWAF_LOOKUP_SOURCE=off`, or set it to `file`. `file` looks every client up in
|
|
the free IPinfo Lite database (`ipinfo_lite.mmdb`), which `SWWAF_LOOKUP_DB_PATH`
|
|
names. You download the database with an IPinfo account and mount it into the
|
|
container. It answers at once, with nothing sent off the host. A client whose
|
|
address it does not hold counts as from an unknown country. `smallwebwaf` reads
|
|
the whole file at start. It reads the file again once it has gone 2 seconds
|
|
without a change after you replace it. A replacement it cannot read is logged
|
|
and alerted, and the file read before stays in use. Mount the directory that
|
|
holds the file rather than the file itself: docker does not show a single
|
|
mounted file being replaced. IPinfo releases the database under the Creative
|
|
Commons Attribution-ShareAlike 4.0 licence. It asks for a link to IPinfo on the
|
|
site that uses it, such as "IP address data is powered by IPinfo".
|
|
|
|
## Blocklists
|
|
|
|
`SWWAF_BLOCKLIST_URLS` names text files of addresses and netblocks, one to a
|
|
line, as the Spamhaus DROP list is written. Anything after a `;` or `#` is left
|
|
out. An IPv4-mapped address such as `::ffff:192.0.2.0/120` is read as the IPv4
|
|
netblock it stands for.
|
|
|
|
Each list, and the file `SWWAF_ASN_LIMIT_PERCENT_URL` names, is fetched
|
|
`SWWAF_BLOCKLIST_REFRESH` after it was last fetched or tried, one after another.
|
|
`reputation.json` keeps when each was last tried, so a restart does not fetch a
|
|
list again before its time. A fetch fails when the server answers other than
|
|
`200`, does not finish within a minute, or sends more than 16 MiB. It also fails
|
|
on a line that is not an address or netblock, or, for the AS number file, not
|
|
`AS<number>:<percent>`. The copy fetched before stays in use. The failure is
|
|
logged and raised as a `source_failure` alert. Each list is named by its URL in
|
|
the log, the alerts, the ban notes and the metrics, so keep a secret out of it.
|
|
|
|
`deny` suits lists of networks that send nothing legitimate, such as DROP.
|
|
`limit:<percent>` suits lists of addresses shared with ordinary visitors, such
|
|
as Tor exits. The Spamhaus DROP list is The Spamhaus Project's. Its terms ask
|
|
that a product using it credit The Spamhaus Project and keep the list's date and
|
|
copyright lines with the data, which the copy in `reputation.json` does. They
|
|
also ask that it be fetched no more than once an hour. Each `smallwebwaf`
|
|
fetches its own copy, so several on one host, sharing one address, can fetch
|
|
within the same hour.
|
|
|
|
## DNS blocklists
|
|
|
|
Each zone in `SWWAF_DNSBL_ZONES` is asked about a client's own address in the
|
|
background, the first time the client is seen. The request goes on at once, and
|
|
so does every request from the client until the zone has answered. The name
|
|
asked about is the one RFC 5782 gives: the numbers of an IPv4 address reversed,
|
|
or the 32 hex digits of an IPv6 address reversed. `192.0.2.99` is asked about as
|
|
`99.2.0.192.dnsbl.dronebl.org`. An answer in `127.0.0.0/8` lists the client. A
|
|
name that does not exist does not. Any other answer is a failure: an address in
|
|
`127.255.255.0/24`, with which Spamhaus refuses a query, an address outside
|
|
`127.0.0.0/8`, a resolver error, or no answer within `SWWAF_REPUTATION_TIMEOUT`.
|
|
A failure is logged and alerted, and the zone is not asked again for a minute.
|
|
Each verdict is kept for `SWWAF_REPUTATION_CACHE_TTL`, in memory and in
|
|
`reputation.json`.
|
|
|
|
The key of a Spamhaus zone under `dq.spamhaus.net` is its first label.
|
|
`smallwebwaf` shows `********` in its place wherever it names the zone. Only
|
|
`reputation.json` keeps the zone with its key.
|
|
|
|
## AbuseIPDB
|
|
|
|
While `SWWAF_ABUSEIPDB_KEY` is set, `smallwebwaf` asks AbuseIPDB's check
|
|
endpoint for the abuse confidence score of a client, from 0 to 100. Only a
|
|
client that has committed an offence is checked: one that broke a limit, matched
|
|
a `ban` rule, asked for a trap path, or was refused by a `block` rule, the Core
|
|
Rule Set or a token check. The check runs in the background, at the client's
|
|
first request after the offence, so no request waits. The score applies to the
|
|
client's later requests. An IPv6 group is checked by the address of the request
|
|
that has it checked, and the score is used for the whole group. A score at or
|
|
over `SWWAF_ABUSEIPDB_MIN_SCORE` is a hit, and `SWWAF_REPUTATION_ACTION` says
|
|
what is done. Each score is kept for `SWWAF_REPUTATION_CACHE_TTL`.
|
|
|
|
At most `SWWAF_ABUSEIPDB_DAILY_BUDGET` checks are made in a day, counted in UTC
|
|
and kept in `reputation.json`. A restart does not make the budget whole again.
|
|
The check that spends the last of the budget is logged and alerted. No client is
|
|
checked until the day ends. A check fails when AbuseIPDB answers other than
|
|
`200`, gives no score, or does not answer within `SWWAF_REPUTATION_TIMEOUT`. A
|
|
failure is logged and alerted, and no client is checked for a minute. The key is
|
|
sent in the `Key` header and nowhere else, and is never logged.
|
|
|
|
## CrowdSec
|
|
|
|
While `SWWAF_CROWDSEC_LAPI_URL` names the local API of a CrowdSec engine you
|
|
run, `smallwebwaf` fetches its decision list every minute. It sends
|
|
`SWWAF_CROWDSEC_LAPI_KEY` in the `X-Api-Key` header. The list is kept as a
|
|
blocklist is kept. The decisions of the type `ban` on an address or a netblock
|
|
are used. Any other, such as a captcha or a country, is left out. A decision
|
|
lasts for the `duration` it had left when the engine answered.
|
|
|
|
A request from a client a decision in force lists is refused. It bans the
|
|
client's netblock until the decision ends, with the cause `crowdsec`. Only a
|
|
client that sends a request is banned, so a long list does not fill `bans.json`.
|
|
Such a ban is never made permanent, and does not lengthen the netblock's next
|
|
ban for a broken limit. A ban you lift while the decision is in force is
|
|
followed by another at the client's next request. Delete the decision in
|
|
CrowdSec first, such as with `cscli decisions delete --ip 203.0.113.9`, and lift
|
|
the ban once the list has been fetched again. A fetch fails when the engine
|
|
answers other than `200`, a redirect included. It also fails when the fetch does
|
|
not finish within a minute, the answer is longer than 16 MiB, or the answer is
|
|
not a list of decisions.
|
|
|
|
## Rationale
|
|
|
|
Small self-hosted sites now receive a great deal of traffic nobody asked for:
|
|
|
|
- scrapers that ignore `robots.txt` and crawl every commit of every repository
|
|
on a public git server;
|
|
- scanners walking through lists of WordPress and `.env` paths;
|
|
- credential-guessing bots.
|
|
|
|
Most of it comes from a small number of hosting networks and countries. A
|
|
single-person operation has no abuse desk and no CDN contract. It needs
|
|
something small that can be put in front of one service and left alone.
|
|
|
|
The existing tools each solve part of this. Rule-based firewalls catch attack
|
|
payloads but do not limit request rates. Rate limiters count requests but cannot
|
|
tell a residential visitor from a rented server farm. The products that do most
|
|
of it want several containers, a database and a web console. None of them can
|
|
say "clients from these networks are banned after half as many requests as
|
|
anyone else". That is the useful thing to say when nearly all abuse comes from a
|
|
known list of AS numbers. [`EVALUATION.md`](EVALUATION.md) goes through the
|
|
candidates one by one.
|
|
|
|
`smallwebwaf` fills that gap with:
|
|
|
|
- per-client limits that ban;
|
|
- lower limits for the networks and countries abuse comes from;
|
|
- bans that grow with repetition;
|
|
- a log that is easy to search and ship;
|
|
- one binary in the app's own container, with no database and no required
|
|
setting.
|
|
|
|
## Design
|
|
|
|
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
|
|
- `internal/smallwebwaf`: the process. It reads the settings, the rule files,
|
|
the lookup database and the state files, listens, and serves until `SIGTERM`
|
|
or `SIGINT`. Then it stops, writing the state files. Run as
|
|
`smallwebwaf healthcheck`, it is the image's health check instead.
|
|
- `internal/config`: reads the settings, the one place they are read.
|
|
- `internal/proxy`: what happens to each request. It works out the client, runs
|
|
the checks in `check`, and passes the request to the app with the standard
|
|
library's `httputil.ReverseProxy`, within the timeouts and size limits. It
|
|
answers its own endpoints in `answerAdmin`. Once the request has ended, it
|
|
counts bytes, refusals and anomalies, and writes the log line.
|
|
- `internal/ratelimit`: the table of clients. It counts requests, bytes and
|
|
refusals, and keeps each client's history.
|
|
- `internal/bans`: the ban ledger. It holds each netblock's bans with their
|
|
notes, decides how long a new ban lasts, and picks the ban to drop past
|
|
`SWWAF_MAX_BANS`.
|
|
- `internal/rules`: reads the rule files, and again as they change, and tells
|
|
which rules a request matches.
|
|
- `internal/waf`: the Core Rule Set with the six changes, as Coraza directives.
|
|
- `internal/lookup`: looks clients up through GeoJS or in the lookup database.
|
|
- `internal/reputation`: fetches the lists, asks the DNS blocklists and
|
|
AbuseIPDB in the background, and keeps their answers.
|
|
- `internal/anomaly`: the anomaly counters and their alerts.
|
|
- `internal/alerts`: holds back repeats and alerts past the hourly limit, and
|
|
sends the rest to the webhook, Slack and ntfy, each from a queue of its own.
|
|
- `internal/state`: reads the state files at start, takes in edits while
|
|
running, and writes the files when due and at the stop.
|
|
- `internal/requestlog`: the lines on stdout.
|
|
- `internal/remotelog`: sends those lines to the syslog server. It is written
|
|
with the standard library alone, since `log/syslog` writes only the older
|
|
syslog format.
|
|
- `internal/metrics`: the metrics, served in the Prometheus text format.
|
|
- `Dockerfile`: the lint and test phases, then the image. Its last stage
|
|
installs Ubuntu's packages, nixpkgs, `runsvinit` and `smallwebwaf`, with
|
|
`share/smallwebwaf.run` as runit's `run` script and
|
|
`share/rules.d/00-default.rules` as the default rule file.
|
|
- `deploy/example-app`: an app built on the image, which `script/example-app`
|
|
checks.
|
|
|
|
Besides the standard library, it uses:
|
|
|
|
- `github.com/hashicorp/golang-lru/v2` for the bounded tables;
|
|
- `github.com/prometheus/client_golang` for the metrics;
|
|
- `github.com/fsnotify/fsnotify` to notice edited files;
|
|
- `github.com/oschwald/maxminddb-golang/v2` to read the lookup database;
|
|
- `github.com/corazawaf/coraza/v3` to run the Core Rule Set, which
|
|
`github.com/corazawaf/coraza-coreruleset/v4` at `v4.25.0` carries;
|
|
- `github.com/maxmind/mmdbwriter`, in the tests, to write lookup databases.
|
|
|
|
## Entrypoints
|
|
|
|
This repository adheres to the
|
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
|
standard: the scripts in `script/` are the entrypoints for working on it, and
|
|
the `Makefile` targets are thin shims that call them. The scripts are POSIX sh.
|
|
The tests and the linter run in Docker, as phases of the `Dockerfile`, and never
|
|
on the host.
|
|
|
|
- `script/bootstrap`: installs what the other scripts need on the host: `make`,
|
|
`git`, `curl`, Go for `gofmt`, node and yarn, and prettier. `make bootstrap`.
|
|
- `script/setup`: readies a fresh clone: `script/bootstrap`, then
|
|
`script/install-precommit`. `make setup`.
|
|
- `script/projectname`: prints `smallwebwaf`, which the other scripts tag their
|
|
images with.
|
|
- `script/test`: checks that `go.mod` and `go.sum` are tidy, then runs the
|
|
tests, as the `test` phase of the `Dockerfile`. `make test`.
|
|
- `script/tidy`: writes `go.mod` and `go.sum` as `go mod tidy` writes them, in
|
|
the `tidy` stage of the `Dockerfile`. `make tidy`.
|
|
- `script/lint`: runs golangci-lint, as the `lint` phase of the `Dockerfile`.
|
|
`make lint`.
|
|
- `script/fmt`: formats the Go code with `gofmt` and the Markdown with prettier.
|
|
`make fmt`.
|
|
- `script/fmt-check`: checks the formatting and changes nothing.
|
|
`make fmt-check`.
|
|
- `script/check`: runs `script/test`, `script/lint` and `script/fmt-check`.
|
|
`make check`.
|
|
- `script/docker`: builds the image, whose build runs the tests and the linter
|
|
first. `make docker`.
|
|
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
|
|
image build.
|
|
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
|
- `script/install-precommit`: installs that hook. `make hooks`.
|
|
- `script/build`: builds `bin/smallwebwaf` on the host, with Go installed.
|
|
`make build`.
|
|
- `script/run`: builds `bin/smallwebwaf` and runs it. Its state files go in
|
|
`bin/state` unless `SWWAF_STATE_DIR` is set. Its rule files are those of
|
|
`share/rules.d` unless `SWWAF_RULES_DIR` is set. `make run`.
|
|
- `script/example-app`: builds the image and the example app on it, runs it with
|
|
a volume, and checks it as "Checking that it works" above describes. Then it
|
|
removes the containers, volumes and images. Needs network access.
|
|
`make example-app`.
|
|
- `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind it at
|
|
the defaults. It sends through it a sample of gitea's ordinary use, and the
|
|
requests the Core Rule Set is known to refuse. It prints what `smallwebwaf`
|
|
did with each and the rule ids that matched, so that those requests can be
|
|
checked again when gitea or the Core Rule Set changes version. Needs network
|
|
access, and a docker daemon that pushes to a registry on `localhost` over
|
|
plain HTTP. `make gitea-requests`.
|
|
|
|
## TODO
|
|
|
|
- Everything `SPEC.md` describes is built.
|
|
- Two points `SPEC.md` defers until there is experience to go on: sharing bans
|
|
between the instances of several apps, and sending the log over RELP.
|
|
|
|
## Documents
|
|
|
|
- [`SPEC.md`](SPEC.md): the design.
|
|
- [`EVALUATION.md`](EVALUATION.md): what already exists, what each tool covers
|
|
and misses, and why none was adopted.
|
|
- [`REPO_POLICIES.md`](REPO_POLICIES.md): the policies this repository follows.
|
|
|
|
## License
|
|
|
|
MIT. See [`LICENSE`](LICENSE).
|
|
|
|
## Author
|
|
|
|
[@sneak](https://sneak.berlin)
|