Files
smallwebwaf/SPEC.md
T
sneak 43c63faa04 SPEC and README: owner rulings, issues 2 to 5, internet-ready defaults (closes #6)
Rewrite `SPEC.md` and `README.md` to the owner's rulings. The seven answers
to the spec's questions now stand where their topics live, and the questions
section is gone. Bans follow the owner's model: seven days for a clear sign of
attack and permanent on any further request, an hour for a broken limit,
tripled on a repeat within a day, permanent past seven days. The state files
hold all state, are watched, and take in an admin's edits while running. Every
ban carries notes, and each client's history survives a restart. Size and time
limits apply in both directions. Only `UPSTREAM_URL` is required: the Core Rule
Set refuses what it flags and every limit has a default.

Model: opus-5-5
2026-09-23 12:46:05 +00:00

910 lines
52 KiB
Markdown

# smallwebwaf SPEC (draft): protective reverse-proxy sidecar
Status: third draft, with the owner's rulings to date applied. Nothing has been
built yet. `EVALUATION.md` beside this file explains why no existing tool was
chosen.
## Purpose
One small container that sits between traefik and one application container. The
traefik router for the public hostname points at the sidecar; the sidecar
forwards to the application. The sidecar limits request and byte rates per
client, bounds the size and duration of every request and response, detects
attacks, bans abusers (briefly at first, for seven days on a clear sign of
attack, permanently when they keep at it), consults IP reputation sources, looks
up the AS number and country of each client, lowers its limits for listed AS
numbers and countries, and sends alerts.
It is meant to protect an app on the open internet from the first request with
one setting, `UPSTREAM_URL`: every other setting has a default chosen for a
service facing the internet in 2026. Everything is configured by environment
variables, apart from the attack detection rules, which are read from a
directory of hand-editable text files.
## Non-goals
- TLS termination, certificates, hostname routing: traefik's job.
- More than one upstream application per sidecar. Run one sidecar per app.
- Browser challenges (captcha, proof of work). If wanted, chain Anubis between
the sidecar and the app.
- Defence against traffic floods that saturate the host's network link. That
needs help upstream of the host.
- A web UI or a configuration file. Settings are environment variables. Apart
from its own state files and the lookup database, the only files read are the
rule files, which hold one regex per line and nothing more elaborate.
- Sharing bans between sidecars in the first version: each sidecar keeps its
own. Running one CrowdSec engine per host, which every sidecar would report
its bans to, is reconsidered once real ban volumes are known. Reading a
CrowdSec decision list is supported (see Reputation).
## Architecture
- One statically linked Go binary in one container, running as a non-root user,
read-only root filesystem, one writable volume for state. The image declares
that volume (`/data`), so docker supplies an anonymous one when none is
mounted, and a sidecar started with only `UPSTREAM_URL` has somewhere to
write.
- Three listeners, of which only the first is routed by traefik:
- the proxy listener;
- a metrics listener serving Prometheus metrics, which can be switched off;
- an admin listener for health and ban management.
- Components inside the process:
- Client identification: works out the real client IP from
`X-Forwarded-For`, trusting only the proxy netblocks in `TRUSTED_PROXIES`,
by default the private address ranges.
- Lookup: AS number and country from one database file the operator
supplies, held in memory.
- Reputation: static blocklists fetched on a schedule; DNSBL and reputation
API queries made in the background and cached.
- Counters: per-client request and byte counts per minute, hour and day,
held in memory and written out with the rest of the state, so they survive
a restart.
- Attack detection: regex rules read from a directory of plain text rule
files and read again whenever the files change (see "Rule files"), Coraza
with the OWASP Core Rule Set, and simple signals (requests for listed trap
paths, bursts of error responses).
- Ban ledger: active and past bans with their notes, and each client's
history, held in memory and kept in JSON files that the sidecar watches
for an admin's edits (see "Bans" and "Persistent state"). No database of
any kind is used.
- Request log: one JSON object per request on stdout, optionally also sent
to a remote syslog server (see "Request log").
- Metrics: Prometheus counters and gauges on their own listener (see
"Metrics endpoint").
- Alerting: a queue with de-duplication feeding webhook, Slack and ntfy
senders.
- Proxy: the standard library's `net/http/httputil.ReverseProxy`, streaming
in both directions within the size and time limits, with WebSocket upgrade
support.
- Proposed libraries (to be checked against the owner's Go dependency defaults
before any are added):
- standard library for the proxy, HTTP clients, DNS, logging (`log/slog`),
state files (`encoding/json`, `os.Rename`);
- `github.com/corazawaf/coraza/v3` and
`github.com/corazawaf/coraza-coreruleset` for attack detection;
- `github.com/oschwald/maxminddb-golang/v2`, the current major version, to
read the lookup database;
- `github.com/fsnotify/fsnotify` to notice files that are edited or added
while the sidecar runs;
- `github.com/prometheus/client_golang` for metrics;
- remote log sending is written in this project: the standard library's
`log/syslog` is frozen and writes only the older syslog format, not the
current one (RFC 5424). RELP is not in the first release; it is revisited
against the available packages afterwards.
## Data flow for one request
Steps run in this order; the first step that produces a final answer ends
processing. The size and time limits (see "Configuration surface") apply to the
whole exchange.
- Identify the client.
- If the TCP peer is inside `TRUSTED_PROXIES`, walk `X-Forwarded-For` from
the right and take the first address not inside `TRUSTED_PROXIES`.
Otherwise use the TCP peer address and ignore the header.
- IPv6 clients are grouped by prefix (`IPV6_GROUP_PREFIX`, default 64) for
counting and banning, because one abuser usually controls a whole /64. A
client is therefore one IPv4 address or one IPv6 /64.
- Static lists.
- In `ALLOW_NETS`: skip every check below and forward. Still counted for
anomaly alerts.
- In `DENY_NETS`: refuse.
- Ban ledger. An active ban on the client's netblock: refuse with
`BAN_RESPONSE`. If a clear sign of attack caused the ban, the ban becomes
permanent (see "Bans").
- Lookup AS number and country, when a lookup database is configured. Unknown is
a valid result and changes nothing.
- Reputation.
- Address inside a fetched blocklist: apply `BLOCKLIST_ACTION`.
- Cached DNSBL or reputation API result: apply `REPUTATION_ACTION`.
- No cached result: queue a background query and carry on. A first request
is never delayed by a reputation query.
- Work out the client's limit percentage: the lowest of the percentages that
apply (AS number, country, reputation), or 100 if none applies.
- Request rate limits. Unless the client is in `RATE_LIMIT_EXEMPT_NETS`, check
the minute, hour and day counters against each limit times the percentage. A
request over any of them breaks that limit: it is refused with `BAN_RESPONSE`
and the client is banned (see "Bans").
- Rule files. The request is checked against the rules loaded from `RULES_DIR`,
in file name order then line order. Each rule that matches takes its action:
only log, refuse with 403, or refuse and ban. Matching stops at the first rule
that refuses or bans.
- Core Rule Set inspection of the request (headers, URL, and body up to
`WAF_BODY_LIMIT`). In `block` mode, the default, a match at or over the
anomaly threshold is refused with 403; in `detect` mode it is only logged and
alerted.
- Forward to `UPSTREAM_URL`, streaming. Add `X-Forwarded-For` and, if enabled,
`X-Client-ASN` and `X-Client-Country` for the app's own logs.
- After the response.
- Add response bytes (and request body bytes) to the byte counters. A client
whose byte total passes a byte limit times the percentage has broken that
limit and is banned. The response in progress is not cut off.
- Count the client's requests answered with 401, 403 or 404, whether the app
gave that answer or the sidecar refused the request after a rule file or
Core Rule Set match. More than `ERROR_BURST_THRESHOLD` of them within a
minute breaks a limit and bans the client.
- Update the client's history and the anomaly counters, and evaluate alert
thresholds.
## Counting method
- Each window (minute, hour, day) uses two adjacent fixed buckets per client,
with the previous bucket weighted by how much of it still overlaps the sliding
window. This costs a few integers per client per window and avoids the burst
at bucket boundaries that a single fixed bucket allows.
- Counters are read and updated in memory; a request never waits on the disk.
They are written to `clients.json` every `STATE_COUNTER_INTERVAL` and at
shutdown and loaded again at start (see "Persistent state"), so a restart does
not hand every client a fresh allowance. Buckets whose time has passed are
discarded on load.
- Memory is bounded by `MAX_TRACKED_CLIENTS`; when full, the least recently seen
client is dropped first, with its history and its past bans. Active bans are
never dropped; one whose client is no longer tracked is dropped when it ends.
## Bans
A ban refuses every request from a netblock, answering with `BAN_RESPONSE`
(`403` by default), until the ban ends; a permanent ban never ends. The netblock
a ban covers is the client's IPv4 address (or the wider IPv4 prefix that
`BAN_SCOPE_V4_PREFIX` sets) or its IPv6 /64, and never more, so a permanent ban
does not reach neighbours who did nothing.
An offence is a request the sidecar holds against the client: one that carries a
clear sign of attack, breaks a limit, or is refused by a rule file or the Core
Rule Set. Offences are counted by kind in the client's history. Two kinds lead
to a ban, each by its own rule:
- A clear sign of attack: a request that matches a rule file rule whose action
is `ban`, or asks for a path in `TRAP_PATHS`. Examples are a probe for a
`.env` file or a `.git` directory, and a known scanner's user agent.
- The first one bans the netblock for `ATTACK_BAN_DURATION` (default `7d`).
- Any further request from the netblock while that ban lasts shows it is
malicious: the ban becomes permanent.
- Once that ban has run out, the netblock is served like any other, but its
next clear sign of attack bans it permanently at once.
- A broken limit: a request over a request limit, a response that takes the
client's byte total past a byte limit, or more error responses than
`ERROR_BURST_THRESHOLD` within a minute.
- The first such ban, or one that comes more than `LIMIT_BAN_REPEAT_WINDOW`
(default `24h`) after the last such ban ended, lasts `LIMIT_BAN_DURATION`
(default `1h`).
- Breaking a limit again within that window makes the new ban three times as
long as the last one: one hour, then 3, 9, 27 and 81 hours.
- A ban that would be longer than `MAX_BAN_DURATION` (default `7d`) is
permanent instead; with the defaults, that is the sixth ban in a row.
A Core Rule Set match refuses only the request it matched, without a ban,
because the Core Rule Set has false positives; a `block` rule does the same.
Those refusals count toward the error burst like any other 403, so a client that
keeps setting them off is banned under the second rule. Requests refused under a
ban are not counted against limits, but they are counted in the client's history
and in the ban's notes.
Every ban carries notes with what an admin needs to decide whether to lift it
(see "Persistent state"). An admin can add or lift a ban at any time by editing
`bans.json`, or through the admin listener when `ADMIN_TOKEN` is set. A ban
lifted before it ends is kept, marked as lifted, and does not count toward a
longer ban; deleting its entry from `bans.json` forgets it entirely.
Clients of listed AS numbers and countries, and clients with a poor reputation,
get lower limits (see "Biased thresholds"), so the same rules ban them after
fewer requests. The result is always a ban, first a temporary one and then, for
repeated abuse, a permanent one.
## Configuration surface
Conventions: lists are comma separated; netblocks are CIDR (a bare address means
/32 or /128); durations use Go syntax plus `d` for days (`90s`, `15m`, `24h`,
`7d`); byte sizes accept `K`, `M`, `G` suffixes.
- Only `UPSTREAM_URL` is required. Every other setting has a default chosen for
a service facing the internet in 2026, or stays off until it is given
something only the operator can supply: an alert destination, an account key,
the lookup database.
- Any limit or threshold can be switched off with the value `off`.
- A list set to an empty value is an empty list, and replaces the default.
- Every variable may instead be given as `NAME_FILE` pointing at a file holding
the value, for secrets and long lists.
- Settings, including `NAME_FILE` files, are read once at start; changing one
means restarting the container. The files the sidecar watches while it runs
are its state files, its rule files and the lookup database.
- Invalid configuration stops the process at start with a message naming the
variable. At start the effective configuration is logged with secrets masked.
The settings, by group:
- Core
- `UPSTREAM_URL` (required): the application, for example
`http://gitea:3000`.
- `LISTEN_ADDR` (default `:8080`): proxy listener.
- `ADMIN_LISTEN_ADDR` (default `127.0.0.1:9090`): admin listener.
- `ADMIN_TOKEN`: bearer token required for the ban management endpoints.
Unset by default, which switches those endpoints off; bans are then
managed by editing `bans.json`.
- `INSTANCE_NAME` (default: the host name in `UPSTREAM_URL`, for example
`gitea`): included in every log line, metric and alert. Set it, for
example to `fsn1app1/gitea`, when several sidecars report to one place.
- `MODE` (default `enforce`): `enforce`, or `observe` to log and alert on
every decision while refusing nothing.
- `TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`, the
private address ranges): netblocks whose `X-Forwarded-For` is believed,
normally where traefik reaches the sidecar from. The header is used only
when the TCP peer is inside this list; otherwise the peer's own address is
the client, so a client that connects directly cannot claim another
address. A list given replaces the default; set but empty, it trusts
nothing.
- `IPV6_GROUP_PREFIX` (default `64`).
- `MAX_TRACKED_CLIENTS` (default `50000`): clients held in memory and in
`clients.json` (see "Persistent state").
- Persistent state
- `STATE_DIR` (default `/data`): the JSON state files. The image declares
`/data` as a volume, so it is writable even when nothing is mounted; a
named volume, as in the examples below, keeps the state in a place that
outlives the container.
- `STATE_WRITE_DELAY` (default `2s`): after a ban changes, wait this long
before writing `bans.json`, so a burst of changes becomes one write.
- `STATE_COUNTER_INTERVAL` (default `60s`): how often `clients.json` and
`reputation.json` are written.
- Logging
- The request log on stdout is always on and has no switch.
- `LOG_LEVEL` (default `info`): for the process's own messages (start-up,
fetch failures, state writes), which are JSON lines on stdout too, marked
`"type":"process"`. It does not filter the request log.
- `LOG_REQUEST_HEADERS` (default
`accept,accept-language,accept-encoding,content-type,origin,range`): extra
request headers to record. `Authorization`, `Cookie` and `Set-Cookie`
values are never logged, only whether they were present.
- `LOG_REMOTE_URL`: when set, every log line is also sent to this endpoint.
Forms: `syslog+udp://host:514`, `syslog+tcp://host:514`,
`syslog+tls://host:6514`.
- `LOG_REMOTE_TLS_CA_FILE`: optional CA certificate for the `+tls` forms.
- `LOG_REMOTE_BUFFER` (default `10000`): lines held in memory while the
endpoint is unreachable; when full the oldest are dropped and counted.
- `LOG_REMOTE_FACILITY` (default `local0`), `LOG_REMOTE_APP_NAME` (default
`INSTANCE_NAME`): syslog header fields.
- Metrics
- `METRICS_ENABLED` (default `true`).
- `METRICS_LISTEN_ADDR` (default `:9100`): its own listener, so it can be
bound to a monitoring network without exposing the admin endpoints.
- `METRICS_PATH` (default `/metrics`).
- `METRICS_TOP_N` (default `50`): how many AS numbers and countries get
their own series; the rest are summed as `other`.
- `METRICS_TOKEN`: optional bearer token; unset means no authentication,
which is the usual arrangement for a scraper on a private network.
- Static lists
- `ALLOW_NETS`: bypass everything (monitoring, the owner's own networks).
- `RATE_LIMIT_EXEMPT_NETS`: bypass request and byte limits only; the error
burst threshold, attack detection and bans still apply.
- `DENY_NETS`: always refused.
- Request rate limits, per client (R1, R2). Breaking one bans the client (see
"Bans"), 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.
- `RATE_LIMIT_PER_MINUTE` (default `1000`), `RATE_LIMIT_PER_HOUR` (default
`10000`), `RATE_LIMIT_PER_DAY` (default `50000`).
- `RATE_LIMIT_EXEMPT_PATHS`: path prefixes not counted (static assets,
health checks).
- Byte limits, per client. A response's bytes are counted when it ends, so every
default sits above the largest response allowed (`CLIENT_RESPONSE_MAX_BYTES`,
5 GB) and no single download breaks one.
- `BYTES_LIMIT_PER_MINUTE` (default `10G`), `BYTES_LIMIT_PER_HOUR` (default
`20G`), `BYTES_LIMIT_PER_DAY` (default `50G`).
- `BYTES_COUNT` (default `both`): `response`, `request` or `both`.
- Size and time limits, per request, in both directions. The client-facing
limits apply between the client and the sidecar, the app-facing ones between
the sidecar and the app. Bodies stream straight through, so a request body
reaches the app while the client is still sending it, and of two matching
limits the lower one acts first.
- `CLIENT_REQUEST_TIMEOUT` (default `60s`): how long a client may take to
send its whole request, headers and body.
- `CLIENT_REQUEST_MAX_BYTES` (default `100M`): the largest request body a
client may send.
- `CLIENT_RESPONSE_TIMEOUT` (default `30m`): how long the sidecar may take
to deliver one response to the client, from the end of the request to the
last byte.
- `CLIENT_RESPONSE_MAX_BYTES` (default `5G`): the largest response body sent
to a client.
- `UPSTREAM_REQUEST_TIMEOUT` (default `60s`): how long the sidecar may take
to connect to the app and send it the whole request.
- `UPSTREAM_REQUEST_MAX_BYTES` (default `100M`): the largest request body
sent to the app.
- `UPSTREAM_RESPONSE_TIMEOUT` (default `30m`): how long the app may take to
send one whole response, from the end of the request to the last byte.
- `UPSTREAM_RESPONSE_MAX_BYTES` (default `5G`): the largest response body
taken from the app.
- When a limit is passed before the response has started, the sidecar
answers itself: `413` for a request body that is too large, `408` for a
client that is too slow, `502` for a response that is too large, `504` for
an app that is too slow. A request that announces a body larger than its
limit is refused before anything reaches the app. Once the response has
started it can only be cut off, and the connection is closed.
- A WebSocket connection leaves these limits behind once it is upgraded: it
stays open until either side closes it.
- Lookup of AS number and country (R7)
- `LOOKUP_DB_PATH`: the IPinfo Lite database in its `.mmdb` form
(`ipinfo_lite.mmdb`), one file that carries both country and AS number;
the sidecar reads its `asn`, `as_name` and `country_code` fields. The
operator downloads it with a free IPinfo account and mounts it read-only.
The sidecar never fetches it and holds no account or token for it.
- Refreshing the file is the operator's business; IPinfo updates it daily.
The sidecar notices when the file is replaced and reads it again; a
replacement it cannot read is ignored, logged and alerted once, and the
file it has stays in use. Mount the directory that holds the file rather
than the file itself: docker does not show a single mounted file being
replaced on the host.
- Unset by default, which means no lookups. A setting that needs them (the
biased thresholds below, `ADD_LOOKUP_HEADERS`, the per-AS-number anomaly
thresholds) given without `LOOKUP_DB_PATH` stops the start with a message
naming both. A configured file that is missing or unreadable at start
stops the start too.
- `ADD_LOOKUP_HEADERS` (default `false`): pass `X-Client-ASN` and
`X-Client-Country` to the app.
- Biased thresholds (R8). The lists are empty by default: they need the lookup
database, which only the operator can supply.
- `ASN_LIMIT_PERCENT`: for example `AS14061:50,AS16276:50,AS45102:25`.
Clients in a listed AS number get that percentage of every request and
byte limit, so the rules in "Bans" ban them after fewer requests than
others. `0` is a zero allowance: the client's first request breaks a limit
and bans it.
- `COUNTRY_LIMIT_PERCENT`: same form with ISO country codes, for example
`CN:25,RU:50`. Each client from a listed country gets that percentage of
the normal per-client limits.
- No budget is shared by a whole country or AS number: one abuser could use
it up and lock out everyone else there, the denial of service this tool
exists to prevent.
- When more than one percentage applies to a client (its AS number, its
country, a reputation hit), the lowest applies.
- `ASN_BYTES_PERCENT`, `COUNTRY_BYTES_PERCENT`: optional overrides applied
to byte limits only, when the byte percentage should differ from the
request percentage.
- `UNKNOWN_LIMIT_PERCENT` (default `100`): for clients the lookup cannot
place.
- `ASN_LIMIT_PERCENT_URL`: optional URL of a text file of `AS:percent`
lines, so one abuse-source list can be shared by every sidecar in the
fleet. It is fetched and refreshed like the blocklists.
- Attack detection (R9)
- `RULES_DIR` (default `/etc/smallwebwaf/rules.d`): directory of rule files,
read at start and again whenever a file in it changes; format under "Rule
files".
- `RULES_ENABLED` (default `true`): `false` skips rule files entirely.
- `WAF_MODE` (default `block`): `off`, `detect` (log and alert only), or
`block`.
- `WAF_PARANOIA_LEVEL` (default `1`), `WAF_ANOMALY_THRESHOLD` (default `5`):
the Core Rule Set's own two tuning values, at the Core Rule Set's own
defaults.
- `WAF_DISABLED_RULES` (default `920420,920440`): rule ids to switch off
when an app trips a false positive. The two in the default refuse requests
by content type and by file extension; in front of a code forge they would
refuse git's clone and push over HTTP and the display of source files such
as `.sh` or `.sql`. A list given replaces the default, so include them in
it.
- `WAF_EXEMPT_PATHS`: path prefixes not inspected.
- `WAF_BODY_LIMIT` (default `128K`): bodies are inspected up to this size
and streamed beyond it without buffering, so large uploads and git pushes
are not held in memory.
- `TRAP_PATHS`: paths the app never serves and only scanners ask for, for
example `/wp-login.php,/xmlrpc.php` in front of gitea. A request for one
is a clear sign of attack. This is the env-var short form of a `path` rule
with the `ban` action, for deployments that mount no rule files.
- `ERROR_BURST_THRESHOLD` (default `30`): responses with status 401, 403 or
404 per client per minute; more than this breaks a limit (see "Bans").
Scanners walking lists of paths cause hundreds a minute; a person rarely
causes more than a few.
- Bans (R5), following the rules under "Bans"
- `ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
attack.
- `LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit.
- `LIMIT_BAN_REPEAT_WINDOW` (default `24h`): breaking a limit again within
this time after a ban for a broken limit ended makes the next ban three
times as long.
- `MAX_BAN_DURATION` (default `7d`): a ban that would be longer is permanent
instead.
- `BAN_RESPONSE` (default `403`): `403`, `429`, or `close` to drop the
connection without an answer. `403` makes a ban easy to recognise when
debugging; `close` tells the client nothing.
- `BAN_SCOPE_V4_PREFIX` (default `32`): widen to for example `24` to ban the
surrounding netblock.
- Reputation (R6). No source is on by default. A DNS blocklist would be told the
address of every visitor. AbuseIPDB and CrowdSec need an account or an engine
of the operator's own. A downloaded list reveals nothing about visitors, but
each sidecar fetches its own copy, and the list most fit to be a default,
Spamhaus DROP, may be fetched at most once a day: a default would break that
on any host that runs several sidecars.
- `BLOCKLIST_URLS`: text files of addresses and netblocks, one per line;
anything after a `;` or `#` on a line is ignored. For example the Spamhaus
DROP list, `https://www.spamhaus.org/drop/drop.txt`; Spamhaus asks that it
be fetched at most once a day and that products using it credit The
Spamhaus Project. Refreshed every `BLOCKLIST_REFRESH` (default `24h`); the
last good copy is kept on failure and across restarts.
- `BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>`, or `log`.
`deny` refuses every request from a listed address, which suits lists of
networks that send nothing legitimate, such as DROP. `limit:<percent>`
gives listed clients that percentage of every limit, so they are banned
after fewer requests; it suits a list of addresses shared with ordinary
visitors, such as Tor exits.
- `DNSBL_ZONES`: for example `dnsbl.dronebl.org`. Zones meant for mail
(lists of residential ranges) will block ordinary visitors and should not
be used; Spamhaus zones need their keyed query service, given as a zone
name containing the key.
- `DNSBL_RESOLVER`: optional resolver address, since public resolvers are
refused by several list operators.
- `ABUSEIPDB_KEY`, `ABUSEIPDB_MIN_SCORE` (default `75`),
`ABUSEIPDB_DAILY_BUDGET` (default `900`; the free tier allows 1000 checks
a day). Only clients that have already committed one offence are queried,
so the budget is spent on suspects.
- `CROWDSEC_LAPI_URL`, `CROWDSEC_LAPI_KEY`: optional. If a CrowdSec engine
exists on the host, pull its decision list on a schedule and treat listed
addresses as banned. This is how a fleet-wide blocklist can arrive without
the sidecar depending on CrowdSec.
- `REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>`, or
`log`, for DNSBL and API hits. Such verdicts are less certain than a
blocklist, so by default a listed client gets a quarter of every limit and
is banned after a quarter of the requests.
- `REPUTATION_CACHE_TTL` (default `24h`), `REPUTATION_TIMEOUT` (default
`2s`).
- Alerting (R3)
- `ALERT_WEBHOOK_URL`: JSON POST; schema below. `ALERT_WEBHOOK_HEADERS`:
optional `Name:value` pairs for authentication.
- `ALERT_SLACK_WEBHOOK_URL`: Slack incoming webhook, formatted message.
- `ALERT_NTFY_URL` (full topic URL), `ALERT_NTFY_TOKEN`: title, priority and
tags set from the event type.
- `ALERT_EVENTS` (default
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure`):
which event types are sent.
- `ALERT_COOLDOWN` (default `15m`): the same event type for the same client
or netblock is not repeated within this time; a count of suppressed
repeats is included in the next one.
- `ALERT_MAX_PER_HOUR` (default `60`): beyond this, alerts are rolled into
one summary per hour so a wide attack cannot flood the channel.
- Anomaly thresholds, alert only, nothing is blocked (R4). None is set by
default, and a threshold left unset sends no alert: these only alert, an alert
needs a destination only the operator can supply, and what counts as unusual
depends on each service's normal traffic, which the metrics show.
- Per client: `ANOMALY_CLIENT_REQUESTS_PER_MINUTE`,
`ANOMALY_CLIENT_REQUESTS_PER_HOUR`, `ANOMALY_CLIENT_BYTES_PER_MINUTE`,
`ANOMALY_CLIENT_BYTES_PER_HOUR`.
- Per surrounding netblock (`ANOMALY_NET_V4_PREFIX` default `24`,
`ANOMALY_NET_V6_PREFIX` default `48`): `ANOMALY_NET_REQUESTS_PER_MINUTE`,
`..._PER_HOUR`, `ANOMALY_NET_BYTES_PER_MINUTE`, `..._PER_HOUR`.
- Per AS number, which needs the lookup database:
`ANOMALY_ASN_REQUESTS_PER_MINUTE`, `..._PER_HOUR`,
`ANOMALY_ASN_BYTES_PER_MINUTE`, `..._PER_HOUR`.
- Whole service: `ANOMALY_TOTAL_REQUESTS_PER_MINUTE`, `..._PER_HOUR`,
`ANOMALY_TOTAL_BYTES_PER_MINUTE`, `..._PER_HOUR`.
- Named netblocks: `WATCH_NETS`, for example
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`, with
`WATCH_REQUESTS_PER_MINUTE`, `..._PER_HOUR`, `WATCH_BYTES_PER_MINUTE`,
`..._PER_HOUR` applied to each named block as a whole.
- Anomaly counting includes clients in `ALLOW_NETS` and
`RATE_LIMIT_EXEMPT_NETS`, since an exempt client misbehaving is worth
knowing about.
## Rule files
A required feature: the sidecar reads every `*.rules` file in `RULES_DIR` and
checks each request against them. The files are plain text meant to be edited by
hand, so that a new scanning pattern seen in the request log can be turned into
a rule in one line. The sidecar watches the directory: a file edited, added or
removed there takes effect while the sidecar runs.
- One rule per line, four fields separated by spaces or tabs; the fourth field
runs to the end of the line:
```
<id> <target> <action> <regex>
```
- Blank lines and lines starting with `#` are ignored.
- `id`: a short name of letters, digits, `-` and `_`, unique across all files.
It appears in the request log, metrics, alerts and ban notes.
- `target`: what the regex is matched against.
- `path`: the URL path as received, before any decoding.
- `query`: the raw query string.
- `uri`: path and query together, both as received and once percent-decoded,
so an encoded probe cannot slip past.
- `method`, `host`, `user_agent`, `referer`.
- `header:<Name>`: any one request header.
- Request bodies are not available to rule files; body inspection is the
Core Rule Set's job.
- `action`:
- `log`: note the match in the request log and do nothing else.
- `block`: refuse the request with 403. The client is not banned for it, but
the refusal counts toward the error burst (see "Bans").
- `ban`: the request is a clear sign of attack. Refuse it and ban the
client's netblock for seven days (`ATTACK_BAN_DURATION`); any further
request during those days, or a later clear sign of attack, makes the ban
permanent (see "Bans"). Use it only for requests no real visitor sends.
- `regex`: Go regular expression syntax (RE2). It has no backreferences or
lookaround, and in exchange matching time is linear in the input, so no rule
can be made to stall the proxy. `(?i)` at the front makes a rule
case-insensitive. A rule matches if the regex matches anywhere in the target;
anchor with `^` and `$` when that is not wanted.
- Files are read in name order (`00-default.rules` before `50-gitea.rules`),
rules in line order.
- Rules are compiled when their file is read and held in memory. At start, a
line that does not parse, a regex that does not compile, or a duplicate id
stops the process with a message naming the file and line. While running, the
same faults leave the rules as they were, including the earlier version of
that file, and the log and one alert name the file and line; once the file is
fixed, it is read again. A missing or empty directory is not an error: the log
says that no rules were loaded.
- The image ships a default file in `RULES_DIR`. Mounting a directory over it
replaces the defaults; mounting single files into it adds to them. Docker does
not show a single mounted file being replaced on the host, which is how many
editors save, so rules meant to be edited while the sidecar runs belong in a
mounted directory, with a copy of the default file if the defaults are to
stay.
- In `MODE=observe` every action is logged as what would have happened and
nothing is refused.
- Clients in `ALLOW_NETS` are not checked.
Example file:
```
# 00-default.rules: probes no real visitor sends
# id target action regex
env-file path ban (?i)/\.env(\.[a-z]+)?$
git-dir path ban ^/\.git/(config|HEAD|index)$
php-shell path ban (?i)/(shell|c99|r57|wso|alfa)\.php$
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|masscan|zgrab|wpscan)\b
path-traversal uri block (\.\./){2,}
empty-agent user_agent log ^$
```
The image ships one default file of this kind. It is kept short and limited to
patterns that are wrong for every app. Anything app-specific belongs in a file
the deployer mounts: a request for `/wp-login.php`, for example, is a clear sign
of attack in front of gitea and an ordinary login in front of WordPress.
```
# 50-gitea.rules: WordPress probes, which gitea never serves
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
```
## Persistent state
All state lives in memory, and the files in `STATE_DIR` hold a copy of all of
it, so an orderly stop loses nothing. No database is used, and nothing is read
from disk while serving a request. The files are meant for people as well: an
admin can read or edit them at any time, and the running sidecar takes the edit
in.
- Files, each holding one kind of state:
- `bans.json`: every active and past ban. Per entry: the netblock, start,
expiry (`null` for permanent), what caused it (`attack`, `limit`, `admin`
or `crowdsec`), a short reason, when it was lifted if an admin lifted it,
and a `notes` field (below).
- `clients.json`: per client, its minute, hour and day counters and its
history since it was first seen: first and last time seen, the AS number,
AS name and country last looked up, total requests and bytes in each
direction, how many requests were forwarded and how many refused,
responses by status class, and offences by kind. Clients that were never
banned are kept too, so every client's history survives a restart;
`GET /clients/<ip>` shows it.
- `reputation.json`: the last good copy of each list fetched from a URL, and
cached DNSBL and reputation API verdicts, each with the time it was
fetched.
- Ban notes. The notes on a ban hold what an admin needs to decide whether to
lift it, drawn from what the sidecar already knows; nothing is looked up to
fill them:
- the AS number, AS name and country (empty without a lookup database);
- what was broken: the rule ids and target that matched, or the limit, its
window, the count reached and the client's limit percentage with what set
it; and any reputation sources that listed the client;
- the requests that caused the ban, up to the last ten: time, method, host,
path, status and user agent;
- how many requests counted toward the ban, and the time span over which
they came;
- the netblock's total requests since it was first seen, and the requests
refused under this ban so far, kept up to date while the ban lasts;
- how many earlier bans of each kind the netblock has had.
- Size: each tracked client takes about 2 KiB in `clients.json`, so at the
default `MAX_TRACKED_CLIENTS` of 50,000 the file can reach about 100 MiB; a
small service usually tracks a few thousand clients and a few MiB. The other
files stay small. Writing a whole file of that size takes about a second, in
the background, so rewriting whole files still needs nothing cleverer.
- Format: indented JSON with a top-level `version` number, entries sorted by
client address, times in RFC 3339 UTC, durations and sizes as plain numbers
with the unit in the field name. The aim is that a person can open `bans.json`
in an editor, find an address, and remove or add an entry.
- Writing:
- serialise from a snapshot taken under the lock, so requests are not held
up while the file is written;
- write to a temporary file in the same directory, sync it, rename it over
the real name, sync the directory. A crash at any point leaves either the
old complete file or the new complete file, never a partial one;
- `bans.json` is written `STATE_WRITE_DELAY` after a change; `clients.json`
and `reputation.json` every `STATE_COUNTER_INTERVAL`; all three on orderly
shutdown (SIGTERM);
- before writing a file, the sidecar checks whether it changed on disk since
the sidecar last read or wrote it; if it did, the sidecar takes that edit
in first (below), so an admin's edit is never overwritten.
- Reading at start:
- Active bans go into an in-memory prefix lookup; 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. Starting with empty state
would silently forgive every repeat offender, and since writes are atomic
a broken file can only come from a hand edit, which the editor should hear
about.
- Edits while running:
- The sidecar watches `STATE_DIR` and 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 wrote.
- An edit that parses is taken in at once: what the file says replaces what
the sidecar held for that file. Removing a ban's entry from `bans.json`
lifts the ban; adding an entry bans. Changes the sidecar made after the
admin opened the file, such as a new ban, are lost when the admin saves
over them; most editors warn when a file changed on disk while it was
open.
- An edit that does not parse does not stop the running sidecar. It keeps
the state it has, renames the edited file to `<name>.bad` (for example
`bans.json.bad`) so the edit is kept, writes the file again from memory,
and logs and alerts once with the file and the position of the error. The
admin fixes the `.bad` file and moves it back.
- `STATE_DIR` not writable at start: the process exits. A write that fails while
running: state stays correct in memory, the failure is logged, counted in
metrics, alerted once per cooldown, and retried at the next write.
- What a hard kill can lose: ban changes from the last `STATE_WRITE_DELAY`, and
counts, history and reputation from the last `STATE_COUNTER_INTERVAL`. Losing
the volume loses ban history, not service.
## Request log
One JSON object per line on stdout for every request, including refused ones.
stdout is always on. When `LOG_REMOTE_URL` is set the same lines are also sent
to the remote endpoint, so a deployment can stop depending on docker's log
handling while `docker logs` keeps working.
- Standard web log fields: `time` (RFC 3339 with milliseconds), `instance`,
`client_ip`, `method`, `scheme`, `host`, `path`, `query`, `protocol`,
`status`, `request_bytes`, `response_bytes`, `referer`, `user_agent`.
- Request detail: `request_id` (generated if traefik did not supply one, and
passed to the app), `peer_ip` (the TCP peer, normally traefik),
`forwarded_for` (the header as received), `client_group` (the /64 or
configured prefix used for counting), `asn`, `as_name`, `country`,
`content_type`, `content_length`, the headers named in `LOG_REQUEST_HEADERS`,
`has_authorization` and `has_cookie` as booleans, `websocket` when the
connection was upgraded.
- Response detail: `response_content_type`, `upstream_status` (differs from
`status` when the sidecar answered itself), `cache_control`, `location` on
redirects, `aborted` when the client went away early.
- Decision: `action` (`forward`, `banned`, `denied`, `rate_limited`,
`rule_blocked`, `waf_blocked`, `too_large`, `timed_out`, `upstream_error`),
`would_action` in `observe` mode, `limit_percent` and which rule set it,
`counts` (the client's minute, hour and day request and byte totals after this
request), `limit_hit` (which window), `rule_ids` (rule file rules that
matched), `waf_rule_ids`, `waf_score`, `reputation` (sources that listed the
client), `offence` when one was recorded, `ban_expires`.
- Timings in milliseconds: `duration_total`, `duration_checks` (everything the
sidecar did before forwarding), `duration_waf`, `duration_upstream_connect`,
`duration_upstream_first_byte`, `duration_upstream_total`.
- Bodies are never logged. Query strings are logged as received; an app that
carries secrets in query strings needs that fixed in the app.
- The process's own messages share the stream as JSON lines with
`"type":"process"`; request lines carry `"type":"request"`.
- Remote sending:
- syslog forms send each line as the message of an RFC 5424 record, with
octet-counted framing on TCP and TLS;
- sending happens on its own goroutine from a bounded buffer
(`LOG_REMOTE_BUFFER`). An unreachable or slow endpoint never delays a
request and never stops stdout; it reconnects with backoff, drops the
oldest lines when the buffer is full, and counts the drops in metrics. UDP
gives no delivery signal at all and is offered only for compatibility.
## Metrics endpoint
Prometheus text format on `METRICS_LISTEN_ADDR` at `METRICS_PATH`, on by
default, switched off with `METRICS_ENABLED=false`. It has its own listener so
it can be reached by a scraper without exposing ban management.
- Traffic: requests and bytes in and out, by status class and `action`; request
duration and upstream duration histograms; requests in flight.
- Limits and bans: limit hits by window and kind (requests or bytes), size and
time limit hits by limit, offences by kind, bans created by cause, permanent
bans, active bans (gauge).
- Attack detection: rule file matches by rule id and action, and the number of
rules loaded; Core Rule Set matches by mode and rule id (label limited to the
rules that actually fired).
- Lookup and reputation: requests and bytes by AS number and by country, limited
to the `METRICS_TOP_N` (default `50`) busiest of each with the rest summed as
`other`, so the label set stays bounded; reputation queries, hits, failures
and remaining daily budget by source; age of each blocklist and lookup
database.
- Housekeeping: tracked clients (gauge), state file writes, write failures, last
successful write time and size per file; files read again after an edit, and
edits set aside because they did not parse; alerts sent, failed and suppressed
by destination; remote log lines sent, dropped and buffer depth; the standard
Go runtime and process metrics.
- No metric carries a client IP address as a label; per-address questions are
answered by the request log and `GET /clients/<ip>`.
## Admin listener
- `GET /healthz`: for the container health check.
- `GET /bans`, `POST /bans` (client or netblock, duration, reason),
`DELETE /bans/<client>`: need `ADMIN_TOKEN`, and are switched off while it is
unset. Editing `bans.json` does the same without a token.
- `GET /clients/<ip>`: current counters, history, lookup result, reputation,
offences, and bans with their notes; for answering "why was this address
refused" and "what has this netblock been doing".
## Alert webhook schema
One JSON object per alert:
- `instance`, `time`, `event` (one of the `ALERT_EVENTS` values)
- `client`, `netblock`, `asn`, `as_name`, `country`
- `reason`: short human-readable sentence
- `detail`: event-specific fields, for example `window`, `count`, `limit`,
`limit_percent`, `rule_ids`, `path`, `ban_expires`, and for a ban its notes
- `suppressed_repeats`: number of identical alerts held back by the cooldown
## Deployment as a sidecar
docker-compose shape, using gitea as the example. The app carries no traefik
labels and publishes no ports; the sidecar carries the labels and points at the
app's hostname. `UPSTREAM_URL` is the only setting it needs:
```yaml
services:
gitea:
image: gitea/gitea:1
# no traefik labels, no published ports
networks: [internal]
gitea-guard:
image: <registry>/<image>:<pinned digest>
read_only: true
user: "65532:65532"
environment:
UPSTREAM_URL: http://gitea:3000
volumes:
- gitea-guard-state:/data
networks: [internal, traefik]
labels:
traefik.enable: "true"
traefik.http.routers.gitea.rule: Host(`git.example.invalid`)
traefik.http.services.gitea.loadbalancer.server.port: "8080"
volumes:
gitea-guard-state:
networks:
internal:
traefik:
external: true
```
- An app deployed by upaas takes the same shape, without the app's part of this
file. upaas deploys the app with no traefik labels; the sidecar runs beside it
from its own docker-compose file, carries the traefik labels, and points
`UPSTREAM_URL` at the app's hostname. The sidecar must be able to reach that
hostname, for example over a docker network the two share.
- The application container leaves the traefik network, so the sidecar cannot be
bypassed.
- Traefik routes only port 8080. The metrics port is reached by the scraper over
a docker network it shares with the sidecar; the admin port stays on loopback
inside the container and is used through `docker exec`.
- The state volume holds the JSON state files, a few MiB for a small service; it
needs no backup beyond whatever the host already does.
- SSH access to gitea does not pass through the sidecar and is not protected by
it.
- Rollout per service: point traefik at the sidecar with only `UPSTREAM_URL`
set. It protects the app from the first request, and nothing needs tuning
first. Afterwards the request log and the ban notes say why each client was
refused. A real visitor refused by mistake is let through with an exclusion
(`WAF_DISABLED_RULES`, `WAF_EXEMPT_PATHS`, `RATE_LIMIT_EXEMPT_PATHS`) or an
exemption (`RATE_LIMIT_EXEMPT_NETS`, `ALLOW_NETS`), and its ban is lifted in
`bans.json`. Additions to consider at any time: an alert destination, a lookup
database with biased thresholds, reputation sources, a remote log endpoint,
and an app-specific rule file.
- Notes specific to gitea:
- A clone is a response, and fits the defaults of 30 minutes and 5 GB for
all but the largest repositories and slowest links. A push is a request:
at the defaults, one larger than 100 MB or taking more than 60 seconds to
upload is cut off, so a gitea that takes large pushes needs
`CLIENT_REQUEST_MAX_BYTES`, `UPSTREAM_REQUEST_MAX_BYTES`,
`CLIENT_REQUEST_TIMEOUT` and `UPSTREAM_REQUEST_TIMEOUT` raised to fit.
`WAF_BODY_LIMIT` keeps pack uploads out of memory.
- The default `WAF_DISABLED_RULES` lets git's clone and push over HTTP, and
the display of source files, through the Core Rule Set. Pages where people
post code (issues, pull requests, the web editor) can still trip other
rules; such a match refuses only that request, and the request log names
the rule in `waf_rule_ids` for `WAF_DISABLED_RULES`.
- Archive download and blame or history pages are what scrapers hammer;
request limits do most of the work there.
## Failure behaviour
- Lookup database: a configured file that is missing or unreadable at start
stops the start. A replacement that cannot be read while running is ignored:
the file already loaded stays in use, the problem is logged, one alert.
- Reputation source down or over quota: no verdict, service continues, one
`source_failure` alert per cooldown.
- Alert destination down: retried with backoff from a bounded queue, oldest
dropped first, drops counted in metrics.
- Upstream down: 502 from the sidecar, not counted as client offences.
- Attack detection engine error on a request: request is forwarded, error logged
and counted.
- Remote log endpoint down: stdout continues, lines are buffered then dropped
oldest first, drops counted in metrics.
- State file write fails while running: memory stays authoritative, logged,
counted, alerted, retried.
- A state file or rule file edited while running that does not parse: the
sidecar keeps running on what it has. The state file is set aside as
`<name>.bad` and written again from memory; the rule file is left as it is.
Logged, one alert.
- In short: the only things that stop the process happen at start: invalid
configuration, a configured lookup database that is missing or unreadable, a
rule file or state file that does not parse, and an unwritable `STATE_DIR`.
Once running, a broken helper or a broken edit never takes the protected
service down.
## Risks the design has to handle
- Forged `X-Forwarded-For`: handled by believing the header only from a peer
inside `TRUSTED_PROXIES` and by walking it from the right. The default trusts
every private address, which in the intended deployment means traefik; where
other containers can reach the sidecar directly, setting `TRUSTED_PROXIES` to
traefik's own network closes that gap.
- Many clients behind one address (mobile carriers, offices, Tor): they share
its limits and its bans. The default limits sit several times above what one
busy person produces, `RATE_LIMIT_EXEMPT_NETS` and `ALLOW_NETS` take known
shared addresses out, and the ban notes show an admin what happened before a
ban is lifted.
- A `ban` rule that matches a real visitor bans for seven days, so the default
rule file keeps to requests no real visitor sends.
- IPv6 address rotation inside a /64: handled by grouping.
- Widely distributed scrapers using thousands of addresses at low rates each:
per-client limits do not see them. The AS number and netblock anomaly alerts
reveal them once their thresholds are set, and a low `ASN_LIMIT_PERCENT` for
that AS number is the response: it lowers the limits of each of its clients.
There is no shared budget for a whole AS number or country, which one abuser
could use up and so lock out everyone else there.
- Core Rule Set false positives against real apps (gitea's editor, API
payloads): a match refuses only that request and bans no one by itself; the
request log names the rule, and exclusions by rule id and path fix it.
- Slow-request attacks: `CLIENT_REQUEST_TIMEOUT` bounds how long a request may
take to arrive, and an idle timeout on the listener closes connections that
send nothing.
- Alert floods: cooldown and hourly cap.
## Build order
- First: proxy with its size and time limits, client identification, static
lists, three-window request limits and the bans they lead to, the ban ledger
and the JSON state files with edits taken in while running, exemptions,
`observe` mode, the full request log on stdout, the metrics endpoint, health.
- Second: rule files, admin endpoints, alerting to all three destinations,
remote log sending.
- Third: AS number and country lookup, biased thresholds, byte limits, anomaly
thresholds.
- Fourth: blocklists, DNSBL, AbuseIPDB, optional CrowdSec decision feed.
- Fifth: attack detection with Coraza and the Core Rule Set, trap paths, error
bursts.
- Each stage is usable on its own; the first three already cover the traffic
problem the fleet has today.