No files matched your search
@@ -11,10 +11,10 @@ to them.
|
||||
## Purpose
|
||||
|
||||
- It protects one self-hosted app on the open internet from scrapers, scanners
|
||||
and other abusers, from the app's first request, with no setting at all. Every
|
||||
setting has a default chosen for a service facing the internet in 2026. A
|
||||
setting that needs an alert destination, an account key or a token stays off
|
||||
until the operator supplies it.
|
||||
and other abusers. It does so from the app's first request, with no setting at
|
||||
all. Every setting has a default chosen for a service facing the internet
|
||||
in 2026. A setting that needs an alert destination, an account key or a token
|
||||
stays off until the operator supplies it.
|
||||
- It limits each client's requests and bytes per minute, hour and day. It bounds
|
||||
the size and duration of every request and answer. It detects attacks.
|
||||
- It bans abusers: briefly at first, for seven days on a clear sign of attack,
|
||||
@@ -60,8 +60,8 @@ to them.
|
||||
- `internal/bans`: the bans. It holds each netblock's bans with their notes,
|
||||
decides how long a new ban lasts, and picks the ban to drop when the table
|
||||
of bans is full.
|
||||
- `internal/rules`: reads the rule files, and again as they change, and
|
||||
tells which rules a request matches.
|
||||
- `internal/rules`: reads the rule files at start and again when they
|
||||
change, and tells which rules a request matches.
|
||||
- `internal/waf`: the Core Rule Set, with the six changes listed under
|
||||
"Attack detection", as Coraza directives.
|
||||
- `internal/lookup`: looks clients up through GeoJS or in the lookup
|
||||
@@ -100,8 +100,8 @@ to them.
|
||||
## What happens to a request
|
||||
|
||||
The steps run in this order. The first step that produces a final answer ends
|
||||
the handling. The size and time limits apply to the whole exchange. Each step
|
||||
names the section that holds its rules.
|
||||
the checks; step 15 runs for every request. The size and time limits apply to
|
||||
the whole exchange. Each step names the section that holds its rules.
|
||||
|
||||
1. `GET /_smallwebwaf/healthz` is answered at once, before any check.
|
||||
2. The client is identified.
|
||||
@@ -147,20 +147,19 @@ names the section that holds its rules.
|
||||
while `SWWAF_WAF_MODE` is `block`, and only logged and alerted while it is
|
||||
`detect`.
|
||||
13. A request under `/_smallwebwaf/` is answered here (see "Admin endpoints").
|
||||
14. The request is forwarded to `SWWAF_UPSTREAM_URL`, streaming, with
|
||||
`X-Forwarded-For` added. With `SWWAF_ADD_LOOKUP_HEADERS`, `X-Client-ASN` and
|
||||
`X-Client-Country` are added for the app's own logs; a header whose value is
|
||||
unknown is left out. Whatever that setting says, any such header the client
|
||||
sent is removed.
|
||||
15. After the response:
|
||||
- The response bytes, the request bytes or both are added to the byte
|
||||
counters, as `SWWAF_BYTES_COUNT` says. For a WebSocket, what it carried
|
||||
each way counts once it closes. A client whose total passes a byte limit
|
||||
times the percentage has broken that limit and is banned. The response in
|
||||
progress is not cut off.
|
||||
- A refusal by a rule file, a trap path, the Core Rule Set or a token check
|
||||
is counted for the error burst (see "Attack detection"). No other refusal
|
||||
is.
|
||||
14. The request is forwarded to `SWWAF_UPSTREAM_URL`, streaming.
|
||||
`X-Forwarded-For` is added, and `X-Request-ID` carries the request's id,
|
||||
generated when traefik did not supply one. With `SWWAF_ADD_LOOKUP_HEADERS`,
|
||||
`X-Client-ASN` and `X-Client-Country` are added for the app's own logs; a
|
||||
header whose value is unknown is left out. Whatever that setting says, any
|
||||
such header the client sent is removed.
|
||||
15. After the response, or after the refusal:
|
||||
- For a request forwarded to the app, the response bytes, the request bytes
|
||||
or both are added to the byte counters, as `SWWAF_BYTES_COUNT` says. For a
|
||||
WebSocket, what it carried each way counts once it closes. A client whose
|
||||
total passes a byte limit times the percentage has broken that limit and
|
||||
is banned. The response in progress is not cut off.
|
||||
- The refusals the error burst counts (see "Attack detection") are counted.
|
||||
- The client's history and the anomaly counters are updated, and the alert
|
||||
thresholds are evaluated.
|
||||
|
||||
@@ -183,7 +182,9 @@ the token check still apply.
|
||||
with its history. The bans are bounded by `SWWAF_MAX_BANS` (see "Bans"). The
|
||||
GeoJS answers, the reputation verdicts and scores, the DNS queries under way
|
||||
and the anomaly counters have fixed bounds, listed under "Fixed limits" in
|
||||
`docs/settings.md`; past a bound, the entry used longest ago goes first.
|
||||
`docs/settings.md`. Past its bound, the GeoJS answer or anomaly counter used
|
||||
longest ago goes first, and the verdict or score fetched longest ago. Past
|
||||
1,000 DNS queries under way, a zone is asked at the client's next request.
|
||||
|
||||
## Bans
|
||||
|
||||
@@ -211,7 +212,8 @@ to a ban, each by its own rule:
|
||||
- The first one bans the netblock for `SWWAF_ATTACK_BAN_DURATION` (default
|
||||
`7d`), with the cause `attack`.
|
||||
- Any further request from the netblock while that ban lasts shows it is
|
||||
malicious: the ban becomes permanent.
|
||||
malicious: the ban becomes permanent. No other cause of ban becomes
|
||||
permanent this way.
|
||||
- Once that ban has run out, the netblock is served like any other. Its next
|
||||
clear sign of attack bans it permanently at once.
|
||||
- A broken limit: a request over a rate limit, a response that takes the
|
||||
@@ -238,8 +240,8 @@ A client on the CrowdSec decision list is banned until the decision ends, with
|
||||
the cause `crowdsec`. Only a client that sends a request is banned, so a long
|
||||
list does not fill `bans.json`. Such a ban is never made permanent. It does not
|
||||
make the netblock's next ban for a broken limit longer. A ban lifted while the
|
||||
decision is in force is followed by another at the client's next request; the
|
||||
decision is deleted in CrowdSec first, and the ban lifted once the list has been
|
||||
decision is in force is made again at the client's next request. To lift it for
|
||||
good, delete the decision in CrowdSec, then lift the ban once the list has been
|
||||
fetched again.
|
||||
|
||||
These refuse each request they cover, record nothing in `bans.json`, and count
|
||||
@@ -258,9 +260,9 @@ state") or through the ban endpoints (see "Admin endpoints"). A ban an admin
|
||||
makes has the cause `admin`; an admin who wants to keep a ban `smallwebwaf` made
|
||||
sets its cause to `admin`. A ban lifted before it ends is kept, marked as
|
||||
lifted, and does not count toward a longer ban; it refuses nothing. Deleting its
|
||||
entry from `bans.json` forgets it entirely. Only a ban whose cause is `attack`
|
||||
becomes permanent at the first request it refuses. An admin whose own address is
|
||||
banned lifts the ban by editing `bans.json`.
|
||||
entry from `bans.json` forgets it entirely. An admin whose own address is banned
|
||||
lifts the ban by editing `bans.json`, since the endpoints refuse a banned
|
||||
client's request like any other.
|
||||
|
||||
A permanent ban does not run out. It ends when an admin lifts it, or when
|
||||
`smallwebwaf` drops it from a full table, as below. When `smallwebwaf` holds
|
||||
@@ -274,8 +276,7 @@ table.
|
||||
## Settings
|
||||
|
||||
`docs/settings.md` lists every setting with its default and the values it takes,
|
||||
in the groups below. It also gives the forms of values: lists, netblocks,
|
||||
durations, sizes, countries, AS numbers, percentages and path prefixes.
|
||||
in the groups below. It also gives the form each kind of value takes.
|
||||
Conventions:
|
||||
|
||||
- These settings take `off`, which switches them off:
|
||||
@@ -301,21 +302,18 @@ Conventions:
|
||||
restarting the container. The files `smallwebwaf` watches while it runs are
|
||||
its state files, its rule files and the lookup database.
|
||||
- An invalid setting stops the process at start with one log line, marked
|
||||
`"type":"process"` with `"msg":"invalid setting"`, naming the variable and
|
||||
what is wrong with the value. runit then starts the process again every second
|
||||
until the value is fixed (see "The two processes"). At start the settings in
|
||||
effect are logged on one line; tokens, keys and the paths of webhooks, which
|
||||
often carry a secret, are shown as `********`.
|
||||
`"type":"process"` with `"msg":"invalid setting"`. The line names the variable
|
||||
and what is wrong with the value; "The two processes" says what happens then.
|
||||
At start the settings in effect are logged on one line; tokens, keys and the
|
||||
paths of webhooks, which often carry a secret, are shown as `********`.
|
||||
|
||||
### Core
|
||||
|
||||
- One listener, `SWWAF_LISTEN_ADDR`, for the requests `smallwebwaf` forwards to
|
||||
`SWWAF_UPSTREAM_URL` and for its own endpoints (see "Ports").
|
||||
- `SWWAF_INSTANCE_NAME` is in every log line, metric and alert, so that
|
||||
instances can be told apart. The default, the container's host name, changes
|
||||
at every redeploy; a name set by the operator survives it.
|
||||
- `SWWAF_MODE=observe` logs and alerts on every decision while refusing nothing,
|
||||
so that a configuration can be tried before it is enforced.
|
||||
- `SWWAF_MODE=observe` lets a configuration be tried before it is enforced (see
|
||||
"What happens to a request").
|
||||
- `X-Forwarded-For` is believed only from `SWWAF_TRUSTED_PROXIES`, by default
|
||||
the private address ranges, so a client that connects directly cannot claim
|
||||
another address. Where other containers reach `smallwebwaf` directly, or
|
||||
@@ -350,14 +348,12 @@ Conventions:
|
||||
### Logging
|
||||
|
||||
- The request log on stdout is always on and has no switch (see "Request log").
|
||||
The process's own messages share the stream; `SWWAF_LOG_LEVEL` filters only
|
||||
those, and no message is at `debug`. `warn` and `error` hold back the line of
|
||||
settings at start.
|
||||
`SWWAF_LOG_LEVEL` filters only the process's own messages, and no message is
|
||||
at `debug`. `warn` and `error` hold back the line of settings at start.
|
||||
- `SWWAF_LOG_REQUEST_HEADERS` names the request headers to record.
|
||||
`Authorization`, `Cookie` and `Set-Cookie` values are never logged, even if
|
||||
listed, only whether they were present.
|
||||
- `SWWAF_LOG_REMOTE_URL` sends every log line to a syslog server as well, over
|
||||
UDP, TCP or TLS (see "Request log").
|
||||
- `SWWAF_LOG_REMOTE_URL` names a syslog server (see "Request log").
|
||||
|
||||
### Metrics
|
||||
|
||||
@@ -370,7 +366,6 @@ Conventions:
|
||||
- `SWWAF_ALLOW_NETS` skips the checks step 3 names, for monitoring or the
|
||||
owner's own networks. `SWWAF_RATE_LIMIT_EXEMPT_NETS` bypasses the request and
|
||||
byte limits only; the error burst, attack detection and bans still apply.
|
||||
`SWWAF_DENY_NETS` is always refused.
|
||||
- Many clients share one address (mobile carriers, offices, Tor), and so share
|
||||
its limits and its bans. The defaults sit well above one person's use, and the
|
||||
first two lists take known shared addresses out.
|
||||
@@ -379,9 +374,8 @@ Conventions:
|
||||
|
||||
Breaking one bans the client, so the defaults sit several times above what one
|
||||
busy person produces. A browser loading a heavy page makes a few hundred
|
||||
requests, a git clone a handful, and several people often share one address. A
|
||||
machine that talks to the app all day can still pass the day limit. Its address
|
||||
belongs in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||
requests, and a git clone a handful. A machine that talks to the app all day can
|
||||
still pass the day limit. Its address belongs in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||
|
||||
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` takes path prefixes out of the count, such as
|
||||
static assets or health checks; their bytes are not counted either. A prefix is
|
||||
@@ -394,10 +388,9 @@ matched the same way.
|
||||
|
||||
### Byte limits, per client
|
||||
|
||||
A response's bytes are counted when it ends, so a byte limit never cuts an
|
||||
answer short; the client's next request is refused instead. Every default sits
|
||||
above the largest request and answer together, so at the defaults no single
|
||||
download breaks a limit.
|
||||
A response's bytes are counted when it ends (see step 15). So every default sits
|
||||
above the largest request and answer together, and no single download breaks a
|
||||
limit.
|
||||
|
||||
### Size and time limits
|
||||
|
||||
@@ -476,10 +469,9 @@ service that keeps its visitors' addresses on its own host.
|
||||
request to GeoJS. A client whose answer has not come in time counts as unknown
|
||||
until it comes, and its later requests do not wait. Its address is asked about
|
||||
again in the background.
|
||||
- A client the lookup cannot place has an unknown country and AS number. The
|
||||
biased thresholds give it `SWWAF_UNKNOWN_LIMIT_PERCENT`, and the exclusive
|
||||
country list refuses it (see "Country lists"). Nothing else treats it
|
||||
differently.
|
||||
- A client the lookup cannot place has an unknown country and AS number, which
|
||||
the country lists and the biased thresholds treat as described there. Nothing
|
||||
else treats it differently.
|
||||
- GeoJS publishes no rate limit, but its terms forbid "an excessive amount of
|
||||
API requests", judged by GeoJS alone, and let it block a caller. While GeoJS
|
||||
is slow, down or refusing `smallwebwaf`, new clients count as unknown. GeoJS
|
||||
@@ -489,21 +481,19 @@ service that keeps its visitors' addresses on its own host.
|
||||
- The file is the free IPinfo Lite database (`ipinfo_lite.mmdb`), whose `asn`,
|
||||
`as_name` and `country_code` fields are read. The operator downloads it with a
|
||||
free IPinfo account and mounts it read-only; `smallwebwaf` never fetches it
|
||||
and holds no account or token for it. IPinfo updates it daily, and releases it
|
||||
under the Creative Commons Attribution-ShareAlike 4.0 licence, asking for a
|
||||
link to IPinfo on the site that uses it, such as "IP address data is powered
|
||||
by IPinfo".
|
||||
and holds no account or token for it. IPinfo updates it daily. Its licence is
|
||||
Creative Commons Attribution-ShareAlike 4.0, and IPinfo asks for a link to it
|
||||
on the site that uses it, such as "IP address data is powered by IPinfo".
|
||||
- The file is read whole at start; one that is missing or unreadable stops the
|
||||
start. It is read again once it has gone 2 seconds without a change after
|
||||
being replaced. One that cannot be read is logged, sent as one `file_error`
|
||||
alert and ignored. The database already loaded stays in use. The directory
|
||||
that holds the file is mounted, not the file itself (see "Rule files" for
|
||||
why). A client whose address the file does not hold counts as unknown.
|
||||
alert and ignored. The database already loaded stays in use. A client whose
|
||||
address the file does not hold counts as unknown.
|
||||
|
||||
### Country lists
|
||||
|
||||
Both are empty by default: the defaults judge a client by what it does, not by
|
||||
where it comes from. Clients in `SWWAF_ALLOW_NETS` are not checked.
|
||||
where it comes from.
|
||||
|
||||
- `SWWAF_DENIED_COUNTRIES` refuses every request from a listed country.
|
||||
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` lets through only clients placed in a
|
||||
@@ -524,9 +514,10 @@ where it comes from. Clients in `SWWAF_ALLOW_NETS` are not checked.
|
||||
|
||||
### Biased thresholds
|
||||
|
||||
The lists are empty by default, for the same reason as the country lists. The
|
||||
request log and the metrics show which AS numbers and countries a service's
|
||||
abuse comes from.
|
||||
The biased thresholds lower the limits of clients in listed AS numbers and
|
||||
countries. The lists are empty by default, for the same reason as the country
|
||||
lists. The request log and the metrics show which AS numbers and countries a
|
||||
service's abuse comes from.
|
||||
|
||||
- Clients in a listed AS number or country get that percentage of every request
|
||||
and byte limit (`SWWAF_ASN_LIMIT_PERCENT`, `SWWAF_COUNTRY_LIMIT_PERCENT`). A
|
||||
@@ -566,9 +557,8 @@ body. The rule files, the limits and the bans still apply to the client that
|
||||
sends it.
|
||||
|
||||
- The Core Rule Set inspects only a request that every earlier check let
|
||||
through. It skips a path under `SWWAF_WAF_EXEMPT_PATHS` and a client in
|
||||
`SWWAF_ALLOW_NETS`. An engine error on a request forwards the request, and is
|
||||
logged and counted.
|
||||
through, and skips a path under `SWWAF_WAF_EXEMPT_PATHS`. An engine error on a
|
||||
request forwards the request, and is logged and counted.
|
||||
- `SWWAF_WAF_PARANOIA_LEVEL` and `SWWAF_WAF_ANOMALY_THRESHOLD` are the Core Rule
|
||||
Set's own two tuning values, at its own defaults. Each level up runs more
|
||||
rules, which find more attacks and take more ordinary requests for attacks.
|
||||
@@ -577,8 +567,8 @@ sends it.
|
||||
default one critical rule is enough. With the threshold `off`, nothing is a
|
||||
match, and scores are still logged. A request with more than 1000 query
|
||||
parameters or body fields adds 5 to its score, since Coraza reads only the
|
||||
first 1000. Either way the log line gives the rule ids and the score, and a
|
||||
`waf_block` alert is sent.
|
||||
first 1000. For a match, in `block` and `detect` mode alike, the log line
|
||||
gives the rule ids and the score, and a `waf_block` alert is sent.
|
||||
- `SWWAF_WAF_DISABLED_RULES` switches off rule ids when an app trips a false
|
||||
positive. The default switches off six rules:
|
||||
- 920340, 920420 and 920640 refuse a request for the type of its body, when
|
||||
@@ -605,19 +595,20 @@ sends it.
|
||||
to send the part that is read. Body inspection suits apps whose forms carry no
|
||||
code; `docs/gitea.md` records what it refuses in front of gitea.
|
||||
- `SWWAF_TRAP_PATHS` names paths the app never serves and only scanners ask for,
|
||||
such as `/wp-login.php` in front of gitea. A request for one is a clear sign
|
||||
of attack. This is the short form of a `path` rule with the `ban` action, for
|
||||
deployments that mount no rule files. 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`, and does not match
|
||||
`/wp-login.php/` or `/blog/wp-login.php`.
|
||||
such as `/wp-login.php` in front of gitea. This is the short form of a `path`
|
||||
rule with the `ban` action, for deployments that mount no rule files. 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`, and does not match `/wp-login.php/` or
|
||||
`/blog/wp-login.php`.
|
||||
- `SWWAF_ERROR_BURST_THRESHOLD` counts a client's refusals per minute for a rule
|
||||
file match, a trap path, a Core Rule Set match, or a missing or wrong token.
|
||||
More than this breaks a limit (see "Bans"). A client trying one attack or
|
||||
token after another is refused many times a minute; a person rarely more than
|
||||
a few times. Answers the app gives are not counted, since they do not tell a
|
||||
scanner from an ordinary client. In front of gitea, registry and git clients
|
||||
get `404` and `401` by design, hundreds of times a minute.
|
||||
file match, a trap path, a Core Rule Set match, or a missing or wrong token;
|
||||
no other refusal counts. More than this breaks a limit (see "Bans"). A client
|
||||
trying one attack or token after another is refused many times a minute; a
|
||||
person rarely more than a few times. Answers the app gives are not counted,
|
||||
since they do not tell a scanner from an ordinary client. In front of gitea,
|
||||
registry and git clients get `404` and `401` by design, hundreds of times a
|
||||
minute.
|
||||
|
||||
`smallwebwaf` changes the Core Rule Set 4.25.0 in six ways, since in front of
|
||||
gitea it would otherwise refuse ordinary requests. The changes hold in front of
|
||||
@@ -645,15 +636,15 @@ every app, and no setting undoes them:
|
||||
- `artifactName` and `redirect_to`.
|
||||
|
||||
gitea puts file paths, branch and workflow names, and the page to return to
|
||||
after signing in into them. Any name on those lists can be an ordinary file
|
||||
or branch, such as `.gitignore` or `docker-build`. In these parameters,
|
||||
anything only these three rules catch reaches the app, commands such as
|
||||
`|cat /etc/passwd` included. Path traversal, SQL and script injection, and
|
||||
PHP, Java and Node.js code are still refused there. Every other parameter
|
||||
keeps all three rules. Parameter names are matched without regard to case,
|
||||
as Coraza matches them. An app that uses one of these parameters as a file
|
||||
on the server, or passes it to a shell, must check the value itself. A rule
|
||||
file can refuse it instead.
|
||||
after signing in into them. It reads these values as names in a repository
|
||||
or in its own records, or as a page of its own site, never as a command. Any
|
||||
name on those lists can be an ordinary file or branch, such as `.gitignore`
|
||||
or `docker-build`. In these parameters, anything only these three rules
|
||||
catch reaches the app, commands such as `|cat /etc/passwd` included. Path
|
||||
traversal, SQL and script injection, and PHP, Java and Node.js code are
|
||||
still refused there. Every other parameter keeps all three rules. An app
|
||||
that uses one of these parameters as a file on the server, or passes it to a
|
||||
shell, must check the value itself. A rule file can refuse it instead.
|
||||
|
||||
5. Two cookies are left out, and `Referer` keeps fewer rules:
|
||||
- The Core Rule Set reads the request without the `gitea_flash` and
|
||||
@@ -677,6 +668,9 @@ every app, and no setting undoes them:
|
||||
6. Responses are not inspected. A raw file from a repository, such as a shell
|
||||
script, looks to the response rules like source code leaking from the server.
|
||||
|
||||
The parameter and cookie names in these changes are matched without regard to
|
||||
case, as Coraza matches them.
|
||||
|
||||
### Ban lengths, response and scope
|
||||
|
||||
`SWWAF_BAN_RESPONSE` is `403` by default, which makes a ban easy to recognise
|
||||
@@ -690,10 +684,10 @@ to the surrounding netblock. The four ban lengths are under "Bans".
|
||||
No source is on by default. A source acts on a list kept elsewhere; the defaults
|
||||
judge a client only by what it does here. AbuseIPDB and CrowdSec also need an
|
||||
account or an engine of the operator's own. The list best suited to be on by
|
||||
default, Spamhaus DROP, must not be fetched more than once an hour, and Spamhaus
|
||||
may block an address that does. Each `smallwebwaf` fetches its own copy, so
|
||||
several apps on one host, sharing one address, would often fetch less than an
|
||||
hour apart.
|
||||
default, Spamhaus DROP, has terms, below, that forbid frequent fetching, and
|
||||
Spamhaus may block an address that breaks them. Each `smallwebwaf` fetches its
|
||||
own copy, so several apps on one host, sharing one address, would often fetch
|
||||
too close together.
|
||||
|
||||
- Blocklists (`SWWAF_BLOCKLIST_URLS`) are text files of addresses and netblocks,
|
||||
one to a line, as the Spamhaus DROP list is written. Anything after a `;` or
|
||||
@@ -718,33 +712,34 @@ hour apart.
|
||||
`AS<number>:<percent>`.
|
||||
|
||||
The log, the alerts, the ban notes and the metrics name each list by its
|
||||
URL, so a secret stays out of it.
|
||||
URL, so a list's URL must not hold a secret.
|
||||
|
||||
- `SWWAF_BLOCKLIST_ACTION` is `deny` by default, which refuses every request
|
||||
from a listed address and suits lists of networks that send nothing
|
||||
legitimate, such as DROP. `limit:<percent>` gives listed clients that
|
||||
percentage of every limit, and suits a list of addresses shared with ordinary
|
||||
visitors, such as Tor exits. `log` only notes the hit in the log line and
|
||||
raises an alert.
|
||||
percentage of every request and byte limit, and suits a list of addresses
|
||||
shared with ordinary visitors, such as Tor exits. `log` only notes the hit in
|
||||
the log line and raises an alert.
|
||||
- A CrowdSec engine on the host (`SWWAF_CROWDSEC_LAPI_URL`) has its decision
|
||||
list fetched every minute, with `SWWAF_CROWDSEC_LAPI_KEY` in the `X-Api-Key`
|
||||
header. The list is kept like a fetched blocklist, and a client on it is
|
||||
banned (see "Bans"). Only 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
|
||||
fetch fails on an answer other than `200`, a redirect included, on one not
|
||||
finished within a minute, on one longer than 16 MiB, and on one that is not a
|
||||
list of decisions. This is how a fleet-wide blocklist can arrive without
|
||||
`smallwebwaf` depending on CrowdSec.
|
||||
decision lasts for the `duration` it had left when the engine answered. This
|
||||
is how a fleet-wide blocklist can arrive without `smallwebwaf` depending on
|
||||
CrowdSec. A fetch fails when:
|
||||
- the engine answers other than `200`, a redirect included;
|
||||
- the fetch does not finish within a minute;
|
||||
- the answer is longer than 16 MiB;
|
||||
- the answer is not a list of decisions.
|
||||
- Each DNS blocklist (`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, so `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, but one in `127.255.255.0/24` is Spamhaus refusing the
|
||||
query. A name that does not exist does not list the client. These are
|
||||
failures, after which the zone is not asked again for a minute:
|
||||
in the background, the first time the client is seen. No request waits for the
|
||||
answer. 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. So
|
||||
`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, but one in `127.255.255.0/24` is Spamhaus
|
||||
refusing the query. A name that does not exist does not list the client. These
|
||||
are failures, after which the zone is not asked again for a minute:
|
||||
- an answer in `127.255.255.0/24`;
|
||||
- an answer outside `127.0.0.0/8`;
|
||||
- a resolver error;
|
||||
@@ -752,10 +747,10 @@ hour apart.
|
||||
|
||||
DNS blocklists made for mail servers list residential ranges, and would
|
||||
block ordinary visitors, so they should not be used. Spamhaus zones need
|
||||
their keyed query service, given as a zone name containing the key, which
|
||||
`smallwebwaf` shows as `********` wherever it names the zone; only
|
||||
`reputation.json` keeps the zone with its key. `SWWAF_DNSBL_RESOLVER` names
|
||||
a resolver, since public resolvers are refused by several list operators.
|
||||
their keyed query service, given as a zone name that contains the key.
|
||||
`smallwebwaf` shows the key as `********` wherever it names the zone. Only
|
||||
`reputation.json` keeps the key. `SWWAF_DNSBL_RESOLVER` names a resolver,
|
||||
since public resolvers are refused by several list operators.
|
||||
|
||||
- AbuseIPDB (`SWWAF_ABUSEIPDB_KEY`) is asked, at its check endpoint, for the
|
||||
abuse confidence score of a client, from 0 to 100. Only a client that has
|
||||
@@ -767,14 +762,16 @@ hour apart.
|
||||
address of the request that set off the check, and the score is used for the
|
||||
whole group. A score at or over `SWWAF_ABUSEIPDB_MIN_SCORE` is a hit. The
|
||||
budget is counted in UTC days and kept in `reputation.json`, so a restart does
|
||||
not make it whole again; the check that spends the last of it is logged and
|
||||
alerted, and no client is checked until the day ends. A check fails on an
|
||||
answer other than `200`, on one without a score, and on no answer within
|
||||
`SWWAF_REPUTATION_TIMEOUT`; after a failure no client is checked for a minute.
|
||||
The key is sent in the `Key` header and nowhere else, and is never logged.
|
||||
not make it whole again. The check that spends the last of it is logged and
|
||||
alerted, and no client is checked until the day ends. The key is sent in the
|
||||
`Key` header and nowhere else, and is never logged. A check fails, after which
|
||||
no client is checked for a minute, when:
|
||||
- AbuseIPDB answers other than `200`;
|
||||
- the answer gives no score;
|
||||
- no answer comes within `SWWAF_REPUTATION_TIMEOUT`.
|
||||
- `SWWAF_REPUTATION_ACTION`, for DNS blocklist and AbuseIPDB hits, is `limit:25`
|
||||
by default. Such verdicts are less certain than a blocklist, so a listed
|
||||
client gets a quarter of every limit rather than a refusal.
|
||||
client gets a quarter of every request and byte limit rather than a refusal.
|
||||
- Verdicts and scores are kept for `SWWAF_REPUTATION_CACHE_TTL`, in memory and
|
||||
in `reputation.json`, with the fixed bounds under "Fixed limits" in
|
||||
`docs/settings.md`. A source that is down or over quota gives no verdict. The
|
||||
@@ -797,13 +794,13 @@ hour apart.
|
||||
counted by event, so a wide attack cannot flood the channel. Once the hour
|
||||
ends, one alert with the event `summary` gives those counts. It also counts
|
||||
the repeats that a cooldown held back and that no later alert reported.
|
||||
- Each destination has a queue of at most 1000 alerts, sent one at a time, so a
|
||||
slow destination holds up nothing; when it is full, the oldest alert is
|
||||
dropped, and the drops are counted in the metrics. 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.
|
||||
- Each destination has a bounded queue (see "Fixed limits" in
|
||||
`docs/settings.md`), sent one at a time, so a slow destination holds up
|
||||
nothing. When a queue is full, its oldest alert is dropped and counted in the
|
||||
metrics. 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.
|
||||
|
||||
### Anomaly thresholds
|
||||
|
||||
@@ -814,9 +811,12 @@ which the metrics show. They reveal a widely distributed scraper, thousands of
|
||||
addresses at low rates each, which the per-client limits do not see. A low
|
||||
`SWWAF_ASN_LIMIT_PERCENT` for its AS number is the response.
|
||||
|
||||
- Requests and bytes are counted per minute and per hour, for each client, each
|
||||
surrounding netblock, each AS number, the whole service, and each named
|
||||
netblock in `SWWAF_WATCH_NETS`.
|
||||
- Requests and bytes are counted per minute and per hour, for each of:
|
||||
- a client;
|
||||
- the surrounding netblock;
|
||||
- an AS number;
|
||||
- the whole service;
|
||||
- a named netblock in `SWWAF_WATCH_NETS`.
|
||||
- Every request but the health check is counted, whatever was done with it,
|
||||
clients in `SWWAF_ALLOW_NETS` and `SWWAF_RATE_LIMIT_EXEMPT_NETS` included,
|
||||
since an exempt client misbehaving is worth knowing about. The counters are
|
||||
@@ -825,8 +825,8 @@ addresses at low rates each, which the per-client limits do not see. A low
|
||||
## Rule files
|
||||
|
||||
`smallwebwaf` reads every `*.rules` file in `SWWAF_RULES_DIR` and checks each
|
||||
request against them, unless the client is in `SWWAF_ALLOW_NETS`.
|
||||
`docs/rules.md` gives the format, the targets and actions, and the default file.
|
||||
request against them. `docs/rules.md` gives the format, the targets and actions,
|
||||
and the default file.
|
||||
|
||||
- The files are plain text meant to be edited by hand, so that a new scanning
|
||||
pattern seen in the request log becomes a rule in one line. `smallwebwaf`
|
||||
@@ -835,7 +835,7 @@ request against them, unless the client is in `SWWAF_ALLOW_NETS`.
|
||||
for 2 seconds, so that a file still being written is read whole. A file whose
|
||||
name starts with `.`, such as an editor's lock file, is not a rule file.
|
||||
- Files are read in name order (`00-default.rules` before `50-gitea.rules`),
|
||||
rules in line order. Matching stops at the first rule that refuses.
|
||||
rules in line order.
|
||||
- A rule's regex is Go's RE2 syntax. It has no backreferences or lookaround; in
|
||||
exchange matching time is linear in the input, so no rule can be made to stall
|
||||
the proxy.
|
||||
@@ -843,18 +843,16 @@ request against them, unless the client is in `SWWAF_ALLOW_NETS`.
|
||||
encoded probe cannot slip past. A header sent more than once is matched with
|
||||
its values joined by `, `; a header not sent is empty text. `Host` and
|
||||
`Transfer-Encoding` cannot be targets, since Go's HTTP server takes them out
|
||||
of every request. Request bodies are not available to rule files; reading them
|
||||
is the Core Rule Set's job.
|
||||
of every request; a rule naming one stops the start like any bad line. Request
|
||||
bodies are not available to rule files; reading them is the Core Rule Set's
|
||||
job.
|
||||
- `log` only notes the match in the request log. `block` refuses the request
|
||||
without a ban; `ban` makes the request a clear sign of attack (see "Bans").
|
||||
`ban` is for requests no real visitor sends, anchored at the site root. A file
|
||||
of the same name deeper in a site can be ordinary content, such as a file in a
|
||||
gitea repository. A visitor banned by mistake is let back in by lifting the
|
||||
ban.
|
||||
without a ban; `ban` makes the request a clear sign of attack (see "Bans"),
|
||||
and is for requests no real visitor sends. A visitor banned by mistake is let
|
||||
back in by lifting the ban.
|
||||
- At start, these stop the process with a message naming the file and line:
|
||||
- a line that does not parse;
|
||||
- a regex that does not compile;
|
||||
- a rule for `Host` or `Transfer-Encoding`;
|
||||
- a duplicate id.
|
||||
|
||||
So does a `SWWAF_RULES_DIR` that does not exist or cannot be read. While
|
||||
@@ -867,10 +865,11 @@ request against them, unless the client is in `SWWAF_ALLOW_NETS`.
|
||||
wrong for every app, with its path rules anchored at the site root. In front
|
||||
of gitea, `/.env` is a probe, while
|
||||
`/<owner>/<repo>/src/branch/main/.env.example` is a file in a repository that
|
||||
any visitor may open. Anything app-specific, such as a rule banning WordPress
|
||||
probes in front of gitea, belongs in a file the app's Dockerfile adds or the
|
||||
deployer mounts. A directory mounted over `SWWAF_RULES_DIR` replaces the
|
||||
default file; single files mounted into it add to it.
|
||||
any visitor may open. `ban` rules of your own follow the same rule. Anything
|
||||
app-specific, such as a rule banning WordPress probes in front of gitea,
|
||||
belongs in a file the app's Dockerfile adds or the deployer mounts. A
|
||||
directory mounted over `SWWAF_RULES_DIR` replaces the default file; single
|
||||
files mounted into it add to it.
|
||||
- Rules meant to be edited while `smallwebwaf` runs belong in a mounted
|
||||
directory, not a mounted file. Many editors save a file by replacing it, and a
|
||||
container keeps seeing the old one when the file alone is mounted. The lookup
|
||||
@@ -880,11 +879,10 @@ request against them, unless the client is in `SWWAF_ALLOW_NETS`.
|
||||
## Persistent state
|
||||
|
||||
All state lives in memory, and the files in `SWWAF_STATE_DIR` hold a copy of all
|
||||
of it, so an orderly stop loses nothing. The one exception is log lines still
|
||||
waiting to be sent to `SWWAF_LOG_REMOTE_URL`, which stdout has already carried.
|
||||
The files are meant for people as well: an admin can read or edit them at any
|
||||
time, and the running `smallwebwaf` takes the edit in. `docs/state-files.md`
|
||||
lists the files, what each holds, and their sizes.
|
||||
of it, so an orderly stop loses nothing (the remote log is the exception; see
|
||||
"Request log"). The files are meant for people as well: an admin can read or
|
||||
edit them at any time, and the running `smallwebwaf` takes the edit in.
|
||||
`docs/state-files.md` lists the files, what each holds, and their sizes.
|
||||
|
||||
- Each file holds one kind of state, as JSON with:
|
||||
- a top-level `version` number;
|
||||
@@ -897,9 +895,9 @@ lists the files, what each holds, and their sizes.
|
||||
halves their size and lets `grep` show everything about one client.
|
||||
- Clients that were never banned are kept too, so every client's history
|
||||
survives a restart.
|
||||
- Each file has a bound, so it can be rewritten whole. Writing a file of these
|
||||
sizes takes well under a second, in the background, so rewriting whole files
|
||||
needs nothing cleverer.
|
||||
- Each file has a bound, so it can be rewritten whole. Writing one takes well
|
||||
under a second, in the background, so rewriting whole files needs nothing
|
||||
cleverer.
|
||||
- A write serialises from a snapshot taken under the lock, so requests are not
|
||||
held up. It goes to a temporary file in the same directory, which is synced
|
||||
and renamed over the real name, and the directory is synced. A crash at any
|
||||
@@ -907,23 +905,26 @@ lists the files, what each holds, and their sizes.
|
||||
partial one. Before writing a file, `smallwebwaf` takes in any edit made to it
|
||||
on disk, so an admin's edit is never overwritten.
|
||||
- At start, the files are read back. Active bans go into an in-memory lookup by
|
||||
netblock, everything else into maps. Counter buckets and cache entries whose
|
||||
time has passed are discarded. A missing file means empty state, and is normal
|
||||
on first run. A file that does not parse, or has an unknown `version`, stops
|
||||
the process with a message naming the file and position; so does an entry
|
||||
without a field it needs, a ban whose cause is not one of the four, or a ban
|
||||
whose `expires` is not after its `start`. Starting with empty state would
|
||||
silently forgive every repeat offender. Since writes are atomic, a broken file
|
||||
can only come from a hand edit, which the editor should hear about.
|
||||
netblock, everything else into maps. Expired cache entries are discarded. A
|
||||
missing file means empty state, and is normal on first run. These stop the
|
||||
process with a message naming the file and position:
|
||||
- a file that does not parse, or has an unknown `version`;
|
||||
- an entry without a field it needs;
|
||||
- a ban whose cause is not one of the four;
|
||||
- a ban whose `expires` is not after its `start`.
|
||||
|
||||
Starting with empty state would silently forgive every repeat offender.
|
||||
Since writes are atomic, a broken file can only come from a hand edit, which
|
||||
the editor should hear about.
|
||||
|
||||
- While running, `smallwebwaf` notices a file that is edited, replaced or added.
|
||||
It tells its own writes from an admin's by comparing the file with what it
|
||||
last read or wrote. An edit that parses is taken in at once: what the file
|
||||
says replaces what `smallwebwaf` held for that file. Removing a ban's entry
|
||||
from `bans.json` lifts the ban; adding an entry bans; setting `lifted` lifts
|
||||
it and keeps it. A change `smallwebwaf` made after the admin opened the file,
|
||||
such as a new ban, is lost when the admin saves over it. Most editors warn
|
||||
when a file changed on disk while it was open. A file that is removed is
|
||||
written again at its next write.
|
||||
says replaces what `smallwebwaf` held for that file. An edit to `bans.json`
|
||||
makes, lifts or forgets bans as "Bans" describes. A change `smallwebwaf` made
|
||||
after the admin opened the file, such as a new ban, is lost when the admin
|
||||
saves over it. Most editors warn when a file changed on disk while it was
|
||||
open. A file that is removed is written again at its next write.
|
||||
- An edit that does not parse never stops the running `smallwebwaf`. It keeps
|
||||
the state it has and sets the edited file aside as `<name>.bad`, so the edit
|
||||
is kept. It writes the file again from memory, and names the file and the
|
||||
@@ -999,8 +1000,6 @@ endpoints, what each takes and what each answers.
|
||||
the error burst. A client guessing tokens is therefore banned once it passes
|
||||
`SWWAF_ERROR_BURST_THRESHOLD` guesses in a minute.
|
||||
- A ban made through the endpoints starts at once, with the cause `admin`.
|
||||
Lifting through the endpoints lifts every active ban covering the address
|
||||
given.
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -1017,8 +1016,10 @@ what the image holds and how each part is pinned.
|
||||
need older versions of packages the image holds, and it moves forward with the
|
||||
image's digest. The Dockerfile names the hash of each of the snapshot's
|
||||
`InRelease` files, which cover every package list and so every package, and
|
||||
the build checks them before installing. The hashes are the amd64 archive's,
|
||||
so the image is built for amd64 only.
|
||||
the build checks them before installing. The live archive's `InRelease` files,
|
||||
which `apt-get update` also fetches and the install does not use, are left out
|
||||
of the check. The hashes are the amd64 archive's, so the image is built for
|
||||
amd64 only.
|
||||
- The Ubuntu image has no CA certificates. Without them Nix cannot download
|
||||
packages and `smallwebwaf` cannot reach GeoJS, so `ca-certificates` is
|
||||
installed by name. The snapshot itself is fetched over HTTPS with the CA
|
||||
@@ -1032,15 +1033,14 @@ what the image holds and how each part is pinned.
|
||||
own commands; busybox's `sv`, for one, looks for services elsewhere. An app
|
||||
built on the image gets the same packages each time. Security fixes from
|
||||
Ubuntu and nixpkgs come with a newer `smallwebwaf` image, which an app takes
|
||||
by changing the digest in its `FROM` line; the image moves to each new nixpkgs
|
||||
by changing the digest in its `FROM` line. The image moves to each new nixpkgs
|
||||
release, every six months.
|
||||
- runit supervises the two processes, with `runsvinit` as the entrypoint, as the
|
||||
owner's code style guide asks for service containers. No package carries
|
||||
`runsvinit`, so the image builds it from its archived source, unchanged since
|
||||
2015, at a fixed commit hash, with a `go.mod` the build writes. `runsvinit`
|
||||
starts runit's `runsvdir`, which runs the `run` script of each directory under
|
||||
`/etc/service` and runs it again whenever it exits. Ubuntu's `sv` looks for
|
||||
services there too.
|
||||
`/etc/service`. Ubuntu's `sv` looks for services there too.
|
||||
- The image's `run` script for `smallwebwaf` waits one second and makes
|
||||
`SWWAF_STATE_DIR` and every file in it belong to the `smallwebwaf` user (uid
|
||||
and gid 65532). Then it starts `smallwebwaf` as that user with runit's
|
||||
@@ -1072,19 +1072,20 @@ It sets no `ENTRYPOINT` or `USER` of its own. The container must start
|
||||
script starts as root and hands over to its program's user with `chpst`. Both
|
||||
services start with the container's environment variables and write to the
|
||||
container's output, which `docker logs` shows.
|
||||
- When `smallwebwaf` or the app exits, runit starts it again a second later. The
|
||||
other process keeps running, and the container does not stop. While
|
||||
`smallwebwaf` is down, traefik answers `502`. While the app is down,
|
||||
`smallwebwaf` answers `502`, which is not a client offence.
|
||||
- When `smallwebwaf` or the app exits, runit runs its `run` script again, which
|
||||
waits a second before starting it. The other process keeps running, and the
|
||||
container does not stop. While `smallwebwaf` is down, traefik answers `502`.
|
||||
While the app is down, `smallwebwaf` answers `502`, which is not a client
|
||||
offence.
|
||||
- Only four things stop `smallwebwaf`, all at start:
|
||||
- invalid configuration;
|
||||
- a lookup database that is missing or unreadable;
|
||||
- a rule file or state file that does not parse;
|
||||
- an unwritable `SWWAF_STATE_DIR`.
|
||||
- A `smallwebwaf` that stops at start is started again every second, logging its
|
||||
message each time, until the cause is fixed; the health check fails meanwhile.
|
||||
Once running, a failing GeoJS, reputation source or alert destination, or a
|
||||
broken edit, never takes the protected service down.
|
||||
- A `smallwebwaf` that stops at start is therefore started again every second,
|
||||
logging its message each time, until the cause is fixed; the health check
|
||||
fails meanwhile. Once running, a failing GeoJS, reputation source or alert
|
||||
destination, or a broken edit, never takes the protected service down.
|
||||
- `docker stop` sends `SIGTERM` to `runsvinit`, which has runit stop both
|
||||
services at once, each with `SIGTERM`, and exits once they have stopped.
|
||||
`smallwebwaf` stops taking requests, gives those in progress five seconds to
|
||||
@@ -1126,9 +1127,8 @@ beside the app's data directories, belonging to the `smallwebwaf` user. Without
|
||||
a volume there, the files survive a restart of the container and are lost when a
|
||||
deploy replaces it. A volume mounted at `/var/lib/smallwebwaf`, named or a host
|
||||
directory, keeps them across deploys. A host directory mounted there needs no
|
||||
change of owner, since the `run` script gives it to the `smallwebwaf` user at
|
||||
each start; files an earlier owner left in it can be read and replaced. The
|
||||
volume is separate from the app's, holds a few tens of MiB at most with the
|
||||
change of owner, and files an earlier owner left in it can be read and replaced.
|
||||
The volume is separate from the app's, holds a few tens of MiB at most with the
|
||||
defaults, and needs no backup beyond the host's own. The image declares no
|
||||
volume, since every app image built on it would inherit it.
|
||||
|
||||
@@ -1164,8 +1164,8 @@ and `script/gitea-requests` checks those notes.
|
||||
|
||||
## Planned, not built
|
||||
|
||||
Everything above is built. These two points are specified as later work, and
|
||||
nothing in the code does them yet:
|
||||
These two points are specified as later work, and nothing in the code does them
|
||||
yet:
|
||||
|
||||
- Planned: sharing bans between the instances of several apps, by running one
|
||||
CrowdSec engine per host that every instance reports its bans to. It is
|
||||
|
||||
+8
-5
@@ -78,11 +78,14 @@ 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. `netblock`: the ban's
|
||||
netblock, the netblock an anomaly counted, or, for the other events, the
|
||||
client as rate limiting counts it: an IPv4 address as a /32, or an IPv6 group.
|
||||
Both are empty for `source_failure` and `file_error`.
|
||||
- `asn`, `as_name` and `country`: the client's as known when the alert was
|
||||
- `client`: the address whose request raised the alert. Empty for
|
||||
`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;
|
||||
- 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 1000` or `listed by a blocklist`.
|
||||
|
||||
+38
-34
@@ -21,10 +21,10 @@ checked again when gitea or the Core Rule Set changes version.
|
||||
- a release attachment.
|
||||
|
||||
At the defaults, an upload larger than 100 MiB or taking more than 60
|
||||
seconds is cut off, and no one is banned for it. "Size and time limits" in
|
||||
`SPEC.md` gives the answers the client gets. A gitea that takes large
|
||||
uploads needs `SWWAF_REQUEST_MAX_BYTES`, `SWWAF_CLIENT_REQUEST_TIMEOUT` and
|
||||
`SWWAF_UPSTREAM_REQUEST_TIMEOUT` raised to fit.
|
||||
seconds is cut off ("Size and time limits" in `SPEC.md`). A gitea that takes
|
||||
large uploads needs `SWWAF_REQUEST_MAX_BYTES`,
|
||||
`SWWAF_CLIENT_REQUEST_TIMEOUT` and `SWWAF_UPSTREAM_REQUEST_TIMEOUT` raised
|
||||
to fit.
|
||||
|
||||
- Archive download and blame or history pages are what scrapers hammer. The
|
||||
request limits do most of the work there.
|
||||
@@ -63,15 +63,15 @@ toward). The request log names the rule in `waf_rule_ids`, which
|
||||
|
||||
### A query string that reads as an attack
|
||||
|
||||
Most often this is a search. Every query parameter is checked against the Core
|
||||
Rule Set's lists of system files and commands, except the ten named under
|
||||
"Attack detection" in `SPEC.md`. A release attachment uploaded through the API
|
||||
as `docker-compose.yml` is refused the same way. Examples:
|
||||
Most often this is a search. A search term is a query parameter, which the Core
|
||||
Rule Set checks against its lists of system files and commands (see the fourth
|
||||
change under "Attack detection" in `SPEC.md`). A release attachment uploaded
|
||||
through the API as `docker-compose.yml` is refused the same way. Examples:
|
||||
|
||||
- a name on those lists, such as `package.json`, `.gitignore` or
|
||||
`docker-compose.yml`;
|
||||
- text that starts with a command name, or whose first word has one right after
|
||||
a `/`, such as `python3`, `ssh key` or `feat/docker-support`;
|
||||
- text that starts with a command name, such as `python3` or `ssh key`, or has
|
||||
one right after a `/` in its first word, such as `feat/docker-support`;
|
||||
- a shell command with its options or a system path (`ls -la`, `sed -i`,
|
||||
`/bin/sh`), or a command in backticks;
|
||||
- script code (`fetch(`, `${VAR}`, `process.env`), HTML (`<img src=`) or an SQL
|
||||
@@ -81,15 +81,15 @@ as `docker-compose.yml` is refused the same way. Examples:
|
||||
### A branch, tag or file name that starts with a command name
|
||||
|
||||
The Core Rule Set's list of commands (932260) holds names such as `docker-`,
|
||||
`python3`, `ansible`, `base64` and `whoami`. A branch, tag or file name that
|
||||
starts with one is refused. So is one that has such a name right after a `/`,
|
||||
such as `feat/docker-support`, `fix/docker-build` or
|
||||
`python3`, `ansible`, `base64` and `whoami`. A query parameter holding a branch,
|
||||
tag or file name that starts with one is refused. So is one that has such a name
|
||||
right after a `/`, such as `feat/docker-support`, `fix/docker-build` or
|
||||
`renovate/docker-build-push-action-6.x`. gitea's own pages send the name in a
|
||||
query parameter that 930120, 932160 and 932260 still check:
|
||||
|
||||
- `refSubUrl`, the branch or tag of a directory listing. It is sent when the
|
||||
listing asks for its entries' last commits in a second request, which it does
|
||||
whenever gitea takes more than a second to work them out;
|
||||
- `refSubUrl`, the branch or tag of a directory listing. A listing sends it when
|
||||
it asks for its entries' last commits in a second request. It does so whenever
|
||||
gitea takes more than a second to work them out;
|
||||
- `name`, when a branch is deleted or restored on the branches page;
|
||||
- `tag`, when a release is started from a tag;
|
||||
- `template`, the file of an issue template, when an issue is opened from it;
|
||||
@@ -110,9 +110,9 @@ What a visitor sees, for a branch named `docker-build`:
|
||||
### A file name in a query parameter that trips the code rules
|
||||
|
||||
The Core Rule Set's rules for Java, script, PowerShell and SQL find what they
|
||||
look for in some file names. This holds in the parameters `smallwebwaf` leaves
|
||||
out of 930120, 932160 and 932260 too (see the changes to the Core Rule Set under
|
||||
"Attack detection" in `SPEC.md`). Examples:
|
||||
look for in some file names. This holds too in the ten query parameters that
|
||||
930120, 932160 and 932260 do not check (see the fourth change under "Attack
|
||||
detection" in `SPEC.md`). Examples:
|
||||
|
||||
- `runtime` in OpenJDK's `src/java.base/share/classes/java/lang/Runtime.java`
|
||||
(944110);
|
||||
@@ -129,9 +129,9 @@ For such a file, these requests are refused:
|
||||
- the API's call for the contents of files that names it in its query (`body`);
|
||||
- the commit graph of that file alone (`file`);
|
||||
- the hidden lines of its diff (`path`), which stay hidden;
|
||||
- the sign-in page gitea sends a visitor to on the way to a page of the file
|
||||
that needs signing in, such as its edit page (`redirect_to`). The visitor can
|
||||
still sign in by opening the sign-in page itself.
|
||||
- the sign-in page, for a visitor who opens a page of the file that needs
|
||||
signing in, such as its edit page (`redirect_to`). The visitor can still sign
|
||||
in by opening the sign-in page itself.
|
||||
|
||||
### A path
|
||||
|
||||
@@ -143,25 +143,30 @@ For such a file, these requests are refused:
|
||||
`process.env.d.ts` does.
|
||||
- A path with a directory named `...` (930100, 930110), such as `.../notes.txt`.
|
||||
|
||||
For such a file, the page of the file, its raw file, its history, its blame and
|
||||
its edit page are refused. The directory that holds it lists it, and a clone
|
||||
carries it.
|
||||
For such a file, these are refused:
|
||||
|
||||
- the page of the file;
|
||||
- its raw file;
|
||||
- its history and its blame;
|
||||
- its edit page.
|
||||
|
||||
The directory that holds it lists it, and a clone carries it.
|
||||
|
||||
### A container image push that reuses a layer
|
||||
|
||||
A push can reuse a layer of an image docker has pushed to that gitea before.
|
||||
docker then asks gitea to take the layer from that image, naming it in the query
|
||||
parameter `from`, which 932260 checks. The request is refused when the image's
|
||||
owner starts with an entry on the list of commands. So is one whose name after
|
||||
the owner does. Examples are `owner/python3-app`, `owner/docker-base`, and any
|
||||
image of an owner named `ansible` or `docker-team`.
|
||||
owner starts with an entry on the list of commands. It is refused too when the
|
||||
image's name after the owner starts with one. Examples are `owner/python3-app`,
|
||||
`owner/docker-base`, and any image of an owner named `ansible` or `docker-team`.
|
||||
|
||||
What happens next depends on docker's image store. A docker that keeps its
|
||||
images in containerd's image store fails the push, and fails each new try the
|
||||
same way. A new install of Docker Engine 29 does this. A docker that keeps them
|
||||
in its own older image store uploads the layer instead. The push then succeeds
|
||||
after one refusal for each layer it reuses, and each of those refusals counts
|
||||
toward the error burst.
|
||||
toward the error burst ("Attack detection" in `SPEC.md`).
|
||||
|
||||
### Artifact uploads from `actions/upload-artifact@v3`
|
||||
|
||||
@@ -169,9 +174,9 @@ This action sends the header `Content-Range`, which the Core Rule Set refuses
|
||||
(920450). The action sends two files at a time, does not retry a `403`, and
|
||||
sends no further file once one is refused. So an upload is refused once or
|
||||
twice, however many files it holds, and the step fails. One upload does not ban
|
||||
the runner. More than 30 such refusals from its address within a minute, as when
|
||||
many jobs upload at once, ban it through the error burst.
|
||||
`actions/upload-artifact@v4` does not send the header.
|
||||
the runner. The error burst ("Attack detection" in `SPEC.md`) bans it after more
|
||||
than 30 such refusals from its address in a minute, as when many jobs upload at
|
||||
once. `actions/upload-artifact@v4` does not send the header.
|
||||
|
||||
## What body inspection refuses
|
||||
|
||||
@@ -193,8 +198,7 @@ Of the requests above, the script sends:
|
||||
code rules", the API's list of its commits;
|
||||
- for `Runtime.java` alone, the commit graph of that file, the hidden lines of
|
||||
its diff, and the API's call for the contents of files that names it;
|
||||
- the sign-in page gitea sends a visitor to on the way to the edit page of
|
||||
`document.write.js`;
|
||||
- the sign-in page for a visitor who opens the edit page of `document.write.js`;
|
||||
- the pages of `java.lang.String.md`,
|
||||
`META-INF/services/org.apache.commons.logging.LogFactory`, `process.env.d.ts`
|
||||
and `.../notes.txt`;
|
||||
|
||||
+3
-2
@@ -31,6 +31,7 @@ is pinned that way.
|
||||
- nixpkgs: the release file `nixexprs.tar.xz` of
|
||||
`nixos-26.05.11045.774debe7a0d1` on `releases.nixos.org`, named with its
|
||||
SHA-256 hash.
|
||||
- `runsvinit`: the archived repository, unchanged since 2015, at tag `v2.0.0`,
|
||||
by commit hash, with a `go.mod` the build writes.
|
||||
- `runsvinit`: the archived repository, unchanged since 2015, at its last commit
|
||||
(`b4b2c78`, eight commits after `v2.0.0`), by commit hash, with a `go.mod` the
|
||||
build writes.
|
||||
- The Go image that builds `smallwebwaf`: by digest.
|
||||
+3
-5
@@ -1,10 +1,8 @@
|
||||
# The metrics
|
||||
|
||||
`GET /_smallwebwaf/metrics` answers in the Prometheus text format. The
|
||||
requirements are under "Metrics endpoint" in [`SPEC.md`](../SPEC.md); this file
|
||||
lists the metrics. Every metric carries the label `instance`, set to
|
||||
`SWWAF_INSTANCE_NAME`; Prometheus keeps it as `exported_instance` unless the
|
||||
scrape sets `honor_labels: true`. No metric carries a client's address.
|
||||
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
|
||||
|
||||
|
||||
+23
-12
@@ -12,9 +12,13 @@ sent to a syslog server. A field that does not apply to a request is left out.
|
||||
|
||||
## Every line
|
||||
|
||||
Every line has `type` (`request`; the process's own messages have `process`),
|
||||
the standard web log fields, `request_id`, `peer_ip`, `client_group`, `asn`,
|
||||
`as_name`, `country`, `action` and `duration_total`.
|
||||
Every line has:
|
||||
|
||||
- `type`: `request` (the process's own messages have `process`);
|
||||
- the standard web log fields;
|
||||
- `request_id`, `peer_ip` and `client_group`;
|
||||
- `asn`, `as_name` and `country`;
|
||||
- `action` and `duration_total`.
|
||||
|
||||
## Standard web log fields
|
||||
|
||||
@@ -28,7 +32,7 @@ the standard web log fields, `request_id`, `peer_ip`, `client_group`, `asn`,
|
||||
## Request detail
|
||||
|
||||
- `request_id`: 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`.
|
||||
26 letters and digits.
|
||||
- `peer_ip`: the TCP peer, normally traefik.
|
||||
- `forwarded_for`: the `X-Forwarded-For` header as received.
|
||||
- `client_group`: the client as rate limiting counts it, an IPv4 address as a
|
||||
@@ -73,15 +77,22 @@ the standard web log fields, `request_id`, `peer_ip`, `client_group`, `asn`,
|
||||
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`: the client's totals as rate limiting counts them, this request
|
||||
included. It is left out when rate limiting did not count the request. A count
|
||||
can have a fraction, since each window is counted in two buckets with the
|
||||
earlier one weighted:
|
||||
included. It is left out when rate limiting did not count the request. It
|
||||
holds:
|
||||
- `minute`, `hour` and `day`: requests;
|
||||
- `minute_bytes`, `hour_bytes` and `day_bytes`: bytes.
|
||||
- `limit_hit`: 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`. A request whose bytes broke a limit was passed on whole, so its
|
||||
`action` stays `forward`.
|
||||
|
||||
A count can have a fraction (see "Counting method" in
|
||||
[`SPEC.md`](../SPEC.md)).
|
||||
|
||||
- `limit_hit`: the limit broken:
|
||||
- `minute`, `hour` or `day` for a rate limit;
|
||||
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
||||
- `error_burst`.
|
||||
|
||||
A request whose bytes broke a limit was passed on whole, so its `action`
|
||||
stays `forward`.
|
||||
|
||||
- `rule_ids`: the rule file rules that matched, in order, up to the one that
|
||||
refused.
|
||||
- `waf_score` and `waf_rule_ids`: the Core Rule Set's score and the rules that
|
||||
@@ -91,7 +102,7 @@ the standard web log fields, `request_id`, `peer_ip`, `client_group`, `asn`,
|
||||
- the CrowdSec decision list's URL;
|
||||
- DNS blocklist zones;
|
||||
- `abuseipdb`.
|
||||
- `offence`: the kind of offence recorded, when one was.
|
||||
- `offence`: `limit`, for a request that broke a limit.
|
||||
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
||||
refused under one: a time, or `permanent`.
|
||||
|
||||
|
||||
+17
-18
@@ -2,8 +2,7 @@
|
||||
|
||||
Every setting, its default and the values it takes, in the groups of "Settings"
|
||||
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
|
||||
that take `off`, the `_FILE` form, and what each group does and why. A setting
|
||||
set to a value its entry does not allow stops the start.
|
||||
that take `off`, the `_FILE` form, and what each group does and why.
|
||||
|
||||
## Forms of values
|
||||
|
||||
@@ -27,8 +26,9 @@ set to a value its entry does not allow stops the start.
|
||||
|
||||
- `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.
|
||||
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `smallwebwaf` listens, an
|
||||
optional host and a port. The host stays empty.
|
||||
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `smallwebwaf` listens: `:` and a
|
||||
port, such as `:9000`. Leave the host out (see "Ports" in
|
||||
[`SPEC.md`](../SPEC.md)).
|
||||
- `SWWAF_INSTANCE_NAME` (default: the container's host name): the `instance`
|
||||
name in every log line, alert and metric, such as `fsn1app1/gitea`. Valid
|
||||
UTF-8. No control character while `SWWAF_ALERT_NTFY_URL` is set. 1 to 48
|
||||
@@ -42,8 +42,8 @@ set to a value its entry does not allow stops the start.
|
||||
client's group, from 32 to 128.
|
||||
- `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
|
||||
number above zero.
|
||||
- `SWWAF_MAX_BANS` (default `5000`): the most bans `smallwebwaf` made that are
|
||||
kept, a whole number above zero.
|
||||
- `SWWAF_MAX_BANS` (default `5000`): how many of its own bans `smallwebwaf`
|
||||
keeps, a whole number above zero.
|
||||
|
||||
## Tokens
|
||||
|
||||
@@ -90,8 +90,9 @@ set to a value its entry does not allow stops the start.
|
||||
|
||||
## Static lists
|
||||
|
||||
- `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients are refused only
|
||||
for a size or time limit or a missing or wrong token, such as your monitoring.
|
||||
- `SWWAF_ALLOW_NETS` (default empty): netblocks you trust, such as your
|
||||
monitoring. Their clients are refused only for a size or time limit or a
|
||||
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 always refused.
|
||||
@@ -150,7 +151,7 @@ set to a value its entry does not allow stops the start.
|
||||
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 country on both lists
|
||||
is refused.
|
||||
stops the start.
|
||||
|
||||
## Biased thresholds
|
||||
|
||||
@@ -164,8 +165,8 @@ set to a value its entry does not allow stops the start.
|
||||
- `SWWAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
||||
lookup cannot place.
|
||||
- `SWWAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
||||
text file of `AS<number>:<percent>` lines, one to a line. A URL that is also
|
||||
in `SWWAF_BLOCKLIST_URLS` is refused.
|
||||
text file with one AS number and percentage on each line, such as
|
||||
`AS64496:50`. A URL that is also in `SWWAF_BLOCKLIST_URLS` is refused.
|
||||
|
||||
## Attack detection
|
||||
|
||||
@@ -260,8 +261,8 @@ set to a value its entry does not allow stops the start.
|
||||
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
||||
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
||||
but `summary`. 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_COOLDOWN` (default `15m`): how long after an alert is sent its
|
||||
repeats are held back. A duration or `off`.
|
||||
- `SWWAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
|
||||
whole number above zero, or `off`.
|
||||
|
||||
@@ -294,10 +295,8 @@ as `REQUESTS_PER_MINUTE`:
|
||||
|
||||
## Settings given as files
|
||||
|
||||
Add `_FILE` to a setting's name to read its value from a file:
|
||||
`SWWAF_METRICS_TOKEN_FILE` names the file that holds `SWWAF_METRICS_TOKEN`. To
|
||||
keep a token from the app, make the file on the host, owned by uid 65532 with
|
||||
mode `0400`:
|
||||
To give a token as a file, as "Tokens" under "Deployment" in
|
||||
[`SPEC.md`](../SPEC.md) describes, make the file on the host:
|
||||
|
||||
```sh
|
||||
mkdir -p /srv/app/tokens
|
||||
@@ -306,7 +305,7 @@ chown 65532:65532 /srv/app/tokens/metrics
|
||||
chmod 0400 /srv/app/tokens/metrics
|
||||
```
|
||||
|
||||
Mount its directory into the container read-only, and name the file:
|
||||
Then mount its directory into the container read-only, and name the file:
|
||||
|
||||
```sh
|
||||
docker run \
|
||||
|
||||
+19
-15
@@ -15,8 +15,8 @@ those `smallwebwaf` made. Per entry:
|
||||
- `lifted`: when an admin lifted it, if one did;
|
||||
- `notes`: the notes below.
|
||||
|
||||
To ban a netblock, add an entry with its `netblock`, `start` and `expires`.
|
||||
`reason`, `notes` and `cause` may be left out; the cause is then `admin`:
|
||||
An entry an admin adds needs `netblock`, `start` and `expires`; `reason`,
|
||||
`notes` and `cause` may be left out, and the cause is then `admin`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -32,20 +32,23 @@ To ban a netblock, add an entry with its `netblock`, `start` and `expires`.
|
||||
}
|
||||
```
|
||||
|
||||
To lift a ban, add `"lifted"` with the time you lift it, such as
|
||||
`"lifted": "2026-10-06T13:00:00Z"`. To keep a ban `smallwebwaf` made, set its
|
||||
`cause` to `admin`. To forget a ban, delete its entry.
|
||||
|
||||
The notes:
|
||||
"Bans" in [`SPEC.md`](../SPEC.md) says how an edit lifts, keeps or forgets a
|
||||
ban. The notes:
|
||||
|
||||
- `asn`, `as_name` and `country`: the client's, added when the lookup answers
|
||||
(empty when lookups are off);
|
||||
- what was broken: the rule id and target that matched, or the trap path, or the
|
||||
limit, its window and the count reached;
|
||||
- the client's percentage of the limit, with the setting that gave it;
|
||||
- the reputation sources the client is on;
|
||||
- `request`: the request that caused the ban: time, method, host, path with its
|
||||
query string, status and user agent, each text cut to 256 bytes;
|
||||
- for a broken limit, `kind` (`requests`, `bytes` or `refusals`), `limit`,
|
||||
`window` and `count`: what the limit was on, the limit, its window and the
|
||||
count reached;
|
||||
- `limit_percent` and `limit_percent_setting`: the client's percentage of that
|
||||
limit and the setting that gave it, when a setting lowered it;
|
||||
- for a clear sign of attack, `rule_id` and `target`, or `trap_path`;
|
||||
- `reputation`: the sources the client was on, each with its `source` and, for
|
||||
AbuseIPDB, its `score`;
|
||||
- `request`: the request that caused the ban, each text cut to 256 bytes:
|
||||
- `time`, `method` and `host`;
|
||||
- `path`, with its query string;
|
||||
- `status` and `user_agent`;
|
||||
- `requests` and `refused`: the netblock's requests since it was first seen, and
|
||||
how many this ban has refused so far, kept up to date while the ban lasts;
|
||||
- `earlier_bans`: how many earlier bans the netblock had, by cause.
|
||||
@@ -93,8 +96,9 @@ GeoJS answers, one per line:
|
||||
`SWWAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
|
||||
disk writes a day.
|
||||
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
|
||||
the default `SWWAF_MAX_BANS`. With a full file, a hundred new bans a day come
|
||||
to about 600 MiB of writes.
|
||||
the default `SWWAF_MAX_BANS`. Written every 15 minutes, a full file comes to
|
||||
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
|
||||
more.
|
||||
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
|
||||
full.
|
||||
- `reputation.json` and `alerts.json`: usually a few MiB or less.
|
||||
Reference in new issue
Block a user