Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dbe17e594 |
@@ -8,7 +8,8 @@ scanners and other abusers before they reach the app.
|
||||
A web application firewall reads each request on its way to a web app and
|
||||
refuses the ones that look like abuse. `smallwebwaf` is also a reverse proxy: it
|
||||
accepts requests on the app's behalf, passes them to the app, and passes the
|
||||
app's answers back.
|
||||
app's answers back. A request it does not refuse reaches the app as it came,
|
||||
apart from a few headers (see "Design" below).
|
||||
|
||||
It needs no setting. Every setting has a default chosen for a service on the
|
||||
open internet. [`SPEC.md`](SPEC.md) says how `smallwebwaf` behaves and why. The
|
||||
@@ -17,10 +18,10 @@ reference documents under [`docs/`](docs/) are listed under "Documents" below.
|
||||
## What it protects against
|
||||
|
||||
- Scrapers that fetch every page of a site, all day. Rate limiting gives each
|
||||
client 6000 requests a minute, 100,000 an hour and 1,000,000 a day. Byte
|
||||
limits give it 4 GiB a minute, 20 GiB an hour and 200 GiB a day. A client over
|
||||
a limit is refused and banned. The first ban lasts an hour. Each repeat within
|
||||
a day makes the next ban three times as long. A ban that would last more than
|
||||
client 1000 requests a minute, 10,000 an hour and 50,000 a day. Byte limits
|
||||
give it 10 GiB a minute, 20 GiB an hour and 50 GiB a day. A client over a
|
||||
limit is refused and banned. The first ban lasts an hour. Each repeat within a
|
||||
day makes the next ban three times as long. A ban that would last more than
|
||||
seven days is permanent.
|
||||
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
|
||||
default rule file bans such a client for seven days at its first probe. Its
|
||||
@@ -29,8 +30,8 @@ reference documents under [`docs/`](docs/) are listed under "Documents" below.
|
||||
of your own, or an entry in `SWWAF_TRAP_PATHS`, bans those the same way.
|
||||
- Attacks carried in a request's URL or headers, such as SQL injection and path
|
||||
traversal. The OWASP Core Rule Set is a published set of rules that recognise
|
||||
common web attacks. Coraza, a Go engine, runs those rules (see "Attack
|
||||
detection" in [`SPEC.md`](SPEC.md)). A request the rules take for an attack is
|
||||
common web attacks. Coraza, a Go engine, runs those rules (step 11 under "What
|
||||
happens to a request" below). A request the rules take for an attack is
|
||||
refused.
|
||||
- Abuse from networks and countries you do not want. You can:
|
||||
- refuse whole netblocks and countries;
|
||||
@@ -141,32 +142,89 @@ while working on it, see "Entrypoints" below.
|
||||
|
||||
## What happens to a request
|
||||
|
||||
`smallwebwaf` handles each request in fifteen steps, in this order. Once a step
|
||||
refuses a request, the steps after it are skipped, apart from step 15, and
|
||||
nothing of the request reaches the app. "What happens to a request" in
|
||||
[`SPEC.md`](SPEC.md) gives each step in full, under the same numbers.
|
||||
Two terms recur below. A clear sign of attack is a request that matches a rule
|
||||
whose action is `ban`, or asks for a trap path (steps 9 and 10). An offence is a
|
||||
request that counts against its client in its history. It is one of:
|
||||
|
||||
1. The health check, `GET /_smallwebwaf/healthz`, is answered at once.
|
||||
2. The client is identified.
|
||||
3. The static lists, `SWWAF_ALLOW_NETS` and `SWWAF_DENY_NETS`.
|
||||
4. The bans.
|
||||
5. The lookup of the client's AS number and country.
|
||||
6. The country lists.
|
||||
7. The reputation sources.
|
||||
8. The client's limit percentage.
|
||||
9. The rate limits.
|
||||
10. The trap paths.
|
||||
11. The rule files.
|
||||
12. The Core Rule Set.
|
||||
13. `smallwebwaf`'s own endpoints, under `/_smallwebwaf/`.
|
||||
14. The request is passed to the app.
|
||||
15. After the answer: the byte limits, the error burst and the client's history.
|
||||
- a clear sign of attack;
|
||||
- a broken limit;
|
||||
- a refusal by a rule, by the Core Rule Set or for a missing or wrong token.
|
||||
|
||||
A refused visitor is answered with `SWWAF_BAN_RESPONSE`, `403` by default (see
|
||||
"Ban lengths, response and scope" in [`SPEC.md`](SPEC.md)). A `block` rule or a
|
||||
Core Rule Set match answers `403`. A missing or wrong token gets `401`. A
|
||||
request over a size or time limit gets the status for that limit, such as `413`
|
||||
for a body too large (see "Size and time limits" in [`SPEC.md`](SPEC.md)).
|
||||
`smallwebwaf` handles each request in this order. A request one step refuses
|
||||
skips the steps after it, apart from step 13. Nothing of a refused request
|
||||
reaches the app. "What happens to a request" in [`SPEC.md`](SPEC.md) gives each
|
||||
step in full.
|
||||
|
||||
1. `GET /_smallwebwaf/healthz` is answered `200` with `ok` at once, before any
|
||||
check and without asking the app.
|
||||
2. The client is worked out. A netblock is a range of addresses, written as an
|
||||
address and a prefix length: `203.0.113.0/24`. `SWWAF_TRUSTED_PROXIES` lists
|
||||
the netblocks traefik comes from, by default the private address ranges. For
|
||||
a TCP peer inside them, `X-Forwarded-For` is read from the right, and the
|
||||
first address outside those netblocks is the client. A peer outside them is
|
||||
the client itself. An IPv6 client is its group: the whole netblock around its
|
||||
address, a /64 by default; `SWWAF_IPV6_GROUP_PREFIX` sets its length.
|
||||
3. A client in `SWWAF_ALLOW_NETS` skips steps 4 to 11, the byte limits and the
|
||||
error burst, but still needs a token at step 12. A client in
|
||||
`SWWAF_DENY_NETS` is refused.
|
||||
4. A client under a ban is refused. If the ban was made for a clear sign of
|
||||
attack, this request makes it permanent.
|
||||
5. The client's AS number and country are looked up, unless
|
||||
`SWWAF_LOOKUP_SOURCE` is `off`. An AS number names the network an address
|
||||
belongs to, such as `AS64496`.
|
||||
6. The country lists are checked. A client from a country in
|
||||
`SWWAF_DENIED_COUNTRIES` is refused. When
|
||||
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set, a client from any other country
|
||||
is refused, and so is a client without a country. Such a refusal is not an
|
||||
offence.
|
||||
7. The reputation sources are checked: the blocklists, the CrowdSec decision
|
||||
list, the DNS blocklists and AbuseIPDB. A client on the CrowdSec decision
|
||||
list is banned until the decision ends. Any other source refuses the client,
|
||||
lowers its limits or only notes it, as `SWWAF_BLOCKLIST_ACTION` and
|
||||
`SWWAF_REPUTATION_ACTION` say. No request waits for a query.
|
||||
8. Rate limiting counts the request, unless the client or the path is exempt. A
|
||||
request that takes the client over a limit is refused and bans the client.
|
||||
9. A request for a path in `SWWAF_TRAP_PATHS` is refused and bans the client, as
|
||||
a clear sign of attack.
|
||||
10. The rule files are checked. A `log` rule notes the match. A `block` rule
|
||||
refuses the request. A `ban` rule refuses it and bans the client, as a clear
|
||||
sign of attack.
|
||||
11. The Core Rule Set inspects the request's method, URL and headers, and, once
|
||||
`SWWAF_WAF_BODY_LIMIT` is set, form, JSON and XML bodies. A request it takes
|
||||
for an attack is refused while `SWWAF_WAF_MODE` is `block`, the default, and
|
||||
let through while it is `detect`. It bans no one by itself.
|
||||
12. A request under `/_smallwebwaf/` is answered here by `smallwebwaf`, after
|
||||
its token is checked. Any other request is passed to the app. The app's
|
||||
answer is streamed back within the size and time limits.
|
||||
13. Once the answer has ended, a request passed to the app has its bytes counted
|
||||
for the byte limits. If a rule, a trap path, the Core Rule Set or a token
|
||||
check refused the request, the refusal is counted for the error burst. The
|
||||
client's history and the anomaly counters are updated. More than
|
||||
`SWWAF_ERROR_BURST_THRESHOLD` refusals of one client in a minute ban it.
|
||||
|
||||
In `observe` mode (`SWWAF_MODE=observe`), `smallwebwaf` makes no ban of its own.
|
||||
It refuses a request only for a size or time limit, or for a missing or wrong
|
||||
token. A request it would otherwise have refused is passed to the app, and its
|
||||
log line says what `enforce` mode would have done. Observe mode is for trying a
|
||||
configuration before enforcing it.
|
||||
|
||||
What a refused visitor sees:
|
||||
|
||||
- `SWWAF_BAN_RESPONSE`, by default `403` with the plain text `Forbidden`. It can
|
||||
be `429` instead, or `close`, which closes the connection, so that traefik
|
||||
answers `502`. It answers:
|
||||
- a ban or a broken rate limit;
|
||||
- a `ban` rule or a trap path;
|
||||
- `SWWAF_DENY_NETS` and the country lists;
|
||||
- a reputation source whose action is `deny`.
|
||||
- `403` for a `block` rule and the Core Rule Set.
|
||||
- For a size or time limit:
|
||||
- `431` for headers over their limit;
|
||||
- `413` for a body over its limit;
|
||||
- `408` for a client too slow to send its body;
|
||||
- `502` or `504` when the app passes a limit or cannot be reached.
|
||||
- `401` from an endpoint of `smallwebwaf`'s own, for a request without its
|
||||
token.
|
||||
|
||||
## Settings you are likely to touch
|
||||
|
||||
@@ -175,11 +233,8 @@ Each setting is an environment variable of the app's container, such as
|
||||
[`docs/settings.md`](docs/settings.md) lists every setting, its default and the
|
||||
values it takes. The ones most deployments touch:
|
||||
|
||||
- `SWWAF_MODE=observe`: `smallwebwaf` makes no ban of its own, and refuses a
|
||||
request only for a size or time limit or a missing or wrong token. A request
|
||||
it would have refused is passed to the app, and its log line says what
|
||||
`enforce` mode would have done. For trying a configuration before enforcing
|
||||
it.
|
||||
- `SWWAF_MODE=observe`: log what would have been refused, refuse almost nothing,
|
||||
ban no one. For trying a configuration.
|
||||
- `SWWAF_ALLOW_NETS`: netblocks you trust, such as your monitoring. Their
|
||||
clients are refused only for a size or time limit or a missing or wrong token.
|
||||
- `SWWAF_RATE_LIMIT_EXEMPT_NETS`: netblocks exempt from rate limiting and the
|
||||
@@ -321,32 +376,50 @@ nixpkgs fixed at one release. An app's `Dockerfile` installs a package with
|
||||
[`docs/image.md`](docs/image.md) lists what the image holds and how each part is
|
||||
pinned.
|
||||
|
||||
- runit, a service supervisor, runs `smallwebwaf` and the app side by side, each
|
||||
as its own user, and starts either again when it exits. The app cannot touch
|
||||
the state files or the process of `smallwebwaf`.
|
||||
- `smallwebwaf` listens on port 8080 and passes requests to the app on
|
||||
`127.0.0.1:8081`. The app listens on `127.0.0.1` only, so nothing reaches it
|
||||
except through `smallwebwaf`. `smallwebwaf`'s own endpoints are under
|
||||
`/_smallwebwaf/`, on the same port.
|
||||
- The app sees the headers traefik sent, with traefik's address added to
|
||||
`X-Forwarded-For`, from a TCP peer on `127.0.0.1`. So the app must trust
|
||||
`127.0.0.1` and `::1` for forwarded headers, besides the private address
|
||||
ranges.
|
||||
- Both processes start with the container's environment variables, so every
|
||||
setting of `smallwebwaf` starts with `SWWAF_`, and the app can read a token
|
||||
given as one. A token given as a file that only the `smallwebwaf` user can
|
||||
read is out of its reach (see "Settings given as files" in
|
||||
- The container's first process is `runsvinit`, which starts runit, a service
|
||||
supervisor. runit runs the `run` script of each directory under
|
||||
`/etc/service`, and runs it again a second after its program exits.
|
||||
`smallwebwaf` runs as the `smallwebwaf` user (uid 65532) and the app as a user
|
||||
of its own. So the app cannot touch the state files or the process of
|
||||
`smallwebwaf`.
|
||||
- `smallwebwaf` listens on port 8080, on every address. traefik sends the app's
|
||||
requests there. `smallwebwaf` passes them to the app on `127.0.0.1:8081`. The
|
||||
app listens on `127.0.0.1` only, so nothing reaches it except through
|
||||
`smallwebwaf`. The health check, the metrics and the ban management are on the
|
||||
same port, under `/_smallwebwaf/`. A request for that prefix never reaches the
|
||||
app.
|
||||
- The app sees what it would see from traefik directly: the same `Host`, the
|
||||
same `X-Forwarded-Proto`, and `X-Forwarded-For` with traefik's address added
|
||||
at the end. The app's TCP peer is `smallwebwaf` on `127.0.0.1`. So the app
|
||||
must trust `127.0.0.1` and `::1` for forwarded headers, besides the private
|
||||
address ranges. An app that keeps its usual trusted proxies sees every visitor
|
||||
as `127.0.0.1`. Each request also carries its id in `X-Request-ID`. Any
|
||||
`X-Client-ASN` or `X-Client-Country` header a client sent is removed.
|
||||
- Both processes start with the container's environment variables. That is why
|
||||
every setting of `smallwebwaf` starts with `SWWAF_`. A token given as an
|
||||
environment variable is readable by the app. A token given as a file that only
|
||||
the `smallwebwaf` user can read is not (see "Settings given as files" in
|
||||
[`docs/settings.md`](docs/settings.md)).
|
||||
- The state files live in `/var/lib/smallwebwaf`. A volume mounted there keeps
|
||||
them across a deploy.
|
||||
- `docker stop` stops both processes at once. `smallwebwaf` gives the requests
|
||||
in progress five seconds to finish, so the app must keep answering for five
|
||||
- The state files live in `/var/lib/smallwebwaf`. Without a volume there, they
|
||||
survive a restart of the container and are lost when a deploy replaces it. The
|
||||
`run` script gives that directory and every file in it to the `smallwebwaf`
|
||||
user at each start, so a host directory mounted there needs no change of
|
||||
owner.
|
||||
- The image's health check passes while `smallwebwaf` answers
|
||||
`GET /_smallwebwaf/healthz` and the app accepts connections. traefik sends a
|
||||
container no requests until it is healthy.
|
||||
- When `smallwebwaf` or the app exits, runit starts it again a second later. The
|
||||
other process keeps running. While either is down, the client gets `502`. A
|
||||
`smallwebwaf` that stops at start, as on an invalid setting, is started again
|
||||
every second until the cause is fixed, and logs its message each time.
|
||||
- `docker stop` has runit send `SIGTERM` to both processes at once.
|
||||
`smallwebwaf` gives the requests in progress five seconds to finish and writes
|
||||
its state files. To let them finish, the app must keep answering for five
|
||||
seconds after `SIGTERM`.
|
||||
|
||||
"Deployment" in [`SPEC.md`](SPEC.md) has the rest: the health check, what
|
||||
happens when a process stops, moving `smallwebwaf` to another port, and the
|
||||
forwarded headers. "Architecture" in [`SPEC.md`](SPEC.md) lists the packages and
|
||||
what each does.
|
||||
"Deployment" in [`SPEC.md`](SPEC.md) has the rest: moving `smallwebwaf` to
|
||||
another port, the health check's timing, and the forwarded headers.
|
||||
"Architecture" in [`SPEC.md`](SPEC.md) lists the packages and what each does.
|
||||
|
||||
## TODO
|
||||
|
||||
|
||||
+8
-11
@@ -42,7 +42,7 @@ on one line:
|
||||
"asn": "",
|
||||
"as_name": "",
|
||||
"country": "",
|
||||
"reason": "requests per minute over the limit of 6000",
|
||||
"reason": "requests per minute over the limit of 1000",
|
||||
"detail": {
|
||||
"ban_expires": "2026-10-06T13:00:00.123Z",
|
||||
"cause": "limit",
|
||||
@@ -51,9 +51,9 @@ on one line:
|
||||
"as_name": "",
|
||||
"country": "",
|
||||
"kind": "requests",
|
||||
"limit": 6000,
|
||||
"limit": 1000,
|
||||
"window": "minute",
|
||||
"count": 6001,
|
||||
"count": 1001,
|
||||
"request": {
|
||||
"time": "2026-10-06T12:00:00.123456789Z",
|
||||
"method": "GET",
|
||||
@@ -62,7 +62,7 @@ on one line:
|
||||
"status": 403,
|
||||
"user_agent": "scraper/1.0"
|
||||
},
|
||||
"requests": 8210,
|
||||
"requests": 5210,
|
||||
"refused": 0,
|
||||
"earlier_bans": {
|
||||
"limit": 0,
|
||||
@@ -79,17 +79,16 @@ on one line:
|
||||
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
||||
one of the events above.
|
||||
- `client`: the address whose request raised the alert. Empty for
|
||||
`source_failure`, `file_error` and `summary`.
|
||||
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
|
||||
one of:
|
||||
`source_failure` and `file_error`.
|
||||
- `netblock`: empty for `source_failure` and `file_error`; otherwise one of:
|
||||
- the ban's netblock;
|
||||
- the netblock an anomaly counted, empty in the `asn` and `total` scopes;
|
||||
- the netblock an anomaly counted;
|
||||
- for the other events, the client as rate limiting counts it: an IPv4
|
||||
address as a /32, or an IPv6 group.
|
||||
- `asn`, `as_name` and `country`: the client's, as known when the alert was
|
||||
raised. They are empty when GeoJS had not answered yet.
|
||||
- `reason`: a short sentence, such as
|
||||
`requests per minute over the limit of 6000` or `listed by a blocklist`.
|
||||
`requests per minute over the limit of 1000` or `listed by a blocklist`.
|
||||
- `detail`: what is particular to the event:
|
||||
- for a ban, its `cause`, `ban_expires` and `notes` (the notes are listed
|
||||
under `bans.json` in [`state-files.md`](state-files.md));
|
||||
@@ -98,8 +97,6 @@ on one line:
|
||||
- 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`;
|
||||
- for a summary, the `hour` it covers, the `count` of alerts held back, and
|
||||
that count by event in `events`;
|
||||
- `mode`, when the request was not refused: `detect` or `observe`.
|
||||
- `suppressed_repeats`: how many repeats the cooldown held back before this
|
||||
alert.
|
||||
|
||||
+8
-11
@@ -11,10 +11,8 @@ checked again when gitea or the Core Rule Set changes version.
|
||||
|
||||
- SSH access to gitea does not pass through `smallwebwaf` and is not protected
|
||||
by it.
|
||||
- A clone is a response. It fits the defaults of 30 minutes and 1 GiB unless the
|
||||
repository is large or the link slow. A repository whose clone is larger than
|
||||
1 GiB needs `SWWAF_RESPONSE_MAX_BYTES` raised, and the byte limits with it
|
||||
("Byte limits, per client" in `SPEC.md`).
|
||||
- A clone is a response. It fits the defaults of 30 minutes and 5 GiB for all
|
||||
but the largest repositories and slowest links.
|
||||
- A push is a request, and so is every other upload:
|
||||
- a git push;
|
||||
- an LFS object;
|
||||
@@ -32,13 +30,12 @@ checked again when gitea or the Core Rule Set changes version.
|
||||
request limits do most of the work there.
|
||||
- An Actions runner that reaches gitea through `smallwebwaf` sends requests all
|
||||
day. One older than version 0.4 (April 2026) asks for work every 2 seconds,
|
||||
43,200 requests a day. While it runs a job, it reports the job's log and state
|
||||
every second instead, two requests a second. So one runner busy all day sends
|
||||
172,800 requests, under the request limits. Six such runners on one address,
|
||||
each busy all day, pass the day limit of 1,000,000 within two days; five stay
|
||||
under it. Their address is then banned, their running jobs fail, and each
|
||||
repeat within a day makes the ban longer. Newer runners ask for work less
|
||||
often. Put the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||
43,200 requests a day, and reports a running job's log and state every second.
|
||||
So it passes the day limit of 50,000 after about an hour and a quarter of
|
||||
jobs. It is then banned, the job it is running fails, and each repeat within a
|
||||
day makes the ban longer. Newer runners ask less often, but a busy one, or
|
||||
several on one address, can still pass it. Put the runners' addresses in
|
||||
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||
|
||||
## What the Core Rule Set lets through
|
||||
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
# The metrics
|
||||
|
||||
What `GET /_smallwebwaf/metrics` answers with. "Metrics endpoint" in
|
||||
[`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus keeps the `instance`
|
||||
label as `exported_instance` unless the scrape sets `honor_labels: true`.
|
||||
The metrics `GET /_smallwebwaf/metrics` answers with, as "Metrics endpoint" in
|
||||
[`SPEC.md`](../SPEC.md) describes it. Prometheus keeps the `instance` label as
|
||||
`exported_instance` unless the scrape sets `honor_labels: true`.
|
||||
|
||||
## Traffic
|
||||
|
||||
|
||||
+13
-18
@@ -91,24 +91,24 @@ that take `off`, the `_FILE` form, and what each group does and why.
|
||||
## Static lists
|
||||
|
||||
- `SWWAF_ALLOW_NETS` (default empty): netblocks you trust, such as your
|
||||
monitoring (see step 3 in [`SPEC.md`](../SPEC.md)).
|
||||
monitoring. Their clients are refused only for a size or time limit or a
|
||||
missing or wrong token.
|
||||
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
||||
exempt from rate limiting and the byte limits.
|
||||
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are refused (see
|
||||
step 3 in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are always refused.
|
||||
|
||||
## Request rate limits, per client
|
||||
|
||||
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `6000`), `SWWAF_RATE_LIMIT_PER_HOUR`
|
||||
(default `100000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `1000000`): the
|
||||
most requests a client may make in each window. A count or `off`.
|
||||
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `1000`), `SWWAF_RATE_LIMIT_PER_HOUR`
|
||||
(default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`): the most
|
||||
requests a client may make in each window. A count or `off`.
|
||||
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
|
||||
rate limiting and the byte limits, such as `/assets/`.
|
||||
|
||||
## Byte limits, per client
|
||||
|
||||
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `4G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
|
||||
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `200G`): the most
|
||||
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `10G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
|
||||
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `50G`): the most
|
||||
bytes counted for a client in each window. A size or `off`.
|
||||
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
||||
`response`, `request` or `both`.
|
||||
@@ -131,18 +131,12 @@ that take `off`, the `_FILE` form, and what each group does and why.
|
||||
to send its whole answer. A duration or `off`.
|
||||
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body. A size
|
||||
or `off`.
|
||||
- `SWWAF_RESPONSE_MAX_BYTES` (default `1G`): the largest response body. A size
|
||||
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body. A size
|
||||
or `off`.
|
||||
|
||||
## Lookup of AS number and country
|
||||
|
||||
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
||||
the start while a setting that needs a lookup is set:
|
||||
- the two country lists;
|
||||
- `SWWAF_ADD_LOOKUP_HEADERS`;
|
||||
- the four biased threshold lists and `SWWAF_ASN_LIMIT_PERCENT_URL`;
|
||||
- `SWWAF_UNKNOWN_LIMIT_PERCENT` below `100`;
|
||||
- an anomaly threshold per AS number.
|
||||
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`.
|
||||
- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
||||
`.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`.
|
||||
- `SWWAF_LOOKUP_TIMEOUT` (default `1s`): how long a request waits for its
|
||||
@@ -196,7 +190,8 @@ that take `off`, the `_FILE` form, and what each group does and why.
|
||||
- `SWWAF_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body
|
||||
the Core Rule Set reads, a size of at most `1G`.
|
||||
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client
|
||||
in a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
|
||||
in a minute for a rule match, a trap path, a Core Rule Set match or a failed
|
||||
token check. A count or `off`.
|
||||
|
||||
## Ban lengths, response and scope
|
||||
|
||||
@@ -291,7 +286,7 @@ as `REQUESTS_PER_MINUTE`:
|
||||
client;
|
||||
- `SWWAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
||||
client.
|
||||
- `SWWAF_ANOMALY_ASN_...`: per AS number.
|
||||
- `SWWAF_ANOMALY_ASN_...`: per AS number. Needs a lookup source.
|
||||
- `SWWAF_ANOMALY_TOTAL_...`: the whole service.
|
||||
- `SWWAF_WATCH_...`: each netblock in `SWWAF_WATCH_NETS`, as a whole.
|
||||
`SWWAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Package alerts sends alerts on bans, on traffic over an anomaly
|
||||
// threshold, on a source that fails and on a file with an error to each
|
||||
// destination set: to the webhook
|
||||
// SWWAF_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
||||
// object" in docs/alerts.md describes, to the Slack incoming
|
||||
// SWWAF_ALERT_WEBHOOK_URL names, each as one JSON object, as the "Alert
|
||||
// webhook schema" section of SPEC.md describes, to the Slack incoming
|
||||
// webhook SWWAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
||||
// ntfy topic SWWAF_ALERT_NTFY_URL names. A repeat within
|
||||
// SWWAF_ALERT_COOLDOWN is held back, and so is an alert past
|
||||
@@ -149,10 +149,11 @@ type Params struct {
|
||||
}
|
||||
|
||||
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
||||
// with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and
|
||||
// Country are, for a ban, the client's as the ban's notes give them.
|
||||
// with the fields of the "Alert webhook schema" section of SPEC.md. ASN,
|
||||
// ASName and Country are, for a ban, the client's as the ban's notes give
|
||||
// them.
|
||||
//
|
||||
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
|
||||
//nolint:tagliatelle // SPEC.md's alert webhook schema names its fields in snake_case
|
||||
type Alert struct {
|
||||
Instance string `json:"instance"`
|
||||
Time time.Time `json:"time"`
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Package anomaly counts requests and bytes over a minute and an hour, per
|
||||
// client, per surrounding netblock, per AS number, for the whole service
|
||||
// and per named netblock, and raises an anomaly alert for a count over its
|
||||
// threshold, as "Anomaly thresholds" under "Settings" in SPEC.md
|
||||
// describes. It refuses and bans nothing. At most 20,000 counters
|
||||
// threshold, as "Anomaly thresholds" under "Configuration surface" in
|
||||
// SPEC.md describes. It refuses and bans nothing. At most 20,000 counters
|
||||
// are kept, in memory, and written to alerts.json and read from it by the
|
||||
// state package.
|
||||
package anomaly
|
||||
|
||||
@@ -502,17 +502,17 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
UpstreamRequestTimeout: env.duration("SWWAF_UPSTREAM_REQUEST_TIMEOUT", "60s"),
|
||||
UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"),
|
||||
RequestMaxBytes: env.size("SWWAF_REQUEST_MAX_BYTES", "100M"),
|
||||
ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "1G"),
|
||||
ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "5G"),
|
||||
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
|
||||
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""),
|
||||
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
|
||||
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "6000"),
|
||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "100000"),
|
||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "1000000"),
|
||||
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "1000"),
|
||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"),
|
||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"),
|
||||
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
||||
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "4G"),
|
||||
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "10G"),
|
||||
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
|
||||
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "200G"),
|
||||
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "50G"),
|
||||
BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"),
|
||||
LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"),
|
||||
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""),
|
||||
|
||||
@@ -261,10 +261,10 @@ func TestDefaults(t *testing.T) {
|
||||
UpstreamRequestTimeout: time.Minute,
|
||||
UpstreamResponseTimeout: 30 * time.Minute,
|
||||
RequestMaxBytes: 100 << 20,
|
||||
ResponseMaxBytes: 1 << 30,
|
||||
RateLimitPerMinute: 6000,
|
||||
RateLimitPerHour: 100000,
|
||||
RateLimitPerDay: 1000000,
|
||||
ResponseMaxBytes: 5 << 30,
|
||||
RateLimitPerMinute: 1000,
|
||||
RateLimitPerHour: 10000,
|
||||
RateLimitPerDay: 50000,
|
||||
BanResponse: 403,
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: 24 * time.Hour,
|
||||
@@ -284,8 +284,8 @@ func TestDefaults(t *testing.T) {
|
||||
LookupSource: defaultLookupSource, LookupTimeout: time.Second,
|
||||
})
|
||||
wantByteLimitSettings(t, cfg, config.Config{
|
||||
BytesLimitPerMinute: 4 << 30, BytesLimitPerHour: 20 << 30,
|
||||
BytesLimitPerDay: 200 << 30, BytesCount: "both",
|
||||
BytesLimitPerMinute: 10 << 30, BytesLimitPerHour: 20 << 30,
|
||||
BytesLimitPerDay: 50 << 30, BytesCount: "both",
|
||||
})
|
||||
|
||||
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
|
||||
@@ -2371,17 +2371,17 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
upstreamRequestTimeout: "60s",
|
||||
upstreamResponseTimeout: "30m",
|
||||
requestMaxBytes: "100M",
|
||||
responseMaxBytes: "1G",
|
||||
responseMaxBytes: "5G",
|
||||
allowNets: "",
|
||||
rateLimitExemptNets: "",
|
||||
denyNets: "",
|
||||
rateLimitPerMinute: "6000",
|
||||
rateLimitPerHour: "100000",
|
||||
rateLimitPerDay: "1000000",
|
||||
rateLimitPerMinute: "1000",
|
||||
rateLimitPerHour: "10000",
|
||||
rateLimitPerDay: "50000",
|
||||
rateLimitExemptPaths: "",
|
||||
bytesLimitPerMinute: "4G",
|
||||
bytesLimitPerMinute: "10G",
|
||||
bytesLimitPerHour: "20G",
|
||||
bytesLimitPerDay: "200G",
|
||||
bytesLimitPerDay: "50G",
|
||||
bytesCount: "both",
|
||||
lookupSource: defaultLookupSource,
|
||||
lookupDBPath: "",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Package metrics keeps smallwebwaf's Prometheus metrics, as
|
||||
// docs/metrics.md lists them, and serves them in the Prometheus text
|
||||
// format, as the "Metrics endpoint" section of SPEC.md requires. No
|
||||
// metric carries a client's address.
|
||||
// Package metrics keeps smallwebwaf's Prometheus metrics, as the "Metrics
|
||||
// endpoint" section of SPEC.md lists them, and serves them in the
|
||||
// Prometheus text format. No metric carries a client's address.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
|
||||
@@ -206,7 +206,7 @@ type Hit struct {
|
||||
// Counts are a client's requests and bytes in the minute, the hour and
|
||||
// the day that end at a request, that request's included.
|
||||
//
|
||||
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
||||
//nolint:tagliatelle // SPEC.md's request log names its fields in snake_case
|
||||
type Counts struct {
|
||||
Minute float64 `json:"minute"`
|
||||
Hour float64 `json:"hour"`
|
||||
|
||||
@@ -64,10 +64,11 @@ const LimitHitErrorBurst = "error_burst"
|
||||
const timeLayout = "2006-01-02T15:04:05.000Z07:00"
|
||||
|
||||
// Line is one request's line in the request log. The field names, and
|
||||
// their order, are those of docs/request-log.md. A field that may not
|
||||
// apply to a request is left out of its line when it does not.
|
||||
// their order, are those of the "Request log" section of SPEC.md. A field
|
||||
// that may not apply to a request is left out of its line when it does
|
||||
// not.
|
||||
//
|
||||
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
||||
//nolint:tagliatelle // SPEC.md's request log names its fields in snake_case
|
||||
type Line struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
|
||||
@@ -60,19 +60,8 @@ func TestHealthCheck(t *testing.T) {
|
||||
}, 0, "")
|
||||
}
|
||||
|
||||
// Another test can take a closed server's port at once, so the app's
|
||||
// address here is a multicast address, to which TCP cannot connect.
|
||||
wantHealthCheck(t, map[string]string{
|
||||
listenAddr: ":" + port,
|
||||
upstreamURL: "http://224.0.0.1",
|
||||
}, 1, "unhealthy: connect to the app: ")
|
||||
|
||||
// Listening on port 0 takes any free port, so nothing listens on port 0
|
||||
// itself.
|
||||
wantHealthCheck(t, map[string]string{listenAddr: ":0"}, 1,
|
||||
"unhealthy: ask smallwebwaf: ")
|
||||
wantHealthCheck(t, map[string]string{listenAddr: "8080"}, 1,
|
||||
"unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ")
|
||||
app.Close()
|
||||
wantHealthCheck(t, env, 1, "unhealthy: connect to the app: ")
|
||||
|
||||
stop()
|
||||
|
||||
@@ -81,6 +70,10 @@ func TestHealthCheck(t *testing.T) {
|
||||
case <-time.After(waitLimit):
|
||||
t.Fatal("still running after being told to stop")
|
||||
}
|
||||
|
||||
wantHealthCheck(t, env, 1, "unhealthy: ask smallwebwaf: ")
|
||||
wantHealthCheck(t, map[string]string{listenAddr: "8080"}, 1,
|
||||
"unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ")
|
||||
}
|
||||
|
||||
func TestHealthCheckRefusesAnArgument(t *testing.T) {
|
||||
|
||||
@@ -1240,13 +1240,13 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) {
|
||||
"SWWAF_UPSTREAM_REQUEST_TIMEOUT": "60s",
|
||||
"SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m",
|
||||
"SWWAF_REQUEST_MAX_BYTES": "100M",
|
||||
"SWWAF_RESPONSE_MAX_BYTES": "1G",
|
||||
"SWWAF_RESPONSE_MAX_BYTES": "5G",
|
||||
"SWWAF_ALLOW_NETS": "",
|
||||
rateLimitExemptNets: "",
|
||||
"SWWAF_DENY_NETS": "",
|
||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "6000",
|
||||
"SWWAF_RATE_LIMIT_PER_HOUR": "100000",
|
||||
rateLimitPerDay: "1000000",
|
||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "1000",
|
||||
"SWWAF_RATE_LIMIT_PER_HOUR": "10000",
|
||||
rateLimitPerDay: "50000",
|
||||
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
|
||||
"SWWAF_DENIED_COUNTRIES": "",
|
||||
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Package state keeps smallwebwaf's state in JSON files in
|
||||
// SWWAF_STATE_DIR, as the "Persistent state" section of SPEC.md requires
|
||||
// and docs/state-files.md lists them:
|
||||
// SWWAF_STATE_DIR, as the "Persistent state" section of SPEC.md describes:
|
||||
// bans.json holds the bans, clients.json each client's counters and
|
||||
// history, lookups.json GeoJS's answers, reputation.json the last try and
|
||||
// last good copy of each list fetched from a URL, the DNSBL zones'
|
||||
|
||||
@@ -1019,8 +1019,8 @@ func TestEditJustBeforeAScheduledWriteSurvivesIt(t *testing.T) {
|
||||
edit(t, dir, bansJSON, permanentBansJSON)
|
||||
|
||||
// The write takes the edit in first, and writes it back. The second
|
||||
// ban, made after the admin opened the file, is lost, as the
|
||||
// "Persistent state" section of SPEC.md says.
|
||||
// ban, made after the admin opened the file, is lost, as "Edits
|
||||
// while running" in SPEC.md says.
|
||||
time.Sleep(time.Nanosecond)
|
||||
synctest.Wait()
|
||||
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
||||
// method, the URL with its query and the headers of a request, and on its
|
||||
// body while SWWAF_WAF_BODY_LIMIT is set, with the six changes smallwebwaf
|
||||
// makes to it, as "Attack detection" under "Settings" in SPEC.md describes
|
||||
// them. It reads no response.
|
||||
// makes to it, as "Attack detection" under "Configuration surface" in
|
||||
// SPEC.md describes them. It reads no response.
|
||||
//
|
||||
// smallwebwaf writes only to its state directory, so Coraza is built with
|
||||
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
# script/gitea-requests: build the image and run gitea 1.27.3 behind it,
|
||||
# with every setting of smallwebwaf at its default but SWWAF_UPSTREAM_URL,
|
||||
# which names gitea's container. Then send through it the gitea requests
|
||||
# that reading gitea's source found the Core Rule Set refuses, which
|
||||
# docs/gitea.md records, and a short sample of gitea's ordinary use:
|
||||
# signing in, pages of files and commits, a diff, the API, a push, clone
|
||||
# and fetch over HTTP, and two container image pushes. Last, print each
|
||||
# request smallwebwaf logged, with what it did with it and the ids of the
|
||||
# Core Rule Set's rules that matched it.
|
||||
# that reading gitea's source found the Core Rule Set refuses, and a short
|
||||
# sample of gitea's ordinary use: signing in, pages of files and commits,
|
||||
# a diff, the API, a push, clone and fetch over HTTP, and two container
|
||||
# image pushes. Last, print each request smallwebwaf logged, with what it
|
||||
# did with it and the ids of the Core Rule Set's rules that matched it.
|
||||
# Every request comes from one client, docker's gateway, so more than
|
||||
# SWWAF_ERROR_BURST_THRESHOLD, 30, refusals in a minute would ban it, and
|
||||
# the requests after would show as banned. The containers, the network,
|
||||
|
||||
Reference in New Issue
Block a user