SPEC: false statements fixed, repeats removed, dropped requirements restored
check / check (push) Waiting to run
check / check (push) Waiting to run
The bounds sentence, the blocklist URL sentence, the deny list sentence, the limit action's scope and the observe mode sentence now say what the code does. Each of the twenty-one requirements the fourth review found twice is stated once, with a link from the other place. The request id header, redirect_uri's case, change 4's reason and the live archive's InRelease files are back. The biased thresholds are defined where they are introduced. The docs files apply the fifth README review's findings and stay identical to the README branch. Model: fable-5-1
This commit is contained in:
@@ -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