5 Commits
Author SHA1 Message Date
clawbot fdabf4974b Health check test uses addresses nothing can connect to (closes #158)
check / check (push) Canceled after 0s
TestHealthCheck closed the app's server, and later smallwebwaf, and
expected connections to the freed ports to fail. Another test could take
such a port in between, so the connection succeeded and the test failed.

The failing app check now uses the multicast address 224.0.0.1, to which
TCP cannot connect. The failing smallwebwaf check uses port 0, on which
nothing listens. Neither result depends on other processes. The test now
stops smallwebwaf after its last check, so that it exits before the
test's temporary directories are removed. The code under test is
unchanged.

Model: opus-5-5
2026-10-27 21:00:16 +01:00
clawbot a9dc59546b New defaults for the request and byte limits (closes #161)
check / check (push) Canceled after 0s
The request limits and the hour and day byte limits now sit about twice
above what 40 people busy with heavy web apps make together on one
address:

- requests: 6000 a minute, 100,000 an hour, 1,000,000 a day;
- bytes: 20 GiB an hour, 200 GiB a day.

The largest response drops from 5 GiB to 1 GiB. The minute byte limit
of 4 GiB is sized to hold two of the largest responses with their
requests. SPEC.md gives the new reasoning; README.md and the reference
documents give the new figures.

Model: opus-5-5
2026-10-27 19:28:09 +01:00
sneak 2ac2b671f6 Merge branch 'main' into next
check / check (push) Canceled after 0s
2026-10-26 13:10:59 +01:00
clawbot f408f42b28 SPEC, README and docs rewritten from scratch (closes #154, closes #155, closes #156)
check / check (push) Canceled after 0s
SPEC.md is the only place that says how smallwebwaf behaves and why; docs/ holds lists linking to it; the README is short and links to both. Passed independent review in round 8.

Model: fable-5-1 (writing); opus-5-5 (review, merge)
2026-10-26 03:06:28 +01:00
sneak acceecc7f8 Merge pull request 'Milestones 1 and 2: proxy with timeouts and size limits, rate limits, country lists and the image' (#40) from next into main
check / check (push) Canceled after 0s
Reviewed-on: #40
2026-10-22 19:51:30 +02:00
10 changed files with 524 additions and 2550 deletions

No files matched your search

+296 -2347
View File
File diff suppressed because it is too large. Load diff
+150 -143
View File
@@ -100,10 +100,12 @@ to them.
## What happens to a request
The steps run in this order. The first step that produces a final answer ends
the checks; step 15 runs for every request. The size and time limits apply to
the whole exchange. Each step names the section that holds its rules.
the checks; step 15 runs for every request but the health check. The size and
time limits apply to the whole exchange. Each step names the section that holds
its rules.
1. `GET /_smallwebwaf/healthz` is answered at once, before any check.
1. `GET /_smallwebwaf/healthz` is answered at once, before any check, so a
health checker never needs an exemption and is never refused.
2. The client is identified.
- If the TCP peer is inside `SWWAF_TRUSTED_PROXIES`, `X-Forwarded-For` is
walked from the right, and the first address outside those netblocks is
@@ -116,9 +118,9 @@ the whole exchange. Each step names the section that holds its rules.
around its address, a /64 by default, since one abuser usually controls a
whole /64. A client is therefore one IPv4 address or one IPv6 group.
3. The static lists. A client in `SWWAF_ALLOW_NETS` skips steps 4 to 12 and the
byte limits and the error burst at step 15. It is still counted for the
anomaly thresholds, and still needs a token at step 13. A client in
`SWWAF_DENY_NETS` is refused; a client in both lists is let through.
byte limits and the error burst at step 15. It still needs a token at
step 13. A client in `SWWAF_DENY_NETS` is refused; a client in both lists is
let through.
4. Bans. A client under a ban is refused (see "Bans").
5. The lookup, unless `SWWAF_LOOKUP_SOURCE` is `off` (see "Lookup of AS number
and country"). The request goes on at once. It waits for the first GeoJS
@@ -134,8 +136,6 @@ the whole exchange. Each step names the section that holds its rules.
gets `SWWAF_BLOCKLIST_ACTION`. A client on the CrowdSec decision list is
banned. A client listed by a DNS blocklist, or scored at least
`SWWAF_ABUSEIPDB_MIN_SCORE` by AbuseIPDB, gets `SWWAF_REPUTATION_ACTION`.
With no cached verdict, a query is queued in the background and the request
goes on, so a first request is never delayed by a reputation query.
8. The client's limit percentage (see "Biased thresholds").
9. Rate limiting (see "Request rate limits, per client"). A request over a limit
times the percentage is refused and bans the client.
@@ -164,8 +164,8 @@ the whole exchange. Each step names the section that holds its rules.
thresholds are evaluated.
In `SWWAF_MODE=observe`, every refusal above is logged as what would have
happened, nothing is refused, and no ban is made. The size and time limits and
the token check still apply.
happened, nothing is refused, and `smallwebwaf` makes no ban of its own. The
size and time limits and the token check still apply.
## Counting method
@@ -183,8 +183,8 @@ the token check still apply.
GeoJS answers, the reputation verdicts and scores, the DNS queries under way
and the anomaly counters have fixed bounds, listed under "Fixed limits" in
`docs/settings.md`. Past its bound, the GeoJS answer or anomaly counter used
longest ago goes first, and the verdict or score fetched longest ago. Past
1,000 DNS queries under way, a zone is asked at the client's next request.
longest ago goes first, and the verdict or score fetched longest ago. Past the
bound on DNS queries under way, a zone is asked at the client's next request.
## Bans
@@ -193,8 +193,7 @@ Until a ban ends, every request from its netblock is refused with
`SWWAF_BAN_SCOPE_V4_PREFIX`, or its IPv6 group. At the defaults a ban covers one
address or one /64, so a permanent ban does not reach neighbours who did
nothing. Requests refused under a ban are not counted against limits. They are
counted in the client's history and in the ban's notes. A banned client is not
looked up.
counted in the client's history and in the ban's notes.
An offence is a request `smallwebwaf` holds against the client. It is one of:
@@ -216,9 +215,12 @@ to a ban, each by its own rule:
permanent this way.
- Once that ban has run out, the netblock is served like any other. Its next
clear sign of attack bans it permanently at once.
- A broken limit: a request over a rate limit, a response that takes the
client's byte total past a byte limit, or more than
`SWWAF_ERROR_BURST_THRESHOLD` refusals within a minute.
- A broken limit, one of:
- a request over a rate limit;
- a response that takes the client's byte total past a byte limit;
- more than `SWWAF_ERROR_BURST_THRESHOLD` refusals within a minute.
The bans for a broken limit:
- The first such ban lasts `SWWAF_LIMIT_BAN_DURATION` (default `1h`), with
the cause `limit`. So does one that comes more than
`SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`) after the last such ban
@@ -234,7 +236,7 @@ to a ban, each by its own rule:
A Core Rule Set match refuses only the request it matched, with `403` and
without a ban, because the Core Rule Set has false positives. A `block` rule
does the same. Those refusals count toward the error burst, so a client that
keeps setting them off is banned under the second rule.
keeps setting them off is banned for a broken limit.
A client on the CrowdSec decision list is banned until the decision ends, with
the cause `crowdsec`. Only a client that sends a request is banned, so a long
@@ -329,8 +331,8 @@ Conventions:
- `SWWAF_ADMIN_TOKEN` switches on the ban endpoints and
`/_smallwebwaf/clients/<ip>`. The metrics are off until `SWWAF_METRICS_TOKEN`
is set, since without a token they would be open to anyone on the internet.
The two are separate, so a scraper that holds the metrics token cannot manage
bans.
The two are separate, so the metrics collector, which holds the metrics token,
cannot manage bans.
- A token is a long random value, since the endpoints can be reached from the
internet. One shorter than 32 characters stops the start. The app starts with
the same environment variables as `smallwebwaf`, so it can read a token set as
@@ -366,16 +368,23 @@ Conventions:
- `SWWAF_ALLOW_NETS` skips the checks step 3 names, for monitoring or the
owner's own networks. `SWWAF_RATE_LIMIT_EXEMPT_NETS` bypasses the request and
byte limits only; the error burst, attack detection and bans still apply.
- Many clients share one address (mobile carriers, offices, Tor), and so share
its limits and its bans. The defaults sit well above one person's use, and the
first two lists take known shared addresses out.
- Many people can share one address, as in an office, on a mobile carrier or
through Tor. They are one client, and share its limits and its bans. The
default limits are sized for about 40 busy people on one address. A known
shared address busier than that belongs in `SWWAF_ALLOW_NETS` or
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
### Request rate limits, per client
Breaking one bans the client, so the defaults sit several times above what one
busy person produces. A browser loading a heavy page makes a few hundred
requests, and a git clone a handful. A machine that talks to the app all day can
still pass the day limit. Its address belongs in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
Breaking one bans the client, so the defaults sit about twice above what 40
people busy with heavy web apps make together on one address. Each such person
makes about 75 requests in a busy minute, and 1,250 an hour for ten hours a day.
A client slower than those 40 people cannot be told from them by counting. Only
the other checks can catch an abuser that slow. They include the trap paths, the
rule files, the Core Rule Set and the blocklists. An abuser that trips none of
them is not caught. A machine that talks to the app all day can still pass the
day limit. Its address belongs in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` takes path prefixes out of the count, such as
static assets or health checks; their bytes are not counted either. A prefix is
@@ -388,9 +397,12 @@ matched the same way.
### Byte limits, per client
A response's bytes are counted when it ends (see step 15). So every default sits
above the largest request and answer together, and no single download breaks a
limit.
Each of the same 40 people moves about 256 MiB an hour, for ten hours a day. The
hour and day limits sit about twice above what they move together. A response's
bytes are counted when it ends (see step 15). So the minute limit holds two of
the largest responses, with their requests, ending in the same minute. No single
download then breaks a limit. An operator who raises `SWWAF_RESPONSE_MAX_BYTES`
raises the byte limits with it.
### Size and time limits
@@ -442,12 +454,8 @@ service that keeps its visitors' addresses on its own host.
- Only one source is used. The start stops, naming both settings, when `file` is
set without `SWWAF_LOOKUP_DB_PATH`, or `SWWAF_LOOKUP_DB_PATH` with any other
source. The start also stops when the source is `off` and one of these needs
lookups:
- a country list;
- a biased threshold;
- `SWWAF_ADD_LOOKUP_HEADERS`;
- an anomaly threshold per AS number.
source. The start also stops when the source is `off` and a setting that needs
a lookup is set (`docs/settings.md` lists them).
- GeoJS is told the address of every new visitor, whether or not a setting uses
the answer, since the request log, the metrics and the ban notes carry it.
These addresses are never sent:
@@ -458,8 +466,8 @@ service that keeps its visitors' addresses on its own host.
Addresses are asked about together, up to 200 in one request
(`https://get.geojs.io/v1/ip/geo.json?ip=a,b,c`), one request at a time, which
keeps the number of requests to GeoJS low. Each answer's `country_code`, `asn`
and `organization_name` are read. An answer without a `country_code`, or with
the `asn` `64512`, counts as unknown.
and `organization_name` are read. An answer without a `country_code` leaves
the country unknown, and the `asn` `64512` leaves the AS number unknown.
- Each answer is kept for seven days, in memory and in `lookups.json`. Answers
are kept apart from the table of clients, so that an answer outlasts a client
dropped from that table. The answer reaches the client's history and ban notes
@@ -476,8 +484,8 @@ service that keeps its visitors' addresses on its own host.
API requests", judged by GeoJS alone, and let it block a caller. While GeoJS
is slow, down or refusing `smallwebwaf`, new clients count as unknown. GeoJS
is then left alone for a second, and twice as long after each further failure,
up to five minutes, and one `source_failure` alert is sent per cooldown. A
service that cannot accept this uses the file.
up to five minutes. Each failure raises a `source_failure` alert (see
"Alerting"). A service that cannot accept this uses the file.
- The file is the free IPinfo Lite database (`ipinfo_lite.mmdb`), whose `asn`,
`as_name` and `country_code` fields are read. The operator downloads it with a
free IPinfo account and mounts it read-only; `smallwebwaf` never fetches it
@@ -486,9 +494,9 @@ service that keeps its visitors' addresses on its own host.
on the site that uses it, such as "IP address data is powered by IPinfo".
- The file is read whole at start; one that is missing or unreadable stops the
start. It is read again once it has gone 2 seconds without a change after
being replaced. One that cannot be read is logged, sent as one `file_error`
alert and ignored. The database already loaded stays in use. A client whose
address the file does not hold counts as unknown.
being replaced. A replacement that cannot be read is logged, sent as one
`file_error` alert and ignored. The database already loaded stays in use. A
client whose address the file does not hold counts as unknown.
### Country lists
@@ -508,9 +516,8 @@ where it comes from.
exclusive list already refuses every other country. A code on both lists stops
the start with a message naming it.
- A refused request is answered with `SWWAF_BAN_RESPONSE`, as soon as the
client's address has been looked up, before its body is read. It skips the
reputation checks, the limits, the rule files and the Core Rule Set. It is
logged with the action `country_denied`, and counted in the metrics.
client's address has been looked up, before its body is read. It is logged
with the action `country_denied`, and counted in the metrics.
### Biased thresholds
@@ -519,10 +526,11 @@ countries. The lists are empty by default, for the same reason as the country
lists. The request log and the metrics show which AS numbers and countries a
service's abuse comes from.
- Clients in a listed AS number or country get that percentage of every request
and byte limit (`SWWAF_ASN_LIMIT_PERCENT`, `SWWAF_COUNTRY_LIMIT_PERCENT`). A
lowered limit is rounded down. `0` is a zero allowance: the client's first
request breaks a limit and bans it.
- Each list gives AS numbers or countries a percentage, such as `AS14061:50`.
Their clients get that percentage of every request and byte limit
(`SWWAF_ASN_LIMIT_PERCENT`, `SWWAF_COUNTRY_LIMIT_PERCENT`). A lowered limit is
rounded down. `0` is a zero allowance: the client's first request breaks a
limit and bans it.
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` set the byte
limits' percentage alone, when it should differ from the request limits'. For
an AS number they list, they replace its percentage from
@@ -533,12 +541,12 @@ service's abuse comes from.
fetched and kept like a blocklist (see "Reputation"). Where it and
`SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies.
- A client's limit percentage is the lowest of those that apply to it: its AS
number's, its country's, or a reputation hit's `limit:<percent>`. A client the
lookup cannot place gets `SWWAF_UNKNOWN_LIMIT_PERCENT` in place of an AS
number's or a country's. The percentage is 100 if none applies. Clients given
a lower percentage are banned after fewer requests, first for a while and
then, for repeated abuse, permanently. The log line of each counted request,
and the notes of each ban, say which setting lowered the limit.
number's, its country's, or a reputation hit's `limit:<percent>`. A client
without a country gets `SWWAF_UNKNOWN_LIMIT_PERCENT` in place of a country's.
The percentage is 100 if none applies. Clients given a lower percentage are
banned after fewer requests, first for a while and then, for repeated abuse,
permanently. The log line of each counted request, and the notes of each ban,
say which setting lowered the limit.
- No budget is shared by a whole country or AS number. One abuser could use it
up and lock out everyone else there: a denial of service nobody chose, and the
one this tool exists to prevent. Refusing a whole country is left to the
@@ -549,16 +557,14 @@ service's abuse comes from.
By default the Core Rule Set reads the method, the URL with its query string,
and the headers of every request. That is where automated attacks show:
injection in query strings, path traversal, attacks carried in headers,
scanners' user agents. It reads no request body. On a code forge, the example
deployment, bodies carry what people write and publish, such as issue text and
scanners' user agents. It reads no request body. In front of a code forge such
as gitea, bodies carry what people write and publish, such as issue text and
files saved in the web editor. The Core Rule Set takes the shell commands and
code in them for attacks. The default gives up refusing an attack carried in a
body. The rule files, the limits and the bans still apply to the client that
sends it.
- The Core Rule Set inspects only a request that every earlier check let
through, and skips a path under `SWWAF_WAF_EXEMPT_PATHS`. An engine error on a
request forwards the request, and is logged and counted.
- The Core Rule Set skips a path under `SWWAF_WAF_EXEMPT_PATHS`.
- `SWWAF_WAF_PARANOIA_LEVEL` and `SWWAF_WAF_ANOMALY_THRESHOLD` are the Core Rule
Set's own two tuning values, at its own defaults. Each level up runs more
rules, which find more attacks and take more ordinary requests for attacks.
@@ -601,14 +607,18 @@ sends it.
percent-decoded: `/wp-login.php` matches `/wp-login.php?x=1` and
`/%77p-login.php`, and does not match `/wp-login.php/` or
`/blog/wp-login.php`.
- `SWWAF_ERROR_BURST_THRESHOLD` counts a client's refusals per minute for a rule
file match, a trap path, a Core Rule Set match, or a missing or wrong token;
no other refusal counts. More than this breaks a limit (see "Bans"). A client
trying one attack or token after another is refused many times a minute; a
person rarely more than a few times. Answers the app gives are not counted,
since they do not tell a scanner from an ordinary client. In front of gitea,
registry and git clients get `404` and `401` by design, hundreds of times a
minute.
- `SWWAF_ERROR_BURST_THRESHOLD` counts a client's refusals per minute of these
kinds, and of no other:
- a rule file match;
- a trap path;
- a Core Rule Set match;
- a missing or wrong token.
More than this breaks a limit (see "Bans"). A client trying one attack or
token after another is refused many times a minute; a person rarely more
than a few times. Answers the app gives are not counted, since they do not
tell a scanner from an ordinary client. In front of gitea, registry and git
clients get `404` and `401` by design, hundreds of times a minute.
`smallwebwaf` changes the Core Rule Set 4.25.0 in six ways, since in front of
gitea it would otherwise refuse ordinary requests. The changes hold in front of
@@ -635,12 +645,12 @@ every app, and no setting undoes them:
- `ref`, `sha`, `branch` and `workflow`;
- `artifactName` and `redirect_to`.
gitea puts file paths, branch and workflow names, and the page to return to
after signing in into them. It reads these values as names in a repository
gitea puts into them file paths, branch and workflow names, and the page to
return to after signing in. It reads these values as names in a repository
or in its own records, or as a page of its own site, never as a command. Any
name on those lists can be an ordinary file or branch, such as `.gitignore`
or `docker-build`. In these parameters, anything only these three rules
catch reaches the app, commands such as `|cat /etc/passwd` included. Path
or `docker-build`. In these parameters, what only these three rules would
catch reaches the app, such as the command `|cat /etc/passwd`. Path
traversal, SQL and script injection, and PHP, Java and Node.js code are
still refused there. Every other parameter keeps all three rules. An app
that uses one of these parameters as a file on the server, or passes it to a
@@ -649,8 +659,8 @@ every app, and no setting undoes them:
5. Two cookies are left out, and `Referer` keeps fewer rules:
- The Core Rule Set reads the request without the `gitea_flash` and
`redirect_to` cookies. gitea puts a message naming what was just done into
`gitea_flash`. It puts the page to return to after signing in into
`redirect_to`. The Core Rule Set takes file names in them, such as
`gitea_flash`. It puts into `redirect_to` the page to return to after
signing in. The Core Rule Set takes file names in them, such as
`package.json`, for attacks. A browser sends such a cookie with every
request until gitea replaces it. gitea cannot replace it while those
requests are refused, so one refusal would keep the browser out of the
@@ -773,9 +783,9 @@ too close together.
by default. Such verdicts are less certain than a blocklist, so a listed
client gets a quarter of every request and byte limit rather than a refusal.
- Verdicts and scores are kept for `SWWAF_REPUTATION_CACHE_TTL`, in memory and
in `reputation.json`, with the fixed bounds under "Fixed limits" in
`docs/settings.md`. A source that is down or over quota gives no verdict. The
service continues, with one `source_failure` alert per cooldown.
in `reputation.json`. A source that is down or over quota gives no verdict.
The service continues. Each failure raises a `source_failure` alert (see
"Alerting").
### Alerting
@@ -787,9 +797,8 @@ too close together.
the ban's alert, with `mode` set to `observe` in its `detail`.
- A repeat is an alert with the same event, and the same netblock, source, file
or anomaly scope, as one sent less than `SWWAF_ALERT_COOLDOWN` before. An
anomaly scope is the thing counted: one client, netblock, AS number or named
netblock, or the whole service. A repeat is held back and counted, and the
next such alert sent gives the count.
anomaly scope is one of the things counted under "Anomaly thresholds". A
repeat is held back and counted, and the next such alert sent gives the count.
- Past `SWWAF_ALERT_MAX_PER_HOUR` alerts in an hour, the rest are held back and
counted by event, so a wide attack cannot flood the channel. Once the hour
ends, one alert with the event `summary` gives those counts. It also counts
@@ -820,7 +829,7 @@ addresses at low rates each, which the per-client limits do not see. A low
- Every request but the health check is counted, whatever was done with it,
clients in `SWWAF_ALLOW_NETS` and `SWWAF_RATE_LIMIT_EXEMPT_NETS` included,
since an exempt client misbehaving is worth knowing about. The counters are
kept in memory and in `alerts.json`, with a fixed bound.
kept in memory and in `alerts.json`.
## Rule files
@@ -846,10 +855,10 @@ and the default file.
of every request; a rule naming one stops the start like any bad line. Request
bodies are not available to rule files; reading them is the Core Rule Set's
job.
- `log` only notes the match in the request log. `block` refuses the request
without a ban; `ban` makes the request a clear sign of attack (see "Bans"),
and is for requests no real visitor sends. A visitor banned by mistake is let
back in by lifting the ban.
- `log` only notes the match in the request log. `block` refuses the request;
`ban` makes the request a clear sign of attack (see "Bans"), and is for
requests no real visitor sends. A visitor banned by mistake is let back in by
lifting the ban.
- At start, these stop the process with a message naming the file and line:
- a line that does not parse;
- a regex that does not compile;
@@ -865,11 +874,11 @@ and the default file.
wrong for every app, with its path rules anchored at the site root. In front
of gitea, `/.env` is a probe, while
`/<owner>/<repo>/src/branch/main/.env.example` is a file in a repository that
any visitor may open. `ban` rules of your own follow the same rule. Anything
app-specific, such as a rule banning WordPress probes in front of gitea,
belongs in a file the app's Dockerfile adds or the deployer mounts. A
directory mounted over `SWWAF_RULES_DIR` replaces the default file; single
files mounted into it add to it.
any visitor may open. Anchor the paths of your own `ban` rules at the site
root too. Anything app-specific, such as a rule banning WordPress probes in
front of gitea, belongs in a file the app's Dockerfile adds or the deployer
mounts. A directory mounted over `SWWAF_RULES_DIR` replaces the default file;
single files mounted into it add to it.
- Rules meant to be edited while `smallwebwaf` runs belong in a mounted
directory, not a mounted file. Many editors save a file by replacing it, and a
container keeps seeing the old one when the file alone is mounted. The lookup
@@ -878,11 +887,11 @@ and the default file.
## Persistent state
All state lives in memory, and the files in `SWWAF_STATE_DIR` hold a copy of all
of it, so an orderly stop loses nothing (the remote log is the exception; see
"Request log"). The files are meant for people as well: an admin can read or
edit them at any time, and the running `smallwebwaf` takes the edit in.
`docs/state-files.md` lists the files, what each holds, and their sizes.
All state lives in memory, and the files in `SWWAF_STATE_DIR` hold a copy of it,
so an orderly stop loses nothing. The files are meant for people as well: an
admin can read or edit them at any time, and the running `smallwebwaf` takes the
edit in. `docs/state-files.md` lists the files, what each holds, and their
sizes.
- Each file holds one kind of state, as JSON with:
- a top-level `version` number;
@@ -940,9 +949,8 @@ edit them at any time, and the running `smallwebwaf` takes the edit in.
## Request log
Every request gets one JSON object on one line on stdout, refused ones included,
apart from those Go's HTTP server ends before `smallwebwaf` sees them (see "Size
and time limits"). `docs/request-log.md` lists the fields. Bodies are never
Every request `smallwebwaf` sees gets one JSON object on one line on stdout,
refused ones included. `docs/request-log.md` lists the fields. Bodies are never
logged, and no header but those listed there. 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 (start, the settings, stop, errors) share the
@@ -971,8 +979,7 @@ while `docker logs` keeps working.
## Metrics endpoint
`GET /_smallwebwaf/metrics` answers in the Prometheus text format, for a request
carrying `SWWAF_METRICS_TOKEN`. `docs/metrics.md` lists the metrics. Every
metric carries the label `instance`, set to `SWWAF_INSTANCE_NAME`. No metric
carrying `SWWAF_METRICS_TOKEN`. `docs/metrics.md` lists the metrics. No metric
carries a client IP address as a label. Per-address questions are answered by
the request log and `GET /_smallwebwaf/clients/<ip>`.
@@ -980,15 +987,14 @@ the request log and `GET /_smallwebwaf/clients/<ip>`.
A request whose path starts with `/_smallwebwaf/` is for `smallwebwaf` itself:
it answers it and never passes it to the app. The prefix carries the tool's
name, so it takes no path an app uses. Admins and scrapers reach these endpoints
through traefik, like any other request. `docs/admin-endpoints.md` lists the
endpoints, what each takes and what each answers.
name, so it takes no path an app uses. Admins and the metrics collector reach
these endpoints through traefik, like any other request.
`docs/admin-endpoints.md` lists the endpoints, what each takes and what each
answers.
- `GET /_smallwebwaf/healthz` answers `200` with `ok` to anyone, without a
token, while `smallwebwaf` is running. It does not ask the app; the
container's health check does. It is answered before any check, so a health
checker never needs an exemption and is never refused, for example by
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES`.
container's health check does.
- Every other endpoint needs its token, sent as `Authorization: Bearer <token>`
(see "Tokens" under "Settings"). While a token is unset, the endpoints that
need it answer `404`, as does any other path under the prefix.
@@ -996,10 +1002,9 @@ endpoints, what each takes and what each answers.
other request goes through. They are answered at the point where another
request would be forwarded to the app: a banned or refused client stays
refused, and each request counts toward the client's limits. A missing or
wrong token is answered `401`, in `SWWAF_MODE=observe` too, and counts toward
the error burst. A client guessing tokens is therefore banned once it passes
`SWWAF_ERROR_BURST_THRESHOLD` guesses in a minute.
- A ban made through the endpoints starts at once, with the cause `admin`.
wrong token is answered `401` (see "Attack detection" for what guessing
costs).
- A ban made through the endpoints starts at once.
## Deployment
@@ -1027,40 +1032,40 @@ what the image holds and how each part is pinned.
digest.
- nixpkgs comes from a release file on `releases.nixos.org`, named with its
hash, not from a GitHub archive, whose bytes can change. Root uses Nix
directly; no Nix daemon runs, and since `nix-bin` creates no build users,
`build-users-group =` in `/etc/nix/nix.conf` lets root build without them.
Root's Nix profile is last on the `PATH`, so that no package hides the image's
own commands; busybox's `sv`, for one, looks for services elsewhere. An app
built on the image gets the same packages each time. Security fixes from
Ubuntu and nixpkgs come with a newer `smallwebwaf` image, which an app takes
by changing the digest in its `FROM` line. The image moves to each new nixpkgs
release, every six months.
directly; no Nix daemon runs. `nix-bin` creates no build users, so
`/etc/nix/nix.conf` sets `build-users-group =`, which lets root build without
them. Root's Nix profile is last on the `PATH`, so that no package hides the
image's own commands; busybox's `sv`, for one, looks for services elsewhere.
An app built on the image gets the same packages each time. Security fixes
from Ubuntu and nixpkgs come with a newer `smallwebwaf` image, which an app
takes by changing the digest in its `FROM` line. The image moves to each new
nixpkgs release, every six months.
- runit supervises the two processes, with `runsvinit` as the entrypoint, as the
owner's code style guide asks for service containers. No package carries
`runsvinit`, so the image builds it from its archived source, unchanged since
2015, at a fixed commit hash, with a `go.mod` the build writes. `runsvinit`
starts runit's `runsvdir`, which runs the `run` script of each directory under
`/etc/service`. Ubuntu's `sv` looks for services there too.
- The image's `run` script for `smallwebwaf` waits one second and makes
`SWWAF_STATE_DIR` and every file in it belong to the `smallwebwaf` user (uid
and gid 65532). Then it starts `smallwebwaf` as that user with runit's
`chpst`.
- The image's `run` script for `smallwebwaf` makes `SWWAF_STATE_DIR` and every
file in it belong to the `smallwebwaf` user (uid and gid 65532). Then it
starts `smallwebwaf` as that user with runit's `chpst`.
Every `run` script, the app's included, is a bash script. It starts with
`#!/usr/bin/env bash` and `set -euo pipefail`, and puts its code in a `main`
function, called on its last line, as the owner's code style guide asks. Ubuntu
ships bash.
ships bash. Each waits one second (`sleep 1`) before starting its program, so a
process that stops at start is started again every second, not in a tight loop.
Beyond its `FROM` line, the app's Dockerfile adds:
- its binary;
- any packages it needs, from nixpkgs, with `nix-env -iA nixpkgs.<name>`;
- its runit service: a directory under `/etc/service` named after the app, never
`smallwebwaf`. Its `run` script waits one second (`sleep 1`) and then starts
the app with `chpst`, listening where `SWWAF_UPSTREAM_URL` points (see
"Ports"). The app runs as a user of its own, which the Dockerfile creates with
`useradd`. That user is neither root nor `smallwebwaf`, so that the app cannot
touch the state files or the process of `smallwebwaf`.
`smallwebwaf`. Its `run` script starts the app with `chpst`, listening where
`SWWAF_UPSTREAM_URL` points (see "Ports"). The app runs as a user of its own,
which the Dockerfile creates with `useradd`. That user is neither root nor
`smallwebwaf`, so that the app cannot touch the state files or the process of
`smallwebwaf`.
It sets no `ENTRYPOINT` or `USER` of its own. The container must start
`runsvinit`, as root, so that it can start each service as its own user.
@@ -1072,20 +1077,21 @@ It sets no `ENTRYPOINT` or `USER` of its own. The container must start
script starts as root and hands over to its program's user with `chpst`. Both
services start with the container's environment variables and write to the
container's output, which `docker logs` shows.
- When `smallwebwaf` or the app exits, runit runs its `run` script again, which
waits a second before starting it. The other process keeps running, and the
container does not stop. While `smallwebwaf` is down, traefik answers `502`.
While the app is down, `smallwebwaf` answers `502`, which is not a client
offence.
- Only four things stop `smallwebwaf`, all at start:
- When `smallwebwaf` or the app exits, runit runs its `run` script again. The
other process keeps running, and the container does not stop. While
`smallwebwaf` is down, traefik answers `502`. While the app is down,
`smallwebwaf` answers `502`, which is not a client offence.
- Only faults found at start stop `smallwebwaf`:
- invalid configuration;
- a lookup database that is missing or unreadable;
- a rule file or state file that does not parse;
- an unwritable `SWWAF_STATE_DIR`.
- A `smallwebwaf` that stops at start is therefore started again every second,
logging its message each time, until the cause is fixed; the health check
fails meanwhile. Once running, a failing GeoJS, reputation source or alert
destination, or a broken edit, never takes the protected service down.
- an unwritable `SWWAF_STATE_DIR`;
- a `SWWAF_RULES_DIR` that does not exist or cannot be read;
- a `SWWAF_LISTEN_ADDR` it cannot listen on.
- A `smallwebwaf` that stops at start logs its message at each try until the
cause is fixed; the health check fails meanwhile. Once running, a failing
GeoJS, reputation source or alert destination, or a broken edit, never takes
the protected service down.
- `docker stop` sends `SIGTERM` to `runsvinit`, which has runit stop both
services at once, each with `SIGTERM`, and exits once they have stopped.
`smallwebwaf` stops taking requests, gives those in progress five seconds to
@@ -1154,9 +1160,10 @@ all: `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1/32,::1/128`.
### upaas
An app deployed by upaas takes this shape with no change to upaas. upaas builds
the app's Dockerfile and runs the one container as it runs any app. The traefik
labels route to port 8080, and any `SWWAF_` settings go with the app's
An app deployed by upaas, which rebuilds and restarts an app's Docker container
when its repository changes, takes this shape with no change to upaas. upaas
builds the app's Dockerfile and runs the one container as it runs any app. The
traefik labels route to port 8080, and any `SWWAF_` settings go with the app's
environment variables. The volume for `/var/lib/smallwebwaf` and the directory
of any token file go beside the app's own volumes. gitea is built on the image
like any other app; `docs/gitea.md` records what the defaults do in front of it,
+11 -8
View File
@@ -42,7 +42,7 @@ on one line:
"asn": "",
"as_name": "",
"country": "",
"reason": "requests per minute over the limit of 1000",
"reason": "requests per minute over the limit of 6000",
"detail": {
"ban_expires": "2026-10-06T13:00:00.123Z",
"cause": "limit",
@@ -51,9 +51,9 @@ on one line:
"as_name": "",
"country": "",
"kind": "requests",
"limit": 1000,
"limit": 6000,
"window": "minute",
"count": 1001,
"count": 6001,
"request": {
"time": "2026-10-06T12:00:00.123456789Z",
"method": "GET",
@@ -62,7 +62,7 @@ on one line:
"status": 403,
"user_agent": "scraper/1.0"
},
"requests": 5210,
"requests": 8210,
"refused": 0,
"earlier_bans": {
"limit": 0,
@@ -79,16 +79,17 @@ on one line:
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
one of the events above.
- `client`: the address whose request raised the alert. Empty for
`source_failure` and `file_error`.
- `netblock`: empty for `source_failure` and `file_error`; otherwise one of:
`source_failure`, `file_error` and `summary`.
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
one of:
- the ban's netblock;
- the netblock an anomaly counted;
- the netblock an anomaly counted, empty in the `asn` and `total` scopes;
- for the other events, the client as rate limiting counts it: an IPv4
address as a /32, or an IPv6 group.
- `asn`, `as_name` and `country`: the client's, as known when the alert was
raised. They are empty when GeoJS had not answered yet.
- `reason`: a short sentence, such as
`requests per minute over the limit of 1000` or `listed by a blocklist`.
`requests per minute over the limit of 6000` or `listed by a blocklist`.
- `detail`: what is particular to the event:
- for a ban, its `cause`, `ban_expires` and `notes` (the notes are listed
under `bans.json` in [`state-files.md`](state-files.md));
@@ -97,6 +98,8 @@ on one line:
- for a Core Rule Set match, the `rule_ids`, `score`, `method` and `path`;
- for a source failure, the `source` and `error`;
- for a file error, the `file` and `error`;
- for a summary, the `hour` it covers, the `count` of alerts held back, and
that count by event in `events`;
- `mode`, when the request was not refused: `detect` or `observe`.
- `suppressed_repeats`: how many repeats the cooldown held back before this
alert.
+11 -8
View File
@@ -11,8 +11,10 @@ checked again when gitea or the Core Rule Set changes version.
- SSH access to gitea does not pass through `smallwebwaf` and is not protected
by it.
- A clone is a response. It fits the defaults of 30 minutes and 5 GiB for all
but the largest repositories and slowest links.
- A clone is a response. It fits the defaults of 30 minutes and 1 GiB unless the
repository is large or the link slow. A repository whose clone is larger than
1 GiB needs `SWWAF_RESPONSE_MAX_BYTES` raised, and the byte limits with it
("Byte limits, per client" in `SPEC.md`).
- A push is a request, and so is every other upload:
- a git push;
- an LFS object;
@@ -30,12 +32,13 @@ checked again when gitea or the Core Rule Set changes version.
request limits do most of the work there.
- An Actions runner that reaches gitea through `smallwebwaf` sends requests all
day. One older than version 0.4 (April 2026) asks for work every 2 seconds,
43,200 requests a day, and reports a running job's log and state every second.
So it passes the day limit of 50,000 after about an hour and a quarter of
jobs. It is then banned, the job it is running fails, and each repeat within a
day makes the ban longer. Newer runners ask less often, but a busy one, or
several on one address, can still pass it. Put the runners' addresses in
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
43,200 requests a day. While it runs a job, it reports the job's log and state
every second instead, two requests a second. So one runner busy all day sends
172,800 requests, under the request limits. Six such runners on one address,
each busy all day, pass the day limit of 1,000,000 within two days; five stay
under it. Their address is then banned, their running jobs fail, and each
repeat within a day makes the ban longer. Newer runners ask for work less
often. Put the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
## What the Core Rule Set lets through
+3 -3
View File
@@ -1,8 +1,8 @@
# The metrics
The metrics `GET /_smallwebwaf/metrics` answers with, as "Metrics endpoint" in
[`SPEC.md`](../SPEC.md) describes it. Prometheus keeps the `instance` label as
`exported_instance` unless the scrape sets `honor_labels: true`.
What `GET /_smallwebwaf/metrics` answers with. "Metrics endpoint" in
[`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus keeps the `instance`
label as `exported_instance` unless the scrape sets `honor_labels: true`.
## Traffic
+18 -13
View File
@@ -91,24 +91,24 @@ that take `off`, the `_FILE` form, and what each group does and why.
## Static lists
- `SWWAF_ALLOW_NETS` (default empty): netblocks you trust, such as your
monitoring. Their clients are refused only for a size or time limit or a
missing or wrong token.
monitoring (see step 3 in [`SPEC.md`](../SPEC.md)).
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
exempt from rate limiting and the byte limits.
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are always refused.
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are refused (see
step 3 in [`SPEC.md`](../SPEC.md)).
## Request rate limits, per client
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `1000`), `SWWAF_RATE_LIMIT_PER_HOUR`
(default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`): the most
requests a client may make in each window. A count or `off`.
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `6000`), `SWWAF_RATE_LIMIT_PER_HOUR`
(default `100000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `1000000`): the
most requests a client may make in each window. A count or `off`.
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
rate limiting and the byte limits, such as `/assets/`.
## Byte limits, per client
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `10G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `50G`): the most
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `4G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `200G`): the most
bytes counted for a client in each window. A size or `off`.
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
`response`, `request` or `both`.
@@ -131,12 +131,18 @@ that take `off`, the `_FILE` form, and what each group does and why.
to send its whole answer. A duration or `off`.
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body. A size
or `off`.
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body. A size
- `SWWAF_RESPONSE_MAX_BYTES` (default `1G`): the largest response body. A size
or `off`.
## Lookup of AS number and country
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`.
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
the start while a setting that needs a lookup is set:
- the two country lists;
- `SWWAF_ADD_LOOKUP_HEADERS`;
- the four biased threshold lists and `SWWAF_ASN_LIMIT_PERCENT_URL`;
- `SWWAF_UNKNOWN_LIMIT_PERCENT` below `100`;
- an anomaly threshold per AS number.
- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
`.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`.
- `SWWAF_LOOKUP_TIMEOUT` (default `1s`): how long a request waits for its
@@ -190,8 +196,7 @@ that take `off`, the `_FILE` form, and what each group does and why.
- `SWWAF_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body
the Core Rule Set reads, a size of at most `1G`.
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client
in a minute for a rule match, a trap path, a Core Rule Set match or a failed
token check. A count or `off`.
in a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
## Ban lengths, response and scope
@@ -286,7 +291,7 @@ as `REQUESTS_PER_MINUTE`:
client;
- `SWWAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
client.
- `SWWAF_ANOMALY_ASN_...`: per AS number. Needs a lookup source.
- `SWWAF_ANOMALY_ASN_...`: per AS number.
- `SWWAF_ANOMALY_TOTAL_...`: the whole service.
- `SWWAF_WATCH_...`: each netblock in `SWWAF_WATCH_NETS`, as a whole.
`SWWAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
+6 -6
View File
@@ -502,17 +502,17 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
UpstreamRequestTimeout: env.duration("SWWAF_UPSTREAM_REQUEST_TIMEOUT", "60s"),
UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"),
RequestMaxBytes: env.size("SWWAF_REQUEST_MAX_BYTES", "100M"),
ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "5G"),
ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "1G"),
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""),
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "1000"),
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"),
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "6000"),
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "100000"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "1000000"),
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "10G"),
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "4G"),
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "50G"),
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "200G"),
BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"),
LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"),
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""),
+12 -12
View File
@@ -261,10 +261,10 @@ func TestDefaults(t *testing.T) {
UpstreamRequestTimeout: time.Minute,
UpstreamResponseTimeout: 30 * time.Minute,
RequestMaxBytes: 100 << 20,
ResponseMaxBytes: 5 << 30,
RateLimitPerMinute: 1000,
RateLimitPerHour: 10000,
RateLimitPerDay: 50000,
ResponseMaxBytes: 1 << 30,
RateLimitPerMinute: 6000,
RateLimitPerHour: 100000,
RateLimitPerDay: 1000000,
BanResponse: 403,
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour,
@@ -284,8 +284,8 @@ func TestDefaults(t *testing.T) {
LookupSource: defaultLookupSource, LookupTimeout: time.Second,
})
wantByteLimitSettings(t, cfg, config.Config{
BytesLimitPerMinute: 10 << 30, BytesLimitPerHour: 20 << 30,
BytesLimitPerDay: 50 << 30, BytesCount: "both",
BytesLimitPerMinute: 4 << 30, BytesLimitPerHour: 20 << 30,
BytesLimitPerDay: 200 << 30, BytesCount: "both",
})
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
@@ -2371,17 +2371,17 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
upstreamRequestTimeout: "60s",
upstreamResponseTimeout: "30m",
requestMaxBytes: "100M",
responseMaxBytes: "5G",
responseMaxBytes: "1G",
allowNets: "",
rateLimitExemptNets: "",
denyNets: "",
rateLimitPerMinute: "1000",
rateLimitPerHour: "10000",
rateLimitPerDay: "50000",
rateLimitPerMinute: "6000",
rateLimitPerHour: "100000",
rateLimitPerDay: "1000000",
rateLimitExemptPaths: "",
bytesLimitPerMinute: "10G",
bytesLimitPerMinute: "4G",
bytesLimitPerHour: "20G",
bytesLimitPerDay: "50G",
bytesLimitPerDay: "200G",
bytesCount: "both",
lookupSource: defaultLookupSource,
lookupDBPath: "",
+13 -6
View File
@@ -60,8 +60,19 @@ func TestHealthCheck(t *testing.T) {
}, 0, "")
}
app.Close()
wantHealthCheck(t, env, 1, "unhealthy: connect to the app: ")
// Another test can take a closed server's port at once, so the app's
// address here is a multicast address, to which TCP cannot connect.
wantHealthCheck(t, map[string]string{
listenAddr: ":" + port,
upstreamURL: "http://224.0.0.1",
}, 1, "unhealthy: connect to the app: ")
// Listening on port 0 takes any free port, so nothing listens on port 0
// itself.
wantHealthCheck(t, map[string]string{listenAddr: ":0"}, 1,
"unhealthy: ask smallwebwaf: ")
wantHealthCheck(t, map[string]string{listenAddr: "8080"}, 1,
"unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ")
stop()
@@ -70,10 +81,6 @@ func TestHealthCheck(t *testing.T) {
case <-time.After(waitLimit):
t.Fatal("still running after being told to stop")
}
wantHealthCheck(t, env, 1, "unhealthy: ask smallwebwaf: ")
wantHealthCheck(t, map[string]string{listenAddr: "8080"}, 1,
"unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ")
}
func TestHealthCheckRefusesAnArgument(t *testing.T) {
+4 -4
View File
@@ -1240,13 +1240,13 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) {
"SWWAF_UPSTREAM_REQUEST_TIMEOUT": "60s",
"SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m",
"SWWAF_REQUEST_MAX_BYTES": "100M",
"SWWAF_RESPONSE_MAX_BYTES": "5G",
"SWWAF_RESPONSE_MAX_BYTES": "1G",
"SWWAF_ALLOW_NETS": "",
rateLimitExemptNets: "",
"SWWAF_DENY_NETS": "",
"SWWAF_RATE_LIMIT_PER_MINUTE": "1000",
"SWWAF_RATE_LIMIT_PER_HOUR": "10000",
rateLimitPerDay: "50000",
"SWWAF_RATE_LIMIT_PER_MINUTE": "6000",
"SWWAF_RATE_LIMIT_PER_HOUR": "100000",
rateLimitPerDay: "1000000",
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
"SWWAF_DENIED_COUNTRIES": "",
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",