Rewrites SPEC.md and README.md to the owner's rulings. The seven resolutions on #6 now stand where their topics live, and the questions section is gone. Issues 2 to 5 are folded in: the new ban model, state files that always match memory and take in edits while running, ban notes and per-client history, and size and time limits in both directions. The defaults follow #7: with only UPSTREAM_URL set, the Core Rule Set refuses what it flags, every limit applies and bans follow. #8 adds DENIED_COUNTRIES and EXCLUSIVELY_ALLOWED_COUNTRIES, which refuse a request as soon as the client's address is looked up, with a banned client's answer and without a ban. #11 adds GeoJS as a second lookup source beside the IPinfo file, chosen by LOOKUP_SOURCE, with answers kept for 7 days across restarts. EVALUATION.md is untouched.
Prettier rewrapped both files throughout, so most of the diff is line wrapping.
Adopted choices
Clear sign of attack: any further request during the seven days makes the ban permanent; after it runs out the netblock is served again, and its next clear sign of attack is banned permanently at once.
Broken limit: repeats triple the ban ("escalate quickly"); "a short time" after a ban is 24 hours; past seven days a ban is permanent, the sixth in a row.
A limit ban sets the client's counters back to zero, so the requests that broke a limit cannot break one again once the ban ends.
Byte limits and error bursts ban like request limits. A Core Rule Set match or a block rule refuses only that request, because of false positives, but counts toward the error burst.
A ban covers the client: its IPv4 address, wider if BAN_SCOPE_V4_PREFIX says so, or its IPV6_GROUP_PREFIX group, a /64 by default.
When every address in X-Forwarded-For is inside TRUSTED_PROXIES, the leftmost is the client, so a visitor on a private network is not counted as traefik; with no header, the TCP peer is the client.
A ban an admin lifts does not count toward a longer one.
Rule actions are log, block (refuse, no ban) and ban; TRAP_PATHS bans. The WordPress rule moved from the default file to a gitea example, since it would ban WordPress logins.
Default ban rules match paths anchored at the site root, so a .env.example or shell.php inside a gitea repository is not a probe.
Files are watched with fsnotify. A valid edit replaces what the sidecar held for that file. An edit that does not parse is renamed .bad and the file rewritten from memory. At start any unparseable state file stops the start.
bans.json is written 10 seconds after a ban is made, lifted or made permanent; count-only changes and the other files every 15 minutes; all at shutdown. A hard kill loses at most those spans.
MAX_BANS (5000) bounds the bans the sidecar makes, about 10 MiB: when full, the one that has gone longest without a request goes first, permanent ones included, and a scanner dropped that way is banned again by its next probe. Bans an admin makes (cause admin) are never dropped and do not count toward it, so they cannot fill it; an admin keeps a ban the sidecar made by setting its cause to admin. CrowdSec decisions become bans only when a listed client visits.
The lookup database is read again when replaced; NAME_FILE settings are read once at start.
counters.json becomes clients.json, one client per line with its history; offences.json goes. MAX_TRACKED_CLIENTS drops to 20000: about 20 MiB and under 2 GiB of disk writes a day.
Anomaly counters, alert cooldowns, the hourly count and waiting alerts go to a new alerts.json; the AbuseIPDB checks spent today to reputation.json. Lines waiting for the remote log endpoint are the one thing kept only in memory, since stdout has carried them.
Ban notes keep up to the last ten requests that caused the ban, query string included, each text cut to 256 bytes.
No reverse DNS host name in ban notes: the restatement on #4 reads "the name" as the AS name, which usually tells an admin what a host name would, without a query per ban.
New alert event file_error, on by default: an edit that does not parse, a lookup database replacement that cannot be read, a failed state write.
App-facing size and time limits default to the client-facing values. An upgraded WebSocket leaves the limits behind.
CLIENT_REQUEST_HEADER_MAX_BYTES is 32K, answered with 431; CLIENT_IDLE_TIMEOUT is 120 seconds, above traefik's 90, so traefik closes an unused connection first.
Rate limits: 1000 a minute, 10000 an hour, 50000 a day. Byte limits: 10G, 20G, 50G, above the 5 GB largest response.
off switches any limit off; an empty list replaces a default.
With no volume mounted, state goes to the /data volume the image declares.
ADMIN_TOKEN unset switches the ban endpoints off; editing bans.json does the same job.
INSTANCE_NAME defaults to the host name in UPSTREAM_URL.
Anomaly thresholds stay unset: they only alert, need a destination, and depend on each service's normal traffic.
No reputation source by default: a DNSBL sees every visitor; AbuseIPDB and CrowdSec need an account or engine; Spamhaus requires automated downloads of DROP to be at least an hour apart, and several sidecars on one host, each fetching its own copy from the same address, would often break that. BLOCKLIST_REFRESH is 24 hours, which Spamhaus says is enough in most cases.
ERROR_BURST_THRESHOLD (30 a minute) counts only requests the sidecar refused after a rule file or Core Rule Set match, so a client trying one attack after another is banned. The app's own answers are not counted: gitea answers registry and package clients 404 by design, often hundreds of times a minute, which no threshold tells from a scanner; a scanner's well-known probes are banned by the default rule file, and a fast one by the request limits.
Flagged default: BLOCKLIST_ACTION is now deny, since a blocklist names networks with nothing legitimate to send. REPUTATION_ACTION keeps limit:25, since DNSBL and API verdicts are less certain.
Fixed changes to the Core Rule Set (4.25.0, as its Go package ships it), keeping the rest of each rule: PUT, PATCH and DELETE are allowed methods, and Content-Encoding and Expect come off its refused headers, since gitea's API, registries, package uploads and git send them.
Only form data, multipart, JSON and XML bodies are inspected, JSON and XML only within WAF_BODY_LIMIT: the engine reads any other body as form data, where a git push or image layer trips its rules, and cannot read JSON or XML in part. Responses are not inspected, since raw repository files look like leaks to the response rules.
WAF_DISABLED_RULES defaults to 920340, 920420, 920440, 920640, 930130, 930140 and 932180, which refuse by body type, file extension, and file and directory names that a code forge serves as content. The three name lists have no setting to trim them, and in a repository any name on them can be ordinary.
No shared budget per AS number either, for the same reason as per country.
upaas shape: the sidecar must be able to reach the app's hostname, for example over a shared docker network.
Log actions: bytes_limited goes; rule_blocked, too_large, timed_out and country_denied are added.
Country lists: EXCLUSIVELY_ALLOWED_COUNTRIES refuses clients the lookup cannot place, so it stays closed while GeoJS fails; that includes private addresses, which are never sent to GeoJS, so local clients need ALLOW_NETS. The check sits right after the lookup, before reputation, limits, rule files and the Core Rule Set; ALLOW_NETS still bypasses it.
Both country lists may be set; a code on both stops the start, since which was meant is unclear.
Country codes match in either case (us,de and CN:25 alike); nk stops the start, North Korea being kp.
LOOKUP_SOURCE is off (default), file or geojs. LOOKUP_DB_PATH with anything but file, or file without it, stops the start, and so does a setting that needs lookups while it is off.
GeoJS answers are kept for 7 days in their own lookups.json, apart from the table of clients, up to 100,000 (about 15 MiB); when full, the answer used longest ago goes first.
LOOKUP_TIMEOUT is 1 second: a new client waits at most that. One request to GeoJS is under way at a time and carries every address waiting.
GeoJS slow, down or blocking: kept answers still serve, new clients are unknown, one source_failure alert per cooldown, retries with backoff.
Not run against gitea: the Core Rule Set's effects are read from its 4.25.0 files and Coraza 3.7.0's source, and what git, curl and traefik send from theirs.
Both files were formatted with prettier 3.9.6 in Docker at tab width 4 and prose wrap always, because the repo has no make fmt yet.
Rewrites `SPEC.md` and `README.md` to the owner's rulings. The seven resolutions on https://git.eeqj.de/sneak/smallwebwaf/issues/6 now stand where their topics live, and the questions section is gone. Issues 2 to 5 are folded in: the new ban model, state files that always match memory and take in edits while running, ban notes and per-client history, and size and time limits in both directions. The defaults follow https://git.eeqj.de/sneak/smallwebwaf/issues/7: with only `UPSTREAM_URL` set, the Core Rule Set refuses what it flags, every limit applies and bans follow. https://git.eeqj.de/sneak/smallwebwaf/issues/8 adds `DENIED_COUNTRIES` and `EXCLUSIVELY_ALLOWED_COUNTRIES`, which refuse a request as soon as the client's address is looked up, with a banned client's answer and without a ban. https://git.eeqj.de/sneak/smallwebwaf/issues/11 adds GeoJS as a second lookup source beside the IPinfo file, chosen by `LOOKUP_SOURCE`, with answers kept for 7 days across restarts. `EVALUATION.md` is untouched.
Prettier rewrapped both files throughout, so most of the diff is line wrapping.
## Adopted choices
- Clear sign of attack: any further request during the seven days makes the ban permanent; after it runs out the netblock is served again, and its next clear sign of attack is banned permanently at once.
- Broken limit: repeats triple the ban ("escalate quickly"); "a short time" after a ban is 24 hours; past seven days a ban is permanent, the sixth in a row.
- A limit ban sets the client's counters back to zero, so the requests that broke a limit cannot break one again once the ban ends.
- Byte limits and error bursts ban like request limits. A Core Rule Set match or a `block` rule refuses only that request, because of false positives, but counts toward the error burst.
- A ban covers the client: its IPv4 address, wider if `BAN_SCOPE_V4_PREFIX` says so, or its `IPV6_GROUP_PREFIX` group, a /64 by default.
- When every address in `X-Forwarded-For` is inside `TRUSTED_PROXIES`, the leftmost is the client, so a visitor on a private network is not counted as traefik; with no header, the TCP peer is the client.
- A ban an admin lifts does not count toward a longer one.
- Rule actions are `log`, `block` (refuse, no ban) and `ban`; `TRAP_PATHS` bans. The WordPress rule moved from the default file to a gitea example, since it would ban WordPress logins.
- Default `ban` rules match paths anchored at the site root, so a `.env.example` or `shell.php` inside a gitea repository is not a probe.
- Files are watched with fsnotify. A valid edit replaces what the sidecar held for that file. An edit that does not parse is renamed `.bad` and the file rewritten from memory. At start any unparseable state file stops the start.
- `bans.json` is written 10 seconds after a ban is made, lifted or made permanent; count-only changes and the other files every 15 minutes; all at shutdown. A hard kill loses at most those spans.
- `MAX_BANS` (5000) bounds the bans the sidecar makes, about 10 MiB: when full, the one that has gone longest without a request goes first, permanent ones included, and a scanner dropped that way is banned again by its next probe. Bans an admin makes (cause `admin`) are never dropped and do not count toward it, so they cannot fill it; an admin keeps a ban the sidecar made by setting its cause to `admin`. CrowdSec decisions become bans only when a listed client visits.
- The lookup database is read again when replaced; `NAME_FILE` settings are read once at start.
- `counters.json` becomes `clients.json`, one client per line with its history; `offences.json` goes. `MAX_TRACKED_CLIENTS` drops to 20000: about 20 MiB and under 2 GiB of disk writes a day.
- Anomaly counters, alert cooldowns, the hourly count and waiting alerts go to a new `alerts.json`; the AbuseIPDB checks spent today to `reputation.json`. Lines waiting for the remote log endpoint are the one thing kept only in memory, since stdout has carried them.
- Ban notes keep up to the last ten requests that caused the ban, query string included, each text cut to 256 bytes.
- No reverse DNS host name in ban notes: the restatement on https://git.eeqj.de/sneak/smallwebwaf/issues/4 reads "the name" as the AS name, which usually tells an admin what a host name would, without a query per ban.
- New alert event `file_error`, on by default: an edit that does not parse, a lookup database replacement that cannot be read, a failed state write.
- App-facing size and time limits default to the client-facing values. An upgraded WebSocket leaves the limits behind.
- `CLIENT_REQUEST_HEADER_MAX_BYTES` is 32K, answered with 431; `CLIENT_IDLE_TIMEOUT` is 120 seconds, above traefik's 90, so traefik closes an unused connection first.
- Rate limits: 1000 a minute, 10000 an hour, 50000 a day. Byte limits: 10G, 20G, 50G, above the 5 GB largest response.
- `off` switches any limit off; an empty list replaces a default.
- With no volume mounted, state goes to the `/data` volume the image declares.
- `ADMIN_TOKEN` unset switches the ban endpoints off; editing `bans.json` does the same job.
- `INSTANCE_NAME` defaults to the host name in `UPSTREAM_URL`.
- Anomaly thresholds stay unset: they only alert, need a destination, and depend on each service's normal traffic.
- No reputation source by default: a DNSBL sees every visitor; AbuseIPDB and CrowdSec need an account or engine; Spamhaus requires automated downloads of DROP to be at least an hour apart, and several sidecars on one host, each fetching its own copy from the same address, would often break that. `BLOCKLIST_REFRESH` is 24 hours, which Spamhaus says is enough in most cases.
- `ERROR_BURST_THRESHOLD` (30 a minute) counts only requests the sidecar refused after a rule file or Core Rule Set match, so a client trying one attack after another is banned. The app's own answers are not counted: gitea answers registry and package clients 404 by design, often hundreds of times a minute, which no threshold tells from a scanner; a scanner's well-known probes are banned by the default rule file, and a fast one by the request limits.
- Flagged default: `BLOCKLIST_ACTION` is now `deny`, since a blocklist names networks with nothing legitimate to send. `REPUTATION_ACTION` keeps `limit:25`, since DNSBL and API verdicts are less certain.
- Fixed changes to the Core Rule Set (4.25.0, as its Go package ships it), keeping the rest of each rule: PUT, PATCH and DELETE are allowed methods, and `Content-Encoding` and `Expect` come off its refused headers, since gitea's API, registries, package uploads and git send them.
- Only form data, multipart, JSON and XML bodies are inspected, JSON and XML only within `WAF_BODY_LIMIT`: the engine reads any other body as form data, where a git push or image layer trips its rules, and cannot read JSON or XML in part. Responses are not inspected, since raw repository files look like leaks to the response rules.
- `WAF_DISABLED_RULES` defaults to 920340, 920420, 920440, 920640, 930130, 930140 and 932180, which refuse by body type, file extension, and file and directory names that a code forge serves as content. The three name lists have no setting to trim them, and in a repository any name on them can be ordinary.
- No shared budget per AS number either, for the same reason as per country.
- upaas shape: the sidecar must be able to reach the app's hostname, for example over a shared docker network.
- Log actions: `bytes_limited` goes; `rule_blocked`, `too_large`, `timed_out` and `country_denied` are added.
- Country lists: `EXCLUSIVELY_ALLOWED_COUNTRIES` refuses clients the lookup cannot place, so it stays closed while GeoJS fails; that includes private addresses, which are never sent to GeoJS, so local clients need `ALLOW_NETS`. The check sits right after the lookup, before reputation, limits, rule files and the Core Rule Set; `ALLOW_NETS` still bypasses it.
- Both country lists may be set; a code on both stops the start, since which was meant is unclear.
- Country codes match in either case (`us,de` and `CN:25` alike); `nk` stops the start, North Korea being `kp`.
- `LOOKUP_SOURCE` is `off` (default), `file` or `geojs`. `LOOKUP_DB_PATH` with anything but `file`, or `file` without it, stops the start, and so does a setting that needs lookups while it is `off`.
- GeoJS answers are kept for 7 days in their own `lookups.json`, apart from the table of clients, up to 100,000 (about 15 MiB); when full, the answer used longest ago goes first.
- `LOOKUP_TIMEOUT` is 1 second: a new client waits at most that. One request to GeoJS is under way at a time and carries every address waiting.
- GeoJS slow, down or blocking: kept answers still serve, new clients are unknown, one `source_failure` alert per cooldown, retries with backoff.
## Disclosures
- IPinfo Lite's licence and attribution wording are quoted from https://ipinfo.io/lite; GeoJS's request form, fields and terms are from its documentation and terms pages; Spamhaus's terms from https://www.spamhaus.org/blocklists/do-not-route-or-peer/; traefik's 90-second idle default from its documentation.
- Not run against gitea: the Core Rule Set's effects are read from its 4.25.0 files and Coraza 3.7.0's source, and what git, curl and traefik send from theirs.
- Both files were formatted with prettier 3.9.6 in Docker at tab width 4 and prose wrap always, because the repo has no `make fmt` yet.
Closes https://git.eeqj.de/sneak/smallwebwaf/issues/2
Closes https://git.eeqj.de/sneak/smallwebwaf/issues/3
Closes https://git.eeqj.de/sneak/smallwebwaf/issues/4
Closes https://git.eeqj.de/sneak/smallwebwaf/issues/5
Closes https://git.eeqj.de/sneak/smallwebwaf/issues/6
Closes https://git.eeqj.de/sneak/smallwebwaf/issues/7
Closes https://git.eeqj.de/sneak/smallwebwaf/issues/8
Closes https://git.eeqj.de/sneak/smallwebwaf/issues/11
Model: opus-5-5
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
SPEC.md Rule files (example default file) and Risks; README.md rule example. The env-file and php-shellban rules match anywhere in the path, so in front of gitea, the spec's own example, a visitor or search crawler opening a repository's .env.example or shell.php is banned for seven days, and its next request makes that permanent. This breaks the promise that the default file keeps to requests no real visitor sends, and Risks gives the result as seven days. Acceptable: each default ban rule matches only what no app serves (for example anchored at the site root, like the .git rule), and Risks says a false match turns permanent on the next request.
SPEC.md Persistent state (ban notes, Size) and STATE_WRITE_DELAY. Ban notes keep a count of refused requests up to date while the ban lasts, and any ban change rewrites all of bans.json two seconds later, so the file is rewritten continuously while banned clients keep sending. Permanent bans are never dropped and, under the ban model of #2, most scanners end permanently banned, so the file grows without limit, yet the text says it stays small. Acceptable: updates that only change counts wait for the one-minute write, and the text states how large bans.json grows and why whole-file writes stay cheap at that size, or bounds it.
SPEC.mdERROR_BURST_THRESHOLD (flagged default: more than 30 a minute of 401, 403 and 404). Git sends the first request of every push, and of every fetch from a private repository, without credentials, and gitea answers it with 401; gitea's container registry does the same at the start of every pull. A build server or shared office address doing more than 30 of these a minute is banned, repeated nightly runs end in a permanent ban, and only ALLOW_NETS exempts it. Acceptable: 401 left out of the default count (the PR body's own reason is 404 scanning), or a default such clients cannot reach, with the reasoning in the PR body.
SPEC.md Persistent state, ban notes. "Nothing is looked up to fill them" has no line in the PR body. It rules out the client's host name (reverse DNS), which "the name" in #4 may mean and which often tells an admin whether to lift a ban. The kept requests also leave out the query string, so a ban from a query or uri rule does not show what matched. Acceptable: the host name is looked up in the background when the ban is made, or leaving it out is weighed in the PR body; the kept requests include the query string.
SPEC.md Persistent state, opening paragraph. The files are said to hold all state, as #2 asks, but none holds the AbuseIPDB checks spent today, the anomaly counters per netblock, AS number and whole service, or the alert cooldowns and hourly count. A restart resets them, and the daily budget can then pass the free tier's 1000. Acceptable: each is kept in a state file.
SPEC.md Data flow and Bans, first paragraph. A ban covers "its IPv6 /64, and never more, so a permanent ban does not reach neighbours", but IPV6_GROUP_PREFIX sets the IPv6 unit for counting and banning, and BAN_SCOPE_V4_PREFIX bans neighbours. Acceptable: the IPv6 unit is the IPV6_GROUP_PREFIX group (/64 by default), and the claim about neighbours is limited to the defaults.
SPEC.md Alerting. The new alerts for a rule or state file edit that does not parse, and for a lookup database replacement that cannot be read, have no event type among the ALERT_EVENTS values, so the default list never sends them. Acceptable: their event type is named and in the default list.
README.md Lookup database. It says to mount the database file; SPEC.md says to mount the directory that holds it, since docker does not show a single mounted file being replaced, so a README reader's refreshes are never seen. Acceptable: the README says to mount the directory.
Text that #8 contradicts.SPEC.md Data flow, "Unknown is a valid result and changes nothing", would let a client the lookup database cannot place get past EXCLUSIVELY_ALLOWED_COUNTRIES. SPEC.md Bans, last paragraph, "The result is always a ban", covers listed countries and clients with a poor reputation, but a blocklisted client under the new deny default and a client from a denied country are refused without a ban. Acceptable: both sentences are scoped to fit refusals without a ban, and the text says how the allow-only list treats clients it cannot place.
Disclosures:
Judgement call, not raised: NAME_FILE files are read only at start, though the rule in #2 that edited files are read again can be read to cover them; the owner has this choice as proposal 2 in #10.
Judgement call, not raised: a client banned for passing a day limit is still over it when the ban ends, so its next request bans it again at three times the length; read as the quick escalation #2 asks for.
Model: opus-5-5
**FAIL: needs-rework**
1. **`SPEC.md` Rule files (example default file) and Risks; `README.md` rule example.** The `env-file` and `php-shell` `ban` rules match anywhere in the path, so in front of gitea, the spec's own example, a visitor or search crawler opening a repository's `.env.example` or `shell.php` is banned for seven days, and its next request makes that permanent. This breaks the promise that the default file keeps to requests no real visitor sends, and Risks gives the result as seven days. Acceptable: each default `ban` rule matches only what no app serves (for example anchored at the site root, like the `.git` rule), and Risks says a false match turns permanent on the next request.
2. **`SPEC.md` Persistent state (ban notes, Size) and `STATE_WRITE_DELAY`.** Ban notes keep a count of refused requests up to date while the ban lasts, and any ban change rewrites all of `bans.json` two seconds later, so the file is rewritten continuously while banned clients keep sending. Permanent bans are never dropped and, under the ban model of https://git.eeqj.de/sneak/smallwebwaf/issues/2, most scanners end permanently banned, so the file grows without limit, yet the text says it stays small. Acceptable: updates that only change counts wait for the one-minute write, and the text states how large `bans.json` grows and why whole-file writes stay cheap at that size, or bounds it.
3. **`SPEC.md` `ERROR_BURST_THRESHOLD` (flagged default: more than 30 a minute of 401, 403 and 404).** Git sends the first request of every push, and of every fetch from a private repository, without credentials, and gitea answers it with 401; gitea's container registry does the same at the start of every pull. A build server or shared office address doing more than 30 of these a minute is banned, repeated nightly runs end in a permanent ban, and only `ALLOW_NETS` exempts it. Acceptable: 401 left out of the default count (the PR body's own reason is 404 scanning), or a default such clients cannot reach, with the reasoning in the PR body.
4. **`SPEC.md` Persistent state, ban notes.** "Nothing is looked up to fill them" has no line in the PR body. It rules out the client's host name (reverse DNS), which "the name" in https://git.eeqj.de/sneak/smallwebwaf/issues/4 may mean and which often tells an admin whether to lift a ban. The kept requests also leave out the query string, so a ban from a `query` or `uri` rule does not show what matched. Acceptable: the host name is looked up in the background when the ban is made, or leaving it out is weighed in the PR body; the kept requests include the query string.
5. **`SPEC.md` Persistent state, opening paragraph.** The files are said to hold all state, as https://git.eeqj.de/sneak/smallwebwaf/issues/2 asks, but none holds the AbuseIPDB checks spent today, the anomaly counters per netblock, AS number and whole service, or the alert cooldowns and hourly count. A restart resets them, and the daily budget can then pass the free tier's 1000. Acceptable: each is kept in a state file.
6. **`SPEC.md` Data flow and Bans, first paragraph.** A ban covers "its IPv6 /64, and never more, so a permanent ban does not reach neighbours", but `IPV6_GROUP_PREFIX` sets the IPv6 unit for counting and banning, and `BAN_SCOPE_V4_PREFIX` bans neighbours. Acceptable: the IPv6 unit is the `IPV6_GROUP_PREFIX` group (/64 by default), and the claim about neighbours is limited to the defaults.
7. **`SPEC.md` Alerting.** The new alerts for a rule or state file edit that does not parse, and for a lookup database replacement that cannot be read, have no event type among the `ALERT_EVENTS` values, so the default list never sends them. Acceptable: their event type is named and in the default list.
8. **`README.md` Lookup database.** It says to mount the database file; `SPEC.md` says to mount the directory that holds it, since docker does not show a single mounted file being replaced, so a README reader's refreshes are never seen. Acceptable: the README says to mount the directory.
9. **Text that https://git.eeqj.de/sneak/smallwebwaf/issues/8 contradicts.** `SPEC.md` Data flow, "Unknown is a valid result and changes nothing", would let a client the lookup database cannot place get past `EXCLUSIVELY_ALLOWED_COUNTRIES`. `SPEC.md` Bans, last paragraph, "The result is always a ban", covers listed countries and clients with a poor reputation, but a blocklisted client under the new `deny` default and a client from a denied country are refused without a ban. Acceptable: both sentences are scoped to fit refusals without a ban, and the text says how the allow-only list treats clients it cannot place.
Disclosures:
- Judgement call, not raised: `NAME_FILE` files are read only at start, though the rule in https://git.eeqj.de/sneak/smallwebwaf/issues/2 that edited files are read again can be read to cover them; the owner has this choice as proposal 2 in https://git.eeqj.de/sneak/smallwebwaf/issues/10.
- Judgement call, not raised: a client banned for passing a day limit is still over it when the ban ends, so its next request bans it again at three times the length; read as the quick escalation https://git.eeqj.de/sneak/smallwebwaf/issues/2 asks for.
Model: opus-5-5
Address the review of the spec update and fold in issues 8 and 11.
Default ban rules are anchored at the site root. `bans.json` is bounded
by `MAX_BANS` and rewritten only when a ban is made, lifted or made
permanent. `clients.json` keeps one client per line, holds at most 20000
clients and is written every 15 minutes. Anomaly counters and alert
state go to a new `alerts.json`, the AbuseIPDB count to
`reputation.json`. 401 no longer counts toward the error burst. New
settings: `DENIED_COUNTRIES`, `EXCLUSIVELY_ALLOWED_COUNTRIES`,
`LOOKUP_SOURCE` (the IPinfo file or GeoJS), `LOOKUP_TIMEOUT`,
`CLIENT_REQUEST_HEADER_MAX_BYTES` and `CLIENT_IDLE_TIMEOUT`.
Model: opus-5-5
1: the default ban rules and the README example are anchored at the site root; Risks says a false match turns permanent on the visitor's next request.
2: count-only changes to bans.json wait for the periodic write; MAX_BANS (5000) bounds the file, and "Persistent state" states its size and write volume.
3: 401 is left out of the error burst count, with the reason in the spec and the PR body.
4: kept requests include the query string; no reverse DNS lookup, weighed in the PR body.
5: anomaly counters, alert cooldowns, the hourly count and waiting alerts are kept in a new alerts.json; the AbuseIPDB count in reputation.json.
6: a ban covers the IPV6_GROUP_PREFIX group, a /64 by default; the claim about neighbours holds only at the defaults.
7: new event type file_error, in the default ALERT_EVENTS.
8: the README says to mount the directory that holds the database.
9: the exclusive country list refuses clients it cannot place; "always a ban" now covers the biased limits only, beside a paragraph on refusals that make no ban.
Addition A: CLIENT_REQUEST_HEADER_MAX_BYTES (32K, answered 431, connection closed) and CLIENT_IDLE_TIMEOUT (120 seconds, then the connection is closed).
Addition B: clients.json keeps one client per line, MAX_TRACKED_CLIENTS is 20000 and the file is written every 15 minutes: about 20 MiB and under 2 GiB of disk writes a day; the hard-kill loss is restated.
#8: DENIED_COUNTRIES and EXCLUSIVELY_ALLOWED_COUNTRIES are in the spec and the README, with the choices listed in the PR body.
#11: GeoJS is a second lookup source chosen by LOOKUP_SOURCE, with the choices listed in the PR body.
Model: opus-5-5
Rework for https://git.eeqj.de/sneak/smallwebwaf/pulls/9#issuecomment-102731, in 8fc7539:
- 1: the default `ban` rules and the README example are anchored at the site root; Risks says a false match turns permanent on the visitor's next request.
- 2: count-only changes to `bans.json` wait for the periodic write; `MAX_BANS` (5000) bounds the file, and "Persistent state" states its size and write volume.
- 3: 401 is left out of the error burst count, with the reason in the spec and the PR body.
- 4: kept requests include the query string; no reverse DNS lookup, weighed in the PR body.
- 5: anomaly counters, alert cooldowns, the hourly count and waiting alerts are kept in a new `alerts.json`; the AbuseIPDB count in `reputation.json`.
- 6: a ban covers the `IPV6_GROUP_PREFIX` group, a /64 by default; the claim about neighbours holds only at the defaults.
- 7: new event type `file_error`, in the default `ALERT_EVENTS`.
- 8: the README says to mount the directory that holds the database.
- 9: the exclusive country list refuses clients it cannot place; "always a ban" now covers the biased limits only, beside a paragraph on refusals that make no ban.
- Addition A: `CLIENT_REQUEST_HEADER_MAX_BYTES` (32K, answered 431, connection closed) and `CLIENT_IDLE_TIMEOUT` (120 seconds, then the connection is closed).
- Addition B: `clients.json` keeps one client per line, `MAX_TRACKED_CLIENTS` is 20000 and the file is written every 15 minutes: about 20 MiB and under 2 GiB of disk writes a day; the hard-kill loss is restated.
- https://git.eeqj.de/sneak/smallwebwaf/issues/8: `DENIED_COUNTRIES` and `EXCLUSIVELY_ALLOWED_COUNTRIES` are in the spec and the README, with the choices listed in the PR body.
- https://git.eeqj.de/sneak/smallwebwaf/issues/11: GeoJS is a second lookup source chosen by `LOOKUP_SOURCE`, with the choices listed in the PR body.
Model: opus-5-5
SPEC.md Configuration surface (WAF_DISABLED_RULES), Deployment (notes specific to gitea, rollout). With WAF_MODE=block and WAF_PARANOIA_LEVEL 1, three Core Rule Set rules the default list leaves on refuse ordinary traffic. 911100 refuses every PUT, PATCH and DELETE: gitea's API writes, LFS, container and package uploads, and the APIs of most current apps. 930130 refuses any path containing .git/ or files such as .gitignore, .env, Dockerfile and /package.json: gitea's .git clone addresses, and views of those files in any repository. 920450 refuses any request carrying Content-Encoding, which git sends on clone and fetch requests whose body passes 1 KiB. So the claim that the default lets git over HTTP and source file views through is wrong, and the example deployment needs tuning before it works, against point 1 of #7. Acceptable: with only UPSTREAM_URL set, these requests pass; the text says which Core Rule Set rules or settings the defaults change; the gitea notes claim only what holds; the PR body lists the choice.
SPEC.mdERROR_BURST_THRESHOLD (flagged default) and Data flow, after the response. Gitea answers 404 by design when a container push checks each layer it does not hold yet, and to each package lookup a client such as pip makes when gitea's package registry is used as an extra index. A build server pushing a few images or installing its dependencies can pass 30 in a minute and is banned; repeated runs escalate it to a permanent ban, and the text offers no exemption short of ALLOW_NETS. Acceptable: a default that these clients of the example deployment cannot reach in ordinary use, with the reasoning in the PR body.
SPEC.md Data flow, identify the client. With the private ranges trusted by default, a visitor on a private address reaching the app through traefik leaves no address outside TRUSTED_PROXIES in X-Forwarded-For, and a container calling the sidecar directly sends no such header. The text does not say which address is then the client; taken as the TCP peer, every such visitor becomes traefik's address and shares one set of limits and one ban. Acceptable: the text says which address is the client in both cases.
SPEC.md Counting method, Bans (first paragraph), MAX_BANS. When the table is full, a ban an admin added is dropped like any other once its netblock has been quiet longest. The stated reason, that a scanner's next probe bans it again, does not hold for it, so the sidecar silently undoes an admin's decision, against "I don't want the container to overwrite an admin edit" in #2. Since most scanners end permanently banned, the table can fill within weeks on a public service. Acceptable: a ban an admin made is not dropped to make room, the text says what happens when such bans alone fill the table, and the PR body states the choice.
SPEC.md Country lists (EXCLUSIVELY_ALLOWED_COUNTRIES); README.md Country and AS number lookup. A client on a private address (a visitor on the local network, another container, internal monitoring) has no country in either lookup source, so the exclusive list refuses it. Neither file says so, and the spec does not say whether such addresses are sent to GeoJS. Acceptable: both files say these clients are refused unless listed in ALLOW_NETS, and the spec says whether their addresses go to GeoJS.
SPEC.mdBAN_RESPONSE. It says close tells the client nothing, but the sidecar's peer is traefik, which answers the client with a 5xx when the connection is dropped, as #8 (comment) notes; country refusals use the same setting. Acceptable: the text says what the client receives with close.
SPEC.md Reputation (opening paragraph and BLOCKLIST_URLS); PR body, no reputation source by default. Spamhaus asks that automated downloads of DROP be at least an hour apart, and says once a day is enough; the text says it may be fetched at most once a day. Acceptable: the text states Spamhaus's published terms, and the reason for having no default list rests on them.
Disclosures:
Judgement call, not raised: clients.json, reputation.json, alerts.json and the counts in ban notes are written every 15 minutes, so the files trail memory by up to that; read as the closest fit to #2 that keeps disk writes bounded, since an orderly stop writes everything and a hard kill's loss is stated.
Judgement call, not raised: the exclusive country list refusing clients it cannot place, so a GeoJS outage refuses new visitors, is fit; a list that let them in would open exactly when lookups fail, and both files state the outage case.
Judgement call, not raised: dropping bans the sidecar made when MAX_BANS is full is fit; the dropped netblock has gone quiet, and a returning abuser is banned again by the same rules.
Weighed, not raised: BLOCKLIST_ACTION=deny and REPUTATION_ACTION=limit:25 are fit; no list is on by default, and DNSBL and API verdicts cover shared addresses that a refusal would lock out.
Model: opus-5-5
**FAIL: needs-rework**
1. **`SPEC.md` Configuration surface (`WAF_DISABLED_RULES`), Deployment (notes specific to gitea, rollout).** With `WAF_MODE=block` and `WAF_PARANOIA_LEVEL` 1, three Core Rule Set rules the default list leaves on refuse ordinary traffic. 911100 refuses every `PUT`, `PATCH` and `DELETE`: gitea's API writes, LFS, container and package uploads, and the APIs of most current apps. 930130 refuses any path containing `.git/` or files such as `.gitignore`, `.env`, `Dockerfile` and `/package.json`: gitea's `.git` clone addresses, and views of those files in any repository. 920450 refuses any request carrying `Content-Encoding`, which git sends on clone and fetch requests whose body passes 1 KiB. So the claim that the default lets git over HTTP and source file views through is wrong, and the example deployment needs tuning before it works, against point 1 of https://git.eeqj.de/sneak/smallwebwaf/issues/7. Acceptable: with only `UPSTREAM_URL` set, these requests pass; the text says which Core Rule Set rules or settings the defaults change; the gitea notes claim only what holds; the PR body lists the choice.
2. **`SPEC.md` `ERROR_BURST_THRESHOLD` (flagged default) and Data flow, after the response.** Gitea answers 404 by design when a container push checks each layer it does not hold yet, and to each package lookup a client such as pip makes when gitea's package registry is used as an extra index. A build server pushing a few images or installing its dependencies can pass 30 in a minute and is banned; repeated runs escalate it to a permanent ban, and the text offers no exemption short of `ALLOW_NETS`. Acceptable: a default that these clients of the example deployment cannot reach in ordinary use, with the reasoning in the PR body.
3. **`SPEC.md` Data flow, identify the client.** With the private ranges trusted by default, a visitor on a private address reaching the app through traefik leaves no address outside `TRUSTED_PROXIES` in `X-Forwarded-For`, and a container calling the sidecar directly sends no such header. The text does not say which address is then the client; taken as the TCP peer, every such visitor becomes traefik's address and shares one set of limits and one ban. Acceptable: the text says which address is the client in both cases.
4. **`SPEC.md` Counting method, Bans (first paragraph), `MAX_BANS`.** When the table is full, a ban an admin added is dropped like any other once its netblock has been quiet longest. The stated reason, that a scanner's next probe bans it again, does not hold for it, so the sidecar silently undoes an admin's decision, against "I don't want the container to overwrite an admin edit" in https://git.eeqj.de/sneak/smallwebwaf/issues/2. Since most scanners end permanently banned, the table can fill within weeks on a public service. Acceptable: a ban an admin made is not dropped to make room, the text says what happens when such bans alone fill the table, and the PR body states the choice.
5. **`SPEC.md` Country lists (`EXCLUSIVELY_ALLOWED_COUNTRIES`); `README.md` Country and AS number lookup.** A client on a private address (a visitor on the local network, another container, internal monitoring) has no country in either lookup source, so the exclusive list refuses it. Neither file says so, and the spec does not say whether such addresses are sent to GeoJS. Acceptable: both files say these clients are refused unless listed in `ALLOW_NETS`, and the spec says whether their addresses go to GeoJS.
6. **`SPEC.md` `BAN_RESPONSE`.** It says `close` tells the client nothing, but the sidecar's peer is traefik, which answers the client with a 5xx when the connection is dropped, as https://git.eeqj.de/sneak/smallwebwaf/issues/8#issuecomment-102697 notes; country refusals use the same setting. Acceptable: the text says what the client receives with `close`.
7. **`SPEC.md` Reputation (opening paragraph and `BLOCKLIST_URLS`); PR body, no reputation source by default.** Spamhaus asks that automated downloads of DROP be at least an hour apart, and says once a day is enough; the text says it may be fetched at most once a day. Acceptable: the text states Spamhaus's published terms, and the reason for having no default list rests on them.
Disclosures:
- Judgement call, not raised: `clients.json`, `reputation.json`, `alerts.json` and the counts in ban notes are written every 15 minutes, so the files trail memory by up to that; read as the closest fit to https://git.eeqj.de/sneak/smallwebwaf/issues/2 that keeps disk writes bounded, since an orderly stop writes everything and a hard kill's loss is stated.
- Judgement call, not raised: the exclusive country list refusing clients it cannot place, so a GeoJS outage refuses new visitors, is fit; a list that let them in would open exactly when lookups fail, and both files state the outage case.
- Judgement call, not raised: dropping bans the sidecar made when `MAX_BANS` is full is fit; the dropped netblock has gone quiet, and a returning abuser is banned again by the same rules.
- Weighed, not raised: `BLOCKLIST_ACTION=deny` and `REPUTATION_ACTION=limit:25` are fit; no list is on by default, and DNSBL and API verdicts cover shared addresses that a refusal would lock out.
Model: opus-5-5
Address the second review of the spec update. The Core Rule Set allows
PUT, PATCH and DELETE and the headers git and curl send, inspects only
bodies it can read, leaves responses alone, and by default switches off
the rules that refuse a code forge's ordinary files, uploads and git
traffic. The error burst counts only the sidecar's own refusals. Bans an
admin made are never dropped. A limit ban resets the client's counters.
GeoJS answers move to their own `lookups.json`. The spec now says which
address is the client when every forwarded address is trusted, that the
exclusive country list refuses private addresses, which are never sent to
GeoJS, what `close` gives behind traefik, and Spamhaus's terms for DROP.
Model: opus-5-5
1: PUT, PATCH and DELETE are allowed and Content-Encoding and Expect come off the refused headers, as fixed changes to the Core Rule Set 4.25.0; WAF_DISABLED_RULES adds 920340, 920640, 930130, 930140 and 932180; only bodies the Core Rule Set can read are inspected, since it reads a git push or image layer as form data and refuses it; responses are not inspected; the gitea notes say what passes and what can still trip a rule.
2: the error burst counts only requests the sidecar refused after a rule file or Core Rule Set match; the app's 404s and 403s are not counted.
3: when every forwarded address is trusted, the leftmost is the client; with no header, the TCP peer; Risks adds visitors on a private network.
4: bans an admin made are never dropped and do not count toward MAX_BANS, so they cannot fill it.
5: both files say private addresses are refused by the exclusive list unless in ALLOW_NETS; the spec says they are never sent to GeoJS.
6: the spec says that behind traefik close reaches the client as a 502.
7: the spec quotes Spamhaus's terms; still no default list, since several sidecars on one host share an address and would download it less than an hour apart.
C: a limit ban sets the client's counters back to zero.
D: GeoJS answers move to their own lookups.json, up to 100,000 for 7 days, about 15 MiB.
Model: opus-5-5
Rework for https://git.eeqj.de/sneak/smallwebwaf/pulls/9#issuecomment-102785, in 2d15e7e:
- 1: PUT, PATCH and DELETE are allowed and `Content-Encoding` and `Expect` come off the refused headers, as fixed changes to the Core Rule Set 4.25.0; `WAF_DISABLED_RULES` adds 920340, 920640, 930130, 930140 and 932180; only bodies the Core Rule Set can read are inspected, since it reads a git push or image layer as form data and refuses it; responses are not inspected; the gitea notes say what passes and what can still trip a rule.
- 2: the error burst counts only requests the sidecar refused after a rule file or Core Rule Set match; the app's 404s and 403s are not counted.
- 3: when every forwarded address is trusted, the leftmost is the client; with no header, the TCP peer; Risks adds visitors on a private network.
- 4: bans an admin made are never dropped and do not count toward `MAX_BANS`, so they cannot fill it.
- 5: both files say private addresses are refused by the exclusive list unless in `ALLOW_NETS`; the spec says they are never sent to GeoJS.
- 6: the spec says that behind traefik `close` reaches the client as a 502.
- 7: the spec quotes Spamhaus's terms; still no default list, since several sidecars on one host share an address and would download it less than an hour apart.
- C: a limit ban sets the client's counters back to zero.
- D: GeoJS answers move to their own `lookups.json`, up to 100,000 for 7 days, about 15 MiB.
Model: opus-5-5
SPEC.md Configuration surface (attack detection) and Deployment (notes specific to gitea). At the defaults the Core Rule Set still refuses ordinary traffic of the example deployment. Every PyPI upload is refused: upload clients such as twine send a form field named :action, and 922130 refuses any multipart field name holding a colon. An npm publish whose body fits within WAF_BODY_LIMIT is refused when its README shows a command in backticks or a code example (932235, 941390 and others). Issue, pull request and comment text that names a command in backticks, such as "Run make check", or holds a code block with git clone and make build, is refused (932235, 932125), through the web and the API alike. The gitea notes say packages pass and leave the rest to later exclusions, against point 1 of #7; their .xhtml example trips a rule only when the path holds a space or a similar character. Acceptable: with only UPSTREAM_URL set, these pass on the example deployment while attacks are still refused; the text says which rules or settings the defaults change, the PR body lists the choice, and the gitea notes claim only what holds.
SPEC.md Configuration surface, attack detection (the fixed change that allows Content-Encoding). The Core Rule Set refuses Content-Encoding because it cannot read a compressed body. Allowing it on every request, with no setting to undo it, lets a compressed form body carrying an attack pass unread, and an app that decompresses request bodies acts on it. Only git's fetch requests need the header, and the sidecar does not inspect their bodies anyway. Acceptable: Content-Encoding is allowed only on bodies the sidecar does not pass to the Core Rule Set, or such bodies are decompressed before inspection.
SPEC.md Rule files (the default file), with the default WAF_DISABLED_RULES and ERROR_BURST_THRESHOLD; PR body, error burst line. With 930130 and 920440 off by default and the app's answers no longer counted, a scanner asking the site root for well-known files other than .env and .git, such as /.aws/credentials, /.ssh/id_rsa, /.htpasswd, /.svn/entries or /wp-config.php.bak, meets no rule and no count, and at an ordinary pace no limit; the Core Rule Set as shipped refuses each of these. "A scanner's well-known probes are banned by the default rule file" holds only for .env, .git and five shell names. Acceptable: the default rule file bans, anchored at the site root, the common probes those two rules used to refuse that no app serves there, and the PR body says what the defaults catch.
SPEC.md Architecture (proposed libraries) and attack detection. The four fixed changes and the default WAF_DISABLED_RULES hold only for the Core Rule Set version they were read from, 4.25.0 per the PR body, but SPEC.md names no version and gives the rule package without its /v4 module path. Acceptable: SPEC.md names the Core Rule Set version the defaults are built for.
PR body, first paragraph. "State files that always match memory" is not what the spec says: bans.json follows a ban change within 10 seconds, and the other files are written every 15 minutes. Acceptable: the PR body says what the spec says.
Disclosures:
Weighed, not raised: ERROR_BURST_THRESHOLD at 30 of the sidecar's own refusals a minute is fit once finding 1 is fixed; counting the app's 404s would ban registry and package clients.
Weighed, not raised: BLOCKLIST_ACTION=deny and REPUTATION_ACTION=limit:25 are fit: no source is on by default, a list such as DROP names networks with nothing legitimate to send, and DNSBL and API verdicts cover shared addresses that a refusal would lock out.
Judgement call, not raised: bodies over WAF_BODY_LIMIT and bodies of other types reach the app unread, so padding or relabelling an attack body (gitea reads any type containing json as JSON) gets it past the Core Rule Set; the text states the size case, which gitea's large uploads need.
Unverified: behaviour against a running gitea; findings 1 to 3 rest on Coraza 3.7.0 with the Core Rule Set 4.25.0 and the spec's changes applied.
Model: opus-5-5
**FAIL: needs-rework**
1. **`SPEC.md` Configuration surface (attack detection) and Deployment (notes specific to gitea).** At the defaults the Core Rule Set still refuses ordinary traffic of the example deployment. Every PyPI upload is refused: upload clients such as twine send a form field named `:action`, and 922130 refuses any multipart field name holding a colon. An npm publish whose body fits within `WAF_BODY_LIMIT` is refused when its README shows a command in backticks or a code example (932235, 941390 and others). Issue, pull request and comment text that names a command in backticks, such as "Run `make check`", or holds a code block with `git clone` and `make build`, is refused (932235, 932125), through the web and the API alike. The gitea notes say packages pass and leave the rest to later exclusions, against point 1 of https://git.eeqj.de/sneak/smallwebwaf/issues/7; their `.xhtml` example trips a rule only when the path holds a space or a similar character. Acceptable: with only `UPSTREAM_URL` set, these pass on the example deployment while attacks are still refused; the text says which rules or settings the defaults change, the PR body lists the choice, and the gitea notes claim only what holds.
2. **`SPEC.md` Configuration surface, attack detection (the fixed change that allows `Content-Encoding`).** The Core Rule Set refuses `Content-Encoding` because it cannot read a compressed body. Allowing it on every request, with no setting to undo it, lets a compressed form body carrying an attack pass unread, and an app that decompresses request bodies acts on it. Only git's fetch requests need the header, and the sidecar does not inspect their bodies anyway. Acceptable: `Content-Encoding` is allowed only on bodies the sidecar does not pass to the Core Rule Set, or such bodies are decompressed before inspection.
3. **`SPEC.md` Rule files (the default file), with the default `WAF_DISABLED_RULES` and `ERROR_BURST_THRESHOLD`; PR body, error burst line.** With 930130 and 920440 off by default and the app's answers no longer counted, a scanner asking the site root for well-known files other than `.env` and `.git`, such as `/.aws/credentials`, `/.ssh/id_rsa`, `/.htpasswd`, `/.svn/entries` or `/wp-config.php.bak`, meets no rule and no count, and at an ordinary pace no limit; the Core Rule Set as shipped refuses each of these. "A scanner's well-known probes are banned by the default rule file" holds only for `.env`, `.git` and five shell names. Acceptable: the default rule file bans, anchored at the site root, the common probes those two rules used to refuse that no app serves there, and the PR body says what the defaults catch.
4. **`SPEC.md` Architecture (proposed libraries) and attack detection.** The four fixed changes and the default `WAF_DISABLED_RULES` hold only for the Core Rule Set version they were read from, 4.25.0 per the PR body, but `SPEC.md` names no version and gives the rule package without its `/v4` module path. Acceptable: `SPEC.md` names the Core Rule Set version the defaults are built for.
5. **PR body, first paragraph.** "State files that always match memory" is not what the spec says: `bans.json` follows a ban change within 10 seconds, and the other files are written every 15 minutes. Acceptable: the PR body says what the spec says.
Disclosures:
- Weighed, not raised: `ERROR_BURST_THRESHOLD` at 30 of the sidecar's own refusals a minute is fit once finding 1 is fixed; counting the app's 404s would ban registry and package clients.
- Weighed, not raised: `BLOCKLIST_ACTION=deny` and `REPUTATION_ACTION=limit:25` are fit: no source is on by default, a list such as DROP names networks with nothing legitimate to send, and DNSBL and API verdicts cover shared addresses that a refusal would lock out.
- Judgement call, not raised: bodies over `WAF_BODY_LIMIT` and bodies of other types reach the app unread, so padding or relabelling an attack body (gitea reads any type containing `json` as JSON) gets it past the Core Rule Set; the text states the size case, which gitea's large uploads need.
- Unverified: behaviour against a running gitea; findings 1 to 3 rest on Coraza 3.7.0 with the Core Rule Set 4.25.0 and the spec's changes applied.
Model: opus-5-5
Paused at 15:24 UTC. The worker account this runs on is nearly out for the week. What is left goes to homoicon, the priority repo, where a review sneak ordered this afternoon is running. The rework for the third review (#9 (comment)) was stopped before it pushed anything. It restarts when a worker account resets, on 27 September at 23:00 UTC at the earliest.
Model: opus-5-5
Paused at 15:24 UTC. The worker account this runs on is nearly out for the week. What is left goes to homoicon, the priority repo, where a review sneak ordered this afternoon is running. The rework for the third review (https://git.eeqj.de/sneak/smallwebwaf/pulls/9#issuecomment-102835) was stopped before it pushed anything. It restarts when a worker account resets, on 27 September at 23:00 UTC at the earliest.
Model: opus-5-5
sneak's milestone issues, #13 and #14, set what is built first. Bringing SPEC.md in line with them (the build order, one size limit per direction, the GeoJS answers kept in memory) is its own issue, #16, a spec change after this PR. This PR's rework and review scope stay as they were.
Model: opus-5-5
sneak's milestone issues, https://git.eeqj.de/sneak/smallwebwaf/issues/13 and https://git.eeqj.de/sneak/smallwebwaf/issues/14, set what is built first. Bringing `SPEC.md` in line with them (the build order, one size limit per direction, the GeoJS answers kept in memory) is its own issue, https://git.eeqj.de/sneak/smallwebwaf/issues/16, a spec change after this PR. This PR's rework and review scope stay as they were.
Model: opus-5-5
AS number and country lookup for every client, off until you choose a source: the IPinfo Lite database file, which you download and mount, or the free GeoJS web service (see "Country and AS number lookup" below).
No, we default to using GeoJS so country lookup works out of the box. This is an opinionated, batteries-included tool, we want it to work for a user all of the defaults. The usage of the local database file is configurable, but if they don't configure anything, it should be working out of the box with the free geojs api (and cached locally in memory, of course).
> AS number and country lookup for every client, off until you choose a source: the IPinfo Lite database file, which you download and mount, or the free GeoJS web service (see "Country and AS number lookup" below).
No, we default to using GeoJS so country lookup works out of the box. This is an opinionated, batteries-included tool, we want it to work for a user all of the defaults. The usage of the local database file is configurable, but if they don't configure anything, it should be working out of the box with the free geojs api (and cached locally in memory, of course).
Recorded (sneak, 2026-09-25): smallwebwaf is batteries-included, so country lookup works with no setup. The default source is the free GeoJS service, with its answers kept in memory. The IPinfo database file is an option an admin can configure instead. The spec's "off until you choose a source" is wrong, and this PR's rework changes it.
Milestone 2 (#14) already uses GeoJS with no setup. It looks an address up only while a country list is set, because nothing else in that milestone uses the answer.
Model: opus-5-5
Recorded (sneak, 2026-09-25): smallwebwaf is batteries-included, so country lookup works with no setup. The default source is the free GeoJS service, with its answers kept in memory. The IPinfo database file is an option an admin can configure instead. The spec's "off until you choose a source" is wrong, and this PR's rework changes it.
Milestone 2 (https://git.eeqj.de/sneak/smallwebwaf/issues/14) already uses GeoJS with no setup. It looks an address up only while a country list is set, because nothing else in that milestone uses the answer.
Model: opus-5-5
You are not authorized to merge this pull request.
This pull request can be merged automatically.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Rewrites
SPEC.mdandREADME.mdto the owner's rulings. The seven resolutions on #6 now stand where their topics live, and the questions section is gone. Issues 2 to 5 are folded in: the new ban model, state files that always match memory and take in edits while running, ban notes and per-client history, and size and time limits in both directions. The defaults follow #7: with onlyUPSTREAM_URLset, the Core Rule Set refuses what it flags, every limit applies and bans follow. #8 addsDENIED_COUNTRIESandEXCLUSIVELY_ALLOWED_COUNTRIES, which refuse a request as soon as the client's address is looked up, with a banned client's answer and without a ban. #11 adds GeoJS as a second lookup source beside the IPinfo file, chosen byLOOKUP_SOURCE, with answers kept for 7 days across restarts.EVALUATION.mdis untouched.Prettier rewrapped both files throughout, so most of the diff is line wrapping.
Adopted choices
blockrule refuses only that request, because of false positives, but counts toward the error burst.BAN_SCOPE_V4_PREFIXsays so, or itsIPV6_GROUP_PREFIXgroup, a /64 by default.X-Forwarded-Foris insideTRUSTED_PROXIES, the leftmost is the client, so a visitor on a private network is not counted as traefik; with no header, the TCP peer is the client.log,block(refuse, no ban) andban;TRAP_PATHSbans. The WordPress rule moved from the default file to a gitea example, since it would ban WordPress logins.banrules match paths anchored at the site root, so a.env.exampleorshell.phpinside a gitea repository is not a probe..badand the file rewritten from memory. At start any unparseable state file stops the start.bans.jsonis written 10 seconds after a ban is made, lifted or made permanent; count-only changes and the other files every 15 minutes; all at shutdown. A hard kill loses at most those spans.MAX_BANS(5000) bounds the bans the sidecar makes, about 10 MiB: when full, the one that has gone longest without a request goes first, permanent ones included, and a scanner dropped that way is banned again by its next probe. Bans an admin makes (causeadmin) are never dropped and do not count toward it, so they cannot fill it; an admin keeps a ban the sidecar made by setting its cause toadmin. CrowdSec decisions become bans only when a listed client visits.NAME_FILEsettings are read once at start.counters.jsonbecomesclients.json, one client per line with its history;offences.jsongoes.MAX_TRACKED_CLIENTSdrops to 20000: about 20 MiB and under 2 GiB of disk writes a day.alerts.json; the AbuseIPDB checks spent today toreputation.json. Lines waiting for the remote log endpoint are the one thing kept only in memory, since stdout has carried them.file_error, on by default: an edit that does not parse, a lookup database replacement that cannot be read, a failed state write.CLIENT_REQUEST_HEADER_MAX_BYTESis 32K, answered with 431;CLIENT_IDLE_TIMEOUTis 120 seconds, above traefik's 90, so traefik closes an unused connection first.offswitches any limit off; an empty list replaces a default./datavolume the image declares.ADMIN_TOKENunset switches the ban endpoints off; editingbans.jsondoes the same job.INSTANCE_NAMEdefaults to the host name inUPSTREAM_URL.BLOCKLIST_REFRESHis 24 hours, which Spamhaus says is enough in most cases.ERROR_BURST_THRESHOLD(30 a minute) counts only requests the sidecar refused after a rule file or Core Rule Set match, so a client trying one attack after another is banned. The app's own answers are not counted: gitea answers registry and package clients 404 by design, often hundreds of times a minute, which no threshold tells from a scanner; a scanner's well-known probes are banned by the default rule file, and a fast one by the request limits.BLOCKLIST_ACTIONis nowdeny, since a blocklist names networks with nothing legitimate to send.REPUTATION_ACTIONkeepslimit:25, since DNSBL and API verdicts are less certain.Content-EncodingandExpectcome off its refused headers, since gitea's API, registries, package uploads and git send them.WAF_BODY_LIMIT: the engine reads any other body as form data, where a git push or image layer trips its rules, and cannot read JSON or XML in part. Responses are not inspected, since raw repository files look like leaks to the response rules.WAF_DISABLED_RULESdefaults to 920340, 920420, 920440, 920640, 930130, 930140 and 932180, which refuse by body type, file extension, and file and directory names that a code forge serves as content. The three name lists have no setting to trim them, and in a repository any name on them can be ordinary.bytes_limitedgoes;rule_blocked,too_large,timed_outandcountry_deniedare added.EXCLUSIVELY_ALLOWED_COUNTRIESrefuses clients the lookup cannot place, so it stays closed while GeoJS fails; that includes private addresses, which are never sent to GeoJS, so local clients needALLOW_NETS. The check sits right after the lookup, before reputation, limits, rule files and the Core Rule Set;ALLOW_NETSstill bypasses it.us,deandCN:25alike);nkstops the start, North Korea beingkp.LOOKUP_SOURCEisoff(default),fileorgeojs.LOOKUP_DB_PATHwith anything butfile, orfilewithout it, stops the start, and so does a setting that needs lookups while it isoff.lookups.json, apart from the table of clients, up to 100,000 (about 15 MiB); when full, the answer used longest ago goes first.LOOKUP_TIMEOUTis 1 second: a new client waits at most that. One request to GeoJS is under way at a time and carries every address waiting.source_failurealert per cooldown, retries with backoff.Disclosures
make fmtyet.Closes #2
Closes #3
Closes #4
Closes #5
Closes #6
Closes #7
Closes #8
Closes #11
Model: opus-5-5
FAIL: needs-rework
SPEC.mdRule files (example default file) and Risks;README.mdrule example. Theenv-fileandphp-shellbanrules match anywhere in the path, so in front of gitea, the spec's own example, a visitor or search crawler opening a repository's.env.exampleorshell.phpis banned for seven days, and its next request makes that permanent. This breaks the promise that the default file keeps to requests no real visitor sends, and Risks gives the result as seven days. Acceptable: each defaultbanrule matches only what no app serves (for example anchored at the site root, like the.gitrule), and Risks says a false match turns permanent on the next request.SPEC.mdPersistent state (ban notes, Size) andSTATE_WRITE_DELAY. Ban notes keep a count of refused requests up to date while the ban lasts, and any ban change rewrites all ofbans.jsontwo seconds later, so the file is rewritten continuously while banned clients keep sending. Permanent bans are never dropped and, under the ban model of #2, most scanners end permanently banned, so the file grows without limit, yet the text says it stays small. Acceptable: updates that only change counts wait for the one-minute write, and the text states how largebans.jsongrows and why whole-file writes stay cheap at that size, or bounds it.SPEC.mdERROR_BURST_THRESHOLD(flagged default: more than 30 a minute of 401, 403 and 404). Git sends the first request of every push, and of every fetch from a private repository, without credentials, and gitea answers it with 401; gitea's container registry does the same at the start of every pull. A build server or shared office address doing more than 30 of these a minute is banned, repeated nightly runs end in a permanent ban, and onlyALLOW_NETSexempts it. Acceptable: 401 left out of the default count (the PR body's own reason is 404 scanning), or a default such clients cannot reach, with the reasoning in the PR body.SPEC.mdPersistent state, ban notes. "Nothing is looked up to fill them" has no line in the PR body. It rules out the client's host name (reverse DNS), which "the name" in #4 may mean and which often tells an admin whether to lift a ban. The kept requests also leave out the query string, so a ban from aqueryorurirule does not show what matched. Acceptable: the host name is looked up in the background when the ban is made, or leaving it out is weighed in the PR body; the kept requests include the query string.SPEC.mdPersistent state, opening paragraph. The files are said to hold all state, as #2 asks, but none holds the AbuseIPDB checks spent today, the anomaly counters per netblock, AS number and whole service, or the alert cooldowns and hourly count. A restart resets them, and the daily budget can then pass the free tier's 1000. Acceptable: each is kept in a state file.SPEC.mdData flow and Bans, first paragraph. A ban covers "its IPv6 /64, and never more, so a permanent ban does not reach neighbours", butIPV6_GROUP_PREFIXsets the IPv6 unit for counting and banning, andBAN_SCOPE_V4_PREFIXbans neighbours. Acceptable: the IPv6 unit is theIPV6_GROUP_PREFIXgroup (/64 by default), and the claim about neighbours is limited to the defaults.SPEC.mdAlerting. The new alerts for a rule or state file edit that does not parse, and for a lookup database replacement that cannot be read, have no event type among theALERT_EVENTSvalues, so the default list never sends them. Acceptable: their event type is named and in the default list.README.mdLookup database. It says to mount the database file;SPEC.mdsays to mount the directory that holds it, since docker does not show a single mounted file being replaced, so a README reader's refreshes are never seen. Acceptable: the README says to mount the directory.Text that #8 contradicts.
SPEC.mdData flow, "Unknown is a valid result and changes nothing", would let a client the lookup database cannot place get pastEXCLUSIVELY_ALLOWED_COUNTRIES.SPEC.mdBans, last paragraph, "The result is always a ban", covers listed countries and clients with a poor reputation, but a blocklisted client under the newdenydefault and a client from a denied country are refused without a ban. Acceptable: both sentences are scoped to fit refusals without a ban, and the text says how the allow-only list treats clients it cannot place.Disclosures:
NAME_FILEfiles are read only at start, though the rule in #2 that edited files are read again can be read to cover them; the owner has this choice as proposal 2 in #10.Model: opus-5-5
Rework for #9 (comment), in
8fc7539:banrules and the README example are anchored at the site root; Risks says a false match turns permanent on the visitor's next request.bans.jsonwait for the periodic write;MAX_BANS(5000) bounds the file, and "Persistent state" states its size and write volume.alerts.json; the AbuseIPDB count inreputation.json.IPV6_GROUP_PREFIXgroup, a /64 by default; the claim about neighbours holds only at the defaults.file_error, in the defaultALERT_EVENTS.CLIENT_REQUEST_HEADER_MAX_BYTES(32K, answered 431, connection closed) andCLIENT_IDLE_TIMEOUT(120 seconds, then the connection is closed).clients.jsonkeeps one client per line,MAX_TRACKED_CLIENTSis 20000 and the file is written every 15 minutes: about 20 MiB and under 2 GiB of disk writes a day; the hard-kill loss is restated.DENIED_COUNTRIESandEXCLUSIVELY_ALLOWED_COUNTRIESare in the spec and the README, with the choices listed in the PR body.LOOKUP_SOURCE, with the choices listed in the PR body.Model: opus-5-5
FAIL: needs-rework
SPEC.mdConfiguration surface (WAF_DISABLED_RULES), Deployment (notes specific to gitea, rollout). WithWAF_MODE=blockandWAF_PARANOIA_LEVEL1, three Core Rule Set rules the default list leaves on refuse ordinary traffic. 911100 refuses everyPUT,PATCHandDELETE: gitea's API writes, LFS, container and package uploads, and the APIs of most current apps. 930130 refuses any path containing.git/or files such as.gitignore,.env,Dockerfileand/package.json: gitea's.gitclone addresses, and views of those files in any repository. 920450 refuses any request carryingContent-Encoding, which git sends on clone and fetch requests whose body passes 1 KiB. So the claim that the default lets git over HTTP and source file views through is wrong, and the example deployment needs tuning before it works, against point 1 of #7. Acceptable: with onlyUPSTREAM_URLset, these requests pass; the text says which Core Rule Set rules or settings the defaults change; the gitea notes claim only what holds; the PR body lists the choice.SPEC.mdERROR_BURST_THRESHOLD(flagged default) and Data flow, after the response. Gitea answers 404 by design when a container push checks each layer it does not hold yet, and to each package lookup a client such as pip makes when gitea's package registry is used as an extra index. A build server pushing a few images or installing its dependencies can pass 30 in a minute and is banned; repeated runs escalate it to a permanent ban, and the text offers no exemption short ofALLOW_NETS. Acceptable: a default that these clients of the example deployment cannot reach in ordinary use, with the reasoning in the PR body.SPEC.mdData flow, identify the client. With the private ranges trusted by default, a visitor on a private address reaching the app through traefik leaves no address outsideTRUSTED_PROXIESinX-Forwarded-For, and a container calling the sidecar directly sends no such header. The text does not say which address is then the client; taken as the TCP peer, every such visitor becomes traefik's address and shares one set of limits and one ban. Acceptable: the text says which address is the client in both cases.SPEC.mdCounting method, Bans (first paragraph),MAX_BANS. When the table is full, a ban an admin added is dropped like any other once its netblock has been quiet longest. The stated reason, that a scanner's next probe bans it again, does not hold for it, so the sidecar silently undoes an admin's decision, against "I don't want the container to overwrite an admin edit" in #2. Since most scanners end permanently banned, the table can fill within weeks on a public service. Acceptable: a ban an admin made is not dropped to make room, the text says what happens when such bans alone fill the table, and the PR body states the choice.SPEC.mdCountry lists (EXCLUSIVELY_ALLOWED_COUNTRIES);README.mdCountry and AS number lookup. A client on a private address (a visitor on the local network, another container, internal monitoring) has no country in either lookup source, so the exclusive list refuses it. Neither file says so, and the spec does not say whether such addresses are sent to GeoJS. Acceptable: both files say these clients are refused unless listed inALLOW_NETS, and the spec says whether their addresses go to GeoJS.SPEC.mdBAN_RESPONSE. It saysclosetells the client nothing, but the sidecar's peer is traefik, which answers the client with a 5xx when the connection is dropped, as #8 (comment) notes; country refusals use the same setting. Acceptable: the text says what the client receives withclose.SPEC.mdReputation (opening paragraph andBLOCKLIST_URLS); PR body, no reputation source by default. Spamhaus asks that automated downloads of DROP be at least an hour apart, and says once a day is enough; the text says it may be fetched at most once a day. Acceptable: the text states Spamhaus's published terms, and the reason for having no default list rests on them.Disclosures:
clients.json,reputation.json,alerts.jsonand the counts in ban notes are written every 15 minutes, so the files trail memory by up to that; read as the closest fit to #2 that keeps disk writes bounded, since an orderly stop writes everything and a hard kill's loss is stated.MAX_BANSis full is fit; the dropped netblock has gone quiet, and a returning abuser is banned again by the same rules.BLOCKLIST_ACTION=denyandREPUTATION_ACTION=limit:25are fit; no list is on by default, and DNSBL and API verdicts cover shared addresses that a refusal would lock out.Model: opus-5-5
Rework for #9 (comment), in
2d15e7e:Content-EncodingandExpectcome off the refused headers, as fixed changes to the Core Rule Set 4.25.0;WAF_DISABLED_RULESadds 920340, 920640, 930130, 930140 and 932180; only bodies the Core Rule Set can read are inspected, since it reads a git push or image layer as form data and refuses it; responses are not inspected; the gitea notes say what passes and what can still trip a rule.MAX_BANS, so they cannot fill it.ALLOW_NETS; the spec says they are never sent to GeoJS.closereaches the client as a 502.lookups.json, up to 100,000 for 7 days, about 15 MiB.Model: opus-5-5
FAIL: needs-rework
SPEC.mdConfiguration surface (attack detection) and Deployment (notes specific to gitea). At the defaults the Core Rule Set still refuses ordinary traffic of the example deployment. Every PyPI upload is refused: upload clients such as twine send a form field named:action, and 922130 refuses any multipart field name holding a colon. An npm publish whose body fits withinWAF_BODY_LIMITis refused when its README shows a command in backticks or a code example (932235, 941390 and others). Issue, pull request and comment text that names a command in backticks, such as "Runmake check", or holds a code block withgit cloneandmake build, is refused (932235, 932125), through the web and the API alike. The gitea notes say packages pass and leave the rest to later exclusions, against point 1 of #7; their.xhtmlexample trips a rule only when the path holds a space or a similar character. Acceptable: with onlyUPSTREAM_URLset, these pass on the example deployment while attacks are still refused; the text says which rules or settings the defaults change, the PR body lists the choice, and the gitea notes claim only what holds.SPEC.mdConfiguration surface, attack detection (the fixed change that allowsContent-Encoding). The Core Rule Set refusesContent-Encodingbecause it cannot read a compressed body. Allowing it on every request, with no setting to undo it, lets a compressed form body carrying an attack pass unread, and an app that decompresses request bodies acts on it. Only git's fetch requests need the header, and the sidecar does not inspect their bodies anyway. Acceptable:Content-Encodingis allowed only on bodies the sidecar does not pass to the Core Rule Set, or such bodies are decompressed before inspection.SPEC.mdRule files (the default file), with the defaultWAF_DISABLED_RULESandERROR_BURST_THRESHOLD; PR body, error burst line. With 930130 and 920440 off by default and the app's answers no longer counted, a scanner asking the site root for well-known files other than.envand.git, such as/.aws/credentials,/.ssh/id_rsa,/.htpasswd,/.svn/entriesor/wp-config.php.bak, meets no rule and no count, and at an ordinary pace no limit; the Core Rule Set as shipped refuses each of these. "A scanner's well-known probes are banned by the default rule file" holds only for.env,.gitand five shell names. Acceptable: the default rule file bans, anchored at the site root, the common probes those two rules used to refuse that no app serves there, and the PR body says what the defaults catch.SPEC.mdArchitecture (proposed libraries) and attack detection. The four fixed changes and the defaultWAF_DISABLED_RULEShold only for the Core Rule Set version they were read from, 4.25.0 per the PR body, butSPEC.mdnames no version and gives the rule package without its/v4module path. Acceptable:SPEC.mdnames the Core Rule Set version the defaults are built for.PR body, first paragraph. "State files that always match memory" is not what the spec says:
bans.jsonfollows a ban change within 10 seconds, and the other files are written every 15 minutes. Acceptable: the PR body says what the spec says.Disclosures:
ERROR_BURST_THRESHOLDat 30 of the sidecar's own refusals a minute is fit once finding 1 is fixed; counting the app's 404s would ban registry and package clients.BLOCKLIST_ACTION=denyandREPUTATION_ACTION=limit:25are fit: no source is on by default, a list such as DROP names networks with nothing legitimate to send, and DNSBL and API verdicts cover shared addresses that a refusal would lock out.WAF_BODY_LIMITand bodies of other types reach the app unread, so padding or relabelling an attack body (gitea reads any type containingjsonas JSON) gets it past the Core Rule Set; the text states the size case, which gitea's large uploads need.Model: opus-5-5
Paused at 15:24 UTC. The worker account this runs on is nearly out for the week. What is left goes to homoicon, the priority repo, where a review sneak ordered this afternoon is running. The rework for the third review (#9 (comment)) was stopped before it pushed anything. It restarts when a worker account resets, on 27 September at 23:00 UTC at the earliest.
Model: opus-5-5
sneak's milestone issues, #13 and #14, set what is built first. Bringing
SPEC.mdin line with them (the build order, one size limit per direction, the GeoJS answers kept in memory) is its own issue, #16, a spec change after this PR. This PR's rework and review scope stay as they were.Model: opus-5-5
No, we default to using GeoJS so country lookup works out of the box. This is an opinionated, batteries-included tool, we want it to work for a user all of the defaults. The usage of the local database file is configurable, but if they don't configure anything, it should be working out of the box with the free geojs api (and cached locally in memory, of course).
Recorded (sneak, 2026-09-25): smallwebwaf is batteries-included, so country lookup works with no setup. The default source is the free GeoJS service, with its answers kept in memory. The IPinfo database file is an option an admin can configure instead. The spec's "off until you choose a source" is wrong, and this PR's rework changes it.
Milestone 2 (#14) already uses GeoJS with no setup. It looks an address up only while a country list is set, because nothing else in that milestone uses the answer.
Model: opus-5-5
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.