Author SHA1 Message Date
clawbot 1d75ad2d48 Core Rule Set: gitea's ordinary requests pass by default (closes #198)
check / check (push) Failing after 13m57s
The eighth change, internal/waf/gitea.conf, has the fields where gitea
sends a search, a file name or a branch name skip the rules that refused
ordinary gitea requests, on gitea's paths only. Referer skips 944130 too.
A registry's layer reuse skips 932260 on from, the seventh change.

The new setting SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE loads an operator's
own exclusions before the Core Rule Set's rules. A set file that cannot
be read, does not parse or names smallwebwaf's own ids stops the start
in every mode.

A test sends the recorded gitea requests at the defaults, none refused,
and three attacks, all refused.

Model: opus-5-5
2026-11-07 02:15:39 +00:00
sneak f8676debda Merge branch 'main' into next
check / check (push) Successful in 5m27s
2026-11-07 02:52:20 +01:00
clawbot a41d61e9ad Install the image's Ubuntu packages from the normal mirrors (closes #190)
check / check (push) Successful in 3m14s
The image's `ca-certificates`, `nix-bin` and `runit` now come from Ubuntu's archive instead of a dated snapshot, whose service had failed image builds. apt fetches them over plain HTTP and checks each against the archive's signed package lists, so no CA certificates are borrowed from the Go image. The README, `SPEC.md` and `docs/image.md` say so; the amd64-only limit, which came from the removed hash check, is gone.

Rule cut against: `REPO_POLICIES.md` pins every external reference by hash; sneak's ruling at sneak/prompts#72 (comment) allows Ubuntu's own package index.

Model: deepseek-v4-pro (implementation), opus-5-5 (rework)
2026-11-06 19:15:28 +00:00
clawbot 84a9190d2b next into main
check / check (push) Successful in 4m1s
Merged by sneak's order of 6 November ("take the current state of smallwebwaf and get it on to main. i am going to deploy it soon in its alpha state").

Model: opus-5-5
2026-11-06 18:58:02 +01:00
sneak 1bdf66cd21 Merge pull request 'next into main' (#160) from next into main
check / check (push) Canceled after 0s
Reviewed-on: #160
2026-10-26 13:11:04 +01:00
48 changed files with 1489 additions and 1179 deletions
+10 -6
View File
@@ -192,9 +192,9 @@ account, a key, a token or an alert destination stays off until you give one.
- Any `SWWAF_` setting can be given as a file instead, for secrets and long - Any `SWWAF_` setting can be given as a file instead, for secrets and long
lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`. lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`.
The file's contents are the value, less one newline at the end. Setting both The file's contents are the value, less one newline at the end. Setting both
forms stops the start. `SWWAF_LOG_REMOTE_TLS_CA_FILE` already names a file, so forms stops the start. `SWWAF_LOG_REMOTE_TLS_CA_FILE` and
it has no `_FILE` form, and no `SWRP_` setting has one, since `rproxy` never `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no
touches disk. `_FILE` form. No `SWRP_` setting has one, since `rproxy` never touches disk.
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. - A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of - A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
1024: `512K`, `100M`, `5G`. 1024: `512K`, `100M`, `5G`.
@@ -272,6 +272,7 @@ Every setting the code reads, with its default:
| `SWWAF_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. | | `SWWAF_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWWAF_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. | | `SWWAF_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWWAF_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. | | `SWWAF_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWWAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. | | `SWWAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWWAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. | | `SWWAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWWAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. | | `SWWAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
@@ -636,10 +637,13 @@ and the status for a size or time limit, such as `413` for a body too large.
headers of every request, and no body unless `SWWAF_WAF_BODY_LIMIT` is set. In headers of every request, and no body unless `SWWAF_WAF_BODY_LIMIT` is set. In
front of a code forge, bodies carry what people write and publish, which the front of a code forge, bodies carry what people write and publish, which the
rules take for attacks. rules take for attacks.
- `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary - `smallwebwaf` changes the Core Rule Set in eight ways so that it lets ordinary
requests to a code forge through, such as git over HTTP and sign-in through requests to a code forge through, such as git over HTTP and sign-in through
Git Credential Manager. No setting undoes them; "Attack detection" in Git Credential Manager. On gitea's own paths, its searches, file names and
[`SPEC.md`](SPEC.md) lists them. branch names get through too. No setting undoes the changes; "Attack
detection" in [`SPEC.md`](SPEC.md) lists them.
- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's
own fields or paths.
- Rule files are plain text, one rule to a line, edited by hand and read again - Rule files are plain text, one rule to a line, edited by hand and read again
while `servewaf` runs. The image ships one file of probes no real visitor while `servewaf` runs. The image ships one file of probes no real visitor
sends (see [`docs/rules.md`](docs/rules.md)). sends (see [`docs/rules.md`](docs/rules.md)).
+91 -44
View File
@@ -36,7 +36,8 @@ to them.
- the state files; - the state files;
- a lookup database; - a lookup database;
- a `SWWAF_` setting given as a file; - a `SWWAF_` setting given as a file;
- the file `SWWAF_LOG_REMOTE_TLS_CA_FILE` names. - the file `SWWAF_LOG_REMOTE_TLS_CA_FILE` names;
- the file `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` names.
## Non-goals ## Non-goals
@@ -81,7 +82,7 @@ to them.
of bans is full. of bans is full.
- `internal/rules`: reads the rule files at start and again when they - `internal/rules`: reads the rule files at start and again when they
change, and tells which rules a request matches. change, and tells which rules a request matches.
- `internal/waf`: the Core Rule Set, with the six changes listed under - `internal/waf`: the Core Rule Set, with the eight changes listed under
"Attack detection", as Coraza directives. "Attack detection", as Coraza directives.
- `internal/lookup`: looks clients up through GeoJS or in the lookup - `internal/lookup`: looks clients up through GeoJS or in the lookup
database. database.
@@ -123,9 +124,7 @@ runs steps 3 to 13, and does the counting of step 15. The first step that
produces a final answer ends the checks. Step 15 runs for each request produces a final answer ends the checks. Step 15 runs for each request
`servewaf` answers with `allow`, `deny` or `close`. `rproxy`'s size and time `servewaf` answers with `allow`, `deny` or `close`. `rproxy`'s size and time
limits apply to the whole exchange. Each step names the section that holds its limits apply to the whole exchange. Each step names the section that holds its
rules. Each check is a heuristic. Its name, in parentheses below, is the value rules.
of the request log's `heuristic` field. `refusal_limit` and `byte_limits` give
theirs in `after_answer_heuristic` instead.
1. `GET /_smallwebwaf/healthz` is answered at once by `rproxy`, before any 1. `GET /_smallwebwaf/healthz` is answered at once by `rproxy`, before any
check, so a health checker never needs an exemption and is never refused. check, so a health checker never needs an exemption and is never refused.
@@ -143,9 +142,9 @@ theirs in `after_answer_heuristic` instead.
around its address, a /64 by default, since one abuser usually controls a 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. 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, the 3. The static lists. A client in `SWWAF_ALLOW_NETS` skips steps 4 to 12, the
byte limits and the error burst. A client in `SWWAF_DENY_NETS` is refused byte limits and the error burst. A client in `SWWAF_DENY_NETS` is refused; a
(`deny_nets`); a client in both lists is let through. client in both lists is let through.
4. Bans. A client under a ban is refused (`active_ban`; see "Bans"). 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 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 and country"). The request goes on at once. It waits for the first GeoJS
answer, up to `SWWAF_LOOKUP_TIMEOUT`, only when one of these needs it: answer, up to `SWWAF_LOOKUP_TIMEOUT`, only when one of these needs it:
@@ -155,26 +154,22 @@ theirs in `after_answer_heuristic` instead.
A request that ends before the answer comes is logged without it. A request that ends before the answer comes is logged without it.
6. The country lists (`countries`; see "Country lists"). 6. The country lists (see "Country lists").
7. The reputation sources (see "Reputation"), in this order: 7. The reputation sources (see "Reputation"). A client on a fetched blocklist
- A client on a fetched blocklist gets `SWWAF_BLOCKLIST_ACTION` gets `SWWAF_BLOCKLIST_ACTION`. A client on the CrowdSec decision list is
(`blocklists`). banned. A client listed by a DNS blocklist, or scored at least
- A client on the CrowdSec decision list is banned (`crowdsec`). `SWWAF_ABUSEIPDB_MIN_SCORE` by AbuseIPDB, gets `SWWAF_REPUTATION_ACTION`.
- A client listed by a DNS blocklist gets `SWWAF_REPUTATION_ACTION`
(`dnsbl`).
- A client scored at least `SWWAF_ABUSEIPDB_MIN_SCORE` by AbuseIPDB gets
`SWWAF_REPUTATION_ACTION` (`abuseipdb`).
8. The client's limit percentage (see "Biased thresholds"). 8. The client's limit percentage (see "Biased thresholds").
9. Rate limiting (`rate_limits`; see "Request rate limits, per client"). A 9. Rate limiting (see "Request rate limits, per client"). A request over a limit
request over a limit times the percentage is refused and bans the client. times the percentage is refused and bans the client.
10. The trap paths (`trap_paths`; see "Attack detection"). A request for one is 10. The trap paths (see "Attack detection"). A request for one is refused and
refused and bans the client. bans the client.
11. The rule files (`rule_files`; see "Rule files"). Matching stops at the first 11. The rule files (see "Rule files"). Matching stops at the first rule that
rule that refuses. refuses.
12. The header limits (`header_limits`; see "Answering in time"), and then the 12. The header limits (see "Answering in time"), and then the Core Rule Set (see
Core Rule Set (`core_rule_set`; see "Attack detection"). A Core Rule Set "Attack detection"). A Core Rule Set match is refused with `403` while
match is refused with `403` while `SWWAF_WAF_MODE` is `block`, and only `SWWAF_WAF_MODE` is `block`, and only logged and alerted while it is
logged and alerted while it is `detect`. `detect`.
13. A path under `/_smallwebwaf/` is answered `404`, so that the app never sees 13. A path under `/_smallwebwaf/` is answered `404`, so that the app never sees
that prefix. that prefix.
14. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing 14. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing
@@ -188,14 +183,14 @@ theirs in `after_answer_heuristic` instead.
are added to the byte counters, as `SWWAF_BYTES_COUNT` says. For a are added to the byte counters, as `SWWAF_BYTES_COUNT` says. For a
WebSocket, what it carried each way counts once it closes. A client whose WebSocket, what it carried each way counts once it closes. A client whose
total passes a byte limit times the percentage has broken that limit and total passes a byte limit times the percentage has broken that limit and
is banned (`byte_limits`). The response in progress is not cut off. is banned. The response in progress is not cut off.
- For a request it allowed, the client's history and the anomaly counters - For a request it allowed, the client's history and the anomaly counters
are updated, and the alert thresholds are evaluated. are updated, and the alert thresholds are evaluated.
For a request it refused, `servewaf` adds nothing to the byte limits. It For a request it refused, `servewaf` adds nothing to the byte limits. It
updates the history and the anomaly counters at its decision. It counts the updates the history and the anomaly counters at its decision. It counts the
refusals the error burst counts at the decision too (`refusal_limit`; see refusals the error burst counts at the decision too (see "Attack
"Attack detection"). detection").
In `SWWAF_MODE=observe`, every refusal above is logged as what would have In `SWWAF_MODE=observe`, every refusal above is logged as what would have
happened, nothing is refused, and `servewaf` makes no ban of its own. A path happened, nothing is refused, and `servewaf` makes no ban of its own. A path
@@ -331,9 +326,10 @@ Conventions:
lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`. lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`.
The file's contents are the value, less one newline at the end, and are The file's contents are the value, less one newline at the end, and are
checked as the value itself would be. Setting both forms stops the start, and checked as the value itself would be. Setting both forms stops the start, and
so does naming a file that cannot be read. `SWWAF_LOG_REMOTE_TLS_CA_FILE` so does naming a file that cannot be read. `SWWAF_LOG_REMOTE_TLS_CA_FILE` and
already holds a file name, so it has no `_FILE` form. No `SWRP_` setting has `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` already hold a file name, so they have
one, since `rproxy` never touches disk. no `_FILE` form. No `SWRP_` setting has one, since `rproxy` never touches
disk.
- Settings, files included, are read once at start. Changing one means - Settings, files included, are read once at start. Changing one means
restarting the container. The files `servewaf` watches while it runs are its restarting the container. The files `servewaf` watches while it runs are its
state files, its rule files and the lookup database. state files, its rule files and the lookup database.
@@ -589,6 +585,24 @@ sends it.
probes these rules caught (see "Rule files"). A list you give replaces the probes these rules caught (see "Rule files"). A list you give replaces the
default, so name these six ids in it to keep them off. default, so name these six ids in it to keep them off.
- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as
Coraza directives. An exclusion switches one rule off for some fields or paths
only, where `SWWAF_WAF_DISABLED_RULES` switches it off everywhere. The file
holds an app's own exclusions; the eighth change below holds gitea's.
- `servewaf` loads the file after `smallwebwaf`'s own setup rules and before
the Core Rule Set's rules. The Core Rule Set puts its own file of
exclusions that run before the rules in the same place.
- So the file holds only `SecAction` and `SecRule` lines that exclude with
`ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. These run with each
request, before the rules they exclude, those of phase 1 included.
- A rule that `SWWAF_WAF_DISABLED_RULES` switches off and the file names is
simply off.
- The start stops on a file that cannot be read or does not parse, whatever
`SWWAF_WAF_MODE` is.
- It stops too on a file that names a number from 900000 to 900999, the ids
of `smallwebwaf`'s own rules, alone or in a range. Comment lines are not
checked.
- `SWWAF_WAF_BODY_LIMIT` switches body inspection on, up to that size. It takes - `SWWAF_WAF_BODY_LIMIT` switches body inspection on, up to that size. It takes
at most `64K`, since `servewaf` gets no more of a body than that (see "The at most `64K`, since `servewaf` gets no more of a body than that (see "The
answer"). Form data and multipart bodies are read up to the size. JSON and XML answer"). Form data and multipart bodies are read up to the size. JSON and XML
@@ -618,7 +632,7 @@ sends it.
scanner from an ordinary client. In front of gitea, registry and git clients scanner from an ordinary client. In front of gitea, registry and git clients
get `404` and `401` by design, hundreds of times a minute. 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 `smallwebwaf` changes the Core Rule Set 4.25.0 in eight ways, since in front of
gitea it would otherwise refuse ordinary requests. The changes hold in front of gitea it would otherwise refuse ordinary requests. The changes hold in front of
every app, and no setting undoes them: every app, and no setting undoes them:
@@ -650,9 +664,10 @@ every app, and no setting undoes them:
or `docker-build`. In these parameters, what only these three rules would or `docker-build`. In these parameters, what only these three rules would
catch reaches the app, such as the command `|cat /etc/passwd`. Path 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 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 still refused there. Other parameters keep all three rules, except on the
that uses one of these parameters as a file on the server, or passes it to a gitea paths the eighth change names. An app that uses one of these
shell, must check the value itself. A rule file can refuse it instead. parameters as a file on the server, or passes it to a shell, must check the
value itself. A rule file can refuse it instead.
5. Two cookies are left out, and `Referer` keeps fewer rules: 5. Two cookies are left out, and `Referer` keeps fewer rules:
- The Core Rule Set reads the request without the `gitea_flash` and - The Core Rule Set reads the request without the `gitea_flash` and
@@ -666,15 +681,47 @@ every app, and no setting undoes them:
only to its own pages. Every other cookie is read in full. An app that only to its own pages. Every other cookie is read in full. An app that
reads a cookie of either name must check it itself. reads a cookie of either name must check it itself.
- `Referer` is not checked for a Unix command given without arguments - `Referer` is not checked for a Unix command given without arguments
(932340) or for Java starting a process (944110). `Referer` names the page (932340), for Java starting a process (944110) or for a Java class name
a request comes from, on everything that page loads and on every link (944130). `Referer` names the page a request comes from, on everything
followed from it. The Core Rule Set takes some page addresses for attacks, that page loads and on every link followed from it. The Core Rule Set
such as a search for `env`, or OpenJDK's `Runtime.java`, whose path holds takes some page addresses for attacks, such as a search for `env`,
both `runtime` and `java.`. All of those requests would be refused. OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`, or
`Referer` keeps the other rules. An app that passes `Referer` to a shell a file named `java.lang.String.md`. All of those requests would be
must check it itself. refused. `Referer` keeps the other rules, except after a gitea search (the
eighth change). An app that passes `Referer` to a shell must check it
itself.
6. Responses are not inspected. A raw file from a repository, such as a shell 6. Responses are not inspected. A raw file from a repository, such as a shell
script, looks to the response rules like source code leaking from the server. script, looks to the response rules like source code leaking from the server.
7. The `from` parameter of a container registry's upload path,
`/v2/<image>/blobs/uploads/`, is not checked for command names (932260). When
a push reuses a layer, `from` names the image the layer comes from. An image
such as `owner/python3-app` would have that push refused.
8. On gitea's own paths, the fields where gitea sends a search, a file name or a
branch name skip the rules that refused ordinary gitea requests there.
`internal/waf/gitea.conf` lists each rule and the request it refused. The
fields are:
- `q` on gitea's search pages, for code, issues, pull requests, commits,
repositories, branches, tags, packages and users. A code search holds
code, such as `<script>`, `SELECT * FROM users WHERE` or `../`.
- `Referer` of a page opened from such a search, for script and SQL
injection and Java code (941110, 941120, 941160, 942100, 944100).
- The path of a file's page, raw file, download, blame, history, edit and
delete pages, and of the API's call for its contents. Names such as
`notes.txt~`, `process.env.d.ts` and `java.lang.String.md` were refused.
- A file name in a query parameter: `file` of a commit graph, `path` of the
API's list of commits, `body` of the API's call for several files,
`template` of a new issue, `redirect_to` of the sign-in page, and `name`
of an attachment uploaded through the API.
- A branch or tag name in a query parameter, for command names (932260).
gitea sends one to delete or restore a branch, to start a release from a
tag, to edit a branch protection rule, and to list a directory's last
commits.
- The `Content-Range` header of an artifact upload from
`actions/upload-artifact@v3` (920450).
Elsewhere these fields keep every rule. The paths are those of gitea at the
site root. An app other than gitea that serves one of them must check these
fields itself.
The parameter and cookie names in these changes are matched without regard to The parameter and cookie names in these changes are matched without regard to
case, as Coraza matches them. case, as Coraza matches them.
-29
View File
@@ -107,35 +107,6 @@ Every one of these lines has:
- `would_action`: in `observe` mode, what `enforce` mode would have done with a - `would_action`: in `observe` mode, what `enforce` mode would have done with a
request it would have refused. `action` then names what was done, normally request it would have refused. `action` then names what was done, normally
`forward`. `forward`.
- `heuristic`: the heuristic that refused the request, in `observe` mode too. It
is left out when none did.
- `heuristic_figures`: an object of the figures that heuristic acted on. Each
heuristic and its figures:
- `deny_nets`: `netblock`, the entry of `SWWAF_DENY_NETS` the client is in;
- `active_ban`: `netblock`, the ban's netblock, and `ban_expires`;
- `countries`: `country`, and `setting`, the country list that refused it;
- `blocklists`: `lists`, the blocklist URLs that list the client;
- `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the
decision ends;
- `dnsbl`: `zones`, the DNS blocklist zones that list the client, their keys
masked;
- `abuseipdb`: `score`, and `min_score`, `SWWAF_ABUSEIPDB_MIN_SCORE`;
- `rate_limits`: `window`, `count` and `limit`;
- `trap_paths`: `path`, the trap path asked for;
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
- `core_rule_set`: `score`, and `threshold`, `SWWAF_WAF_ANOMALY_THRESHOLD`;
- `refusal_limit`: `window`, `count` and `limit`, of the error burst;
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
- `after_answer_heuristic`: the heuristic that banned the client after
`servewaf` answered the request, in `observe` mode too: `refusal_limit` or
`byte_limits`.
- `after_answer_heuristic_figures`: an object of the figures that heuristic
acted on, as listed above.
`byte_limits` bans when the report comes, so only the `report` line names
it.
- `limit_percent` and `limit_percent_setting`: the client's percentage of the - `limit_percent` and `limit_percent_setting`: the client's percentage of the
rate limits and the setting that lowered it, when one did. `bytes_percent` and rate limits and the setting that lowered it, when one did. `bytes_percent` and
`bytes_percent_setting` do the same for the byte limits. `bytes_percent_setting` do the same for the byte limits.
+7
View File
@@ -192,6 +192,13 @@ chooses between the two (see "The image's command" below).
the Core Rule Set reads, a size of at most `64K`. A question carries no more the Core Rule Set reads, a size of at most `64K`. A question carries no more
of a body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one of a body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one
byte of it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)). byte of it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
exclusions, written as Coraza directives. It is loaded before the Core Rule
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
read or does not parse stops the start, whatever `SWWAF_WAF_MODE` is. So does
a file that names a number from 900000 to 900999 outside a comment, alone or
in a range. It has no `_FILE` form.
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client - `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client
in a minute (see "Attack detection" in `SPEC.md`). A count or `off`. in a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
+15
View File
@@ -221,12 +221,15 @@ type Config struct {
// WAFExemptPaths the path prefixes it does not inspect // WAFExemptPaths the path prefixes it does not inspect
// (SWWAF_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body // (SWWAF_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
// it reads (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none. // it reads (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none.
// CoreRuleSetExclusionsFile is the path of the file of its exclusions
// (SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none.
WAFMode string WAFMode string
WAFParanoiaLevel int WAFParanoiaLevel int
WAFAnomalyThreshold int WAFAnomalyThreshold int
WAFDisabledRules []int WAFDisabledRules []int
WAFExemptPaths []string WAFExemptPaths []string
WAFBodyLimit int64 WAFBodyLimit int64
CoreRuleSetExclusionsFile string
// TrapPaths are the paths a request for which is a clear sign of // TrapPaths are the paths a request for which is a clear sign of
// attack (SWWAF_TRAP_PATHS), each starting with / and without a ?. // attack (SWWAF_TRAP_PATHS), each starting with / and without a ?.
TrapPaths []string TrapPaths []string
@@ -525,6 +528,8 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
"920340,920420,920440,920640,930130,930140"), "920340,920420,920440,920640,930130,930140"),
WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""), WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""),
WAFBodyLimit: env.wafBodyLimit("SWWAF_WAF_BODY_LIMIT", off), WAFBodyLimit: env.wafBodyLimit("SWWAF_WAF_BODY_LIMIT", off),
CoreRuleSetExclusionsFile: env.filePath(
"SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE"),
TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"), TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"),
ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"), ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"),
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"), LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
@@ -1189,6 +1194,16 @@ func (e *environment) logRemoteURL(name string) *url.URL {
return remote return remote
} }
// filePath reads a setting that is the path of a file, "" while it is
// unset. Its value names a file already, so, unlike the other settings,
// it has no _FILE form. Whoever uses the file reads it.
func (e *environment) filePath(name string) string {
path, _ := e.lookupEnv(name)
e.settings = append(e.settings, slog.String(name, path))
return path
}
// certificates reads a setting that is the path of a file of PEM // certificates reads a setting that is the path of a file of PEM
// certificates. Unset or empty, it is nil. Its value names a file // certificates. Unset or empty, it is nil. Its value names a file
// already, so, unlike the other settings, it has no _FILE form. // already, so, unlike the other settings, it has no _FILE form.
+5 -1
View File
@@ -87,6 +87,7 @@ const (
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES" wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS" wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT" wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
exclusionsFile = "SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE"
trapPaths = "SWWAF_TRAP_PATHS" trapPaths = "SWWAF_TRAP_PATHS"
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD" errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
logRemoteURL = "SWWAF_LOG_REMOTE_URL" logRemoteURL = "SWWAF_LOG_REMOTE_URL"
@@ -539,13 +540,14 @@ func TestCoreRuleSetSettings(t *testing.T) {
environment{ environment{
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10", wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/", wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
wafBodyLimit: "32K", wafBodyLimit: "32K", exclusionsFile: "/etc/smallwebwaf/crs-exclusions.conf",
}, },
config.Config{ config.Config{
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10, WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
WAFDisabledRules: []int{942100, 920350}, WAFDisabledRules: []int{942100, 920350},
WAFExemptPaths: []string{"/api/", "/static/"}, WAFExemptPaths: []string{"/api/", "/static/"},
WAFBodyLimit: 32 << 10, WAFBodyLimit: 32 << 10,
CoreRuleSetExclusionsFile: "/etc/smallwebwaf/crs-exclusions.conf",
}, },
}, },
{ {
@@ -566,6 +568,7 @@ func TestCoreRuleSetSettings(t *testing.T) {
WAFAnomalyThreshold: cfg.WAFAnomalyThreshold, WAFAnomalyThreshold: cfg.WAFAnomalyThreshold,
WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths, WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths,
WAFBodyLimit: cfg.WAFBodyLimit, WAFBodyLimit: cfg.WAFBodyLimit,
CoreRuleSetExclusionsFile: cfg.CoreRuleSetExclusionsFile,
} }
if !reflect.DeepEqual(got, tc.want) { if !reflect.DeepEqual(got, tc.want) {
t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want) t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want)
@@ -2327,6 +2330,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
wafDisabledRules: defaultWAFDisabledRules, wafDisabledRules: defaultWAFDisabledRules,
wafExemptPaths: "", wafExemptPaths: "",
wafBodyLimit: off, wafBodyLimit: off,
exclusionsFile: "",
trapPaths: "", trapPaths: "",
errorBurstThreshold: "30", errorBurstThreshold: "30",
logRemoteURL: "", logRemoteURL: "",
-10
View File
@@ -131,16 +131,6 @@ type Line struct {
// ActionRuleBlocked, ActionWAFBlocked, or, in servewaf, ActionTooLarge // ActionRuleBlocked, ActionWAFBlocked, or, in servewaf, ActionTooLarge
// for the header limits. // for the header limits.
WouldAction string `json:"would_action,omitempty"` WouldAction string `json:"would_action,omitempty"`
// Heuristic is the name of the heuristic that refused the request, in
// observe mode too, and HeuristicFigures are the figures it acted on, by
// name.
Heuristic string `json:"heuristic,omitempty"`
HeuristicFigures map[string]any `json:"heuristic_figures,omitempty"`
// AfterAnswerHeuristic and AfterAnswerHeuristicFigures are the same for
// the heuristic that banned the client after servewaf answered the
// request.
AfterAnswerHeuristic string `json:"after_answer_heuristic,omitempty"`
AfterAnswerHeuristicFigures map[string]any `json:"after_answer_heuristic_figures,omitempty"` //nolint:lll // a struct tag cannot be split
// LimitPercent and LimitPercentSetting are, for a request the rate // LimitPercent and LimitPercentSetting are, for a request the rate
// limits counted whose client a biased threshold gives a percentage of // limits counted whose client a biased threshold gives a percentage of
// the rate limits below 100, that percentage and the setting that gave // the rate limits below 100, that percentage and the setting that gave
-55
View File
@@ -1,55 +0,0 @@
package servewaf
// The heuristic abuseipdb. It reads SWWAF_ABUSEIPDB_KEY,
// SWWAF_ABUSEIPDB_MIN_SCORE and SWWAF_REPUTATION_ACTION.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// abuseIPDB notes AbuseIPDB, as noteHit does, with the score, when its score
// of the client is a hit, and refuses the request when SWWAF_REPUTATION_ACTION
// is deny, as dnsbl does for a zone. While SWWAF_ABUSEIPDB_KEY is unset it does
// nothing. A client without a score is checked in the background, by the
// request's address, if its history counts an offence, and the request does
// not wait for the answer. The score is then used for each address of the
// client. Its figures are the score and SWWAF_ABUSEIPDB_MIN_SCORE.
type abuseIPDB struct{}
func (abuseIPDB) name() string { return "abuseipdb" }
func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
if rq.h.config.AbuseIPDBKey == "" {
return nil
}
client := rq.h.clientGroup(rq.client)
held, _ := rq.h.limiter.Client(client)
offender := held.History.Offences != ratelimit.Offences{}
score, hit := rq.h.abuseIPDB.Hit(ctx, client, rq.client, offender)
if !hit {
return nil
}
rq.abuseIPDBHit = true
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
"scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE")
if rq.h.config.ReputationAction != deny {
return nil
}
return &verdict{
action: requestlog.ActionDenied,
figures: map[string]any{
figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore,
},
}
}
-12
View File
@@ -91,18 +91,6 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
} }
} }
func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", nil)
req := s.question(siteA, client, "/")
fillHeaders(req, headerFieldLimit+1)
do(t, req)
wantHeuristic(t, s.out.requestLine(t), "header_limits",
`{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`)
}
func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) { func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
t.Parallel() t.Parallel()
+79 -25
View File
@@ -1,10 +1,7 @@
package servewaf package servewaf
// The heuristic active_ban, and the code that makes bans, which several
// heuristics share. active_ban reads SWWAF_MODE; the bans are the ban ledger's.
import ( import (
"context" "net/http"
"net/netip" "net/netip"
"time" "time"
@@ -21,15 +18,11 @@ func (rq *request) banResponse(action string) *refusal {
return &refusal{status: rq.h.config.BanResponse, action: action} return &refusal{status: rq.h.config.BanResponse, action: action}
} }
// activeBan refuses a client a ban covers: a ban on a netblock the client is // banned reports whether a ban on a netblock the client is in covers the
// in, in force at now. It notes for the log line when that ban ends. A request // request at now, and notes for the log line when that ban ends. A
// that makes the ban permanent, or in observe mode would have, raises the alert // request that makes the ban permanent, or in observe mode would have,
// for it. Its figures are the ban's netblock and when it ends. // raises the alert for it.
type activeBan struct{} func (rq *request) banned(now time.Time) bool {
func (activeBan) name() string { return "active_ban" }
func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
check := rq.h.ledger.Check check := rq.h.ledger.Check
if rq.h.config.Observe { if rq.h.config.Observe {
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
@@ -45,24 +38,85 @@ func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
rq.alertBan(ban) rq.alertBan(ban)
} }
if !banned { return banned
return nil }
// limitBroken counts the request for the rate limits at now, notes the
// client's counts for the log line, and reports whether the request takes
// the client over a rate limit, as its limit percentage lowers it, which
// breaks it.
func (rq *request) limitBroken(now time.Time) bool {
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
rq.limitPercent.percent)
rq.line.Counts = counts
if over {
rq.banForLimit(now, hit, rq.h.config.BanResponse)
} }
return &verdict{ return over
action: requestlog.ActionBanned, }
figures: map[string]any{
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires, // countBytes counts the request's bytes, as countedBytes gives them, for
}, // the byte limits, once its report has come, and notes the client's byte totals
// for the log line; its requests stay there as the rate limits counted them.
// Only an allowed request has them counted, and only one the rate limits
// counted; in observe mode, not one that enforce mode would have refused. Bytes
// that take the client over a byte limit, as its limit percentage for the byte
// limits lowers it, break it; the response was passed on whole.
func (rq *request) countBytes() {
if !rq.counted || rq.line.WouldAction != "" {
return
}
now := rq.h.now()
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
rq.countedBytes(), rq.bytesPercent.percent)
rq.line.Counts.MinuteBytes = counts.MinuteBytes
rq.line.Counts.HourBytes = counts.HourBytes
rq.line.Counts.DayBytes = counts.DayBytes
if over {
rq.banForLimit(now, hit, rq.out.status)
} }
} }
// limitFigures are the figures of a heuristic that broke hit, a limit: its // countRefusal counts the request for the error burst once it has been
// window, the count and the limit. // answered, if servewaf refused it after a rule file match, a trap path or a
func limitFigures(hit ratelimit.Hit) map[string]any { // Core Rule Set match, and in observe mode if enforce mode would have: more than
return map[string]any{ // SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute
figureWindow: hit.Window, figureCount: hit.Count, figureLimit: hit.Limit, // break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is
// not counted, and nothing is while the threshold is off.
func (rq *request) countRefusal() {
cfg := rq.h.config
if cfg.ErrorBurstThreshold == 0 {
return
} }
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
return
}
now := rq.h.now()
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
cfg.ErrorBurstThreshold)
if !over {
return
}
// What the client was sent, or in observe mode would have been.
status := rq.out.status
switch rq.line.WouldAction {
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
status = http.StatusForbidden
case requestlog.ActionBanned:
status = cfg.BanResponse
}
rq.banForLimit(now, hit, status)
} }
// countedBytes returns the request's bytes, once it has ended, as the // countedBytes returns the request's bytes, once it has ended, as the
-16
View File
@@ -228,22 +228,6 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
} }
} }
func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
t.Parallel()
s, clk, _ := startWithClock(t, "", map[string]string{
rateLimitPerMinute: "1", banScopeV4Prefix: "24",
})
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
wantHeuristic(t, line, "active_ban",
`{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`)
}
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) { func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
t.Parallel() t.Parallel()
-41
View File
@@ -1,41 +0,0 @@
package servewaf
// The heuristic blocklists. It reads SWWAF_BLOCKLIST_ACTION, and the blocklists
// through the reputation lists.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// blocklists notes the blocklists that list the client, as noteListed does,
// and refuses the request when SWWAF_BLOCKLIST_ACTION is deny. Being limit, it
// lowers the client's limits instead (see limitPercentages), and being log, it
// does nothing more. Its figure is the blocklists that list the client.
type blocklists struct{}
func (blocklists) name() string { return "blocklists" }
func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
listedBy := rq.h.lists.ListedBy(rq.client)
rq.blocklisted = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a blocklist")
if !rq.refusedByBlocklists(listedBy) {
return nil
}
return &verdict{
action: requestlog.ActionDenied,
figures: map[string]any{"lists": listedBy},
}
}
// refusedByBlocklists reports whether the heuristic blocklists refuses a
// client, given listedBy, the blocklists that list it: it does when one lists
// it and SWWAF_BLOCKLIST_ACTION is deny.
func (rq *request) refusedByBlocklists(listedBy []string) bool {
return len(listedBy) > 0 && rq.h.config.BlocklistAction == deny
}
-43
View File
@@ -1,43 +0,0 @@
package servewaf
// The heuristic byte_limits. It reads SWWAF_BYTES_COUNT, and the byte limits
// and biased thresholds through the limiter and limitPercentages.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// byteLimits counts the request's bytes, as countedBytes gives them, for the
// byte limits, once its report has come, and notes the client's byte totals for
// the log line; its requests stay there as the rate limits counted them. Only
// an allowed request has them counted, and only one the rate limits counted; in
// observe mode, not one that enforce mode would have refused. Bytes that take
// the client over a byte limit, as its limit percentage for the byte limits
// lowers it, break it, which bans the client; the response was passed on whole.
// Its figures are the window, the count and the limit.
type byteLimits struct{}
func (byteLimits) name() string { return "byte_limits" }
func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
if !rq.counted || rq.line.WouldAction != "" {
return nil
}
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
rq.countedBytes(), rq.bytesPercent.percent)
rq.line.Counts.MinuteBytes = counts.MinuteBytes
rq.line.Counts.HourBytes = counts.HourBytes
rq.line.Counts.DayBytes = counts.DayBytes
if !over {
return nil
}
rq.banForLimit(now, hit, rq.out.status)
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
}
-15
View File
@@ -98,21 +98,6 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
} }
} }
func TestByteLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: byteLimit})
line, _ := s.download()
wantAfterAnswerHeuristic(t, line, "", "null")
// Two answers of 70 bytes are over the limit of 99.
line, _ = s.download()
wantHeuristic(t, line, "", "null")
wantAfterAnswerHeuristic(t, line, "byte_limits",
`{"window": "minute", "count": 140, "limit": 99}`)
}
func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) { func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
t.Parallel() t.Parallel()
+9 -19
View File
@@ -1,10 +1,6 @@
package servewaf package servewaf
// The heuristic core_rule_set. It reads SWWAF_WAF_MODE, SWWAF_WAF_EXEMPT_PATHS
// and SWWAF_WAF_ANOMALY_THRESHOLD.
import ( import (
"context"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
@@ -13,21 +9,18 @@ import (
"sneak.berlin/go/smallwebwaf/internal/waf" "sneak.berlin/go/smallwebwaf/internal/waf"
) )
// coreRuleSet inspects the request with the Core Rule Set, unless // checkCoreRuleSet inspects the request with the Core Rule Set, unless
// SWWAF_WAF_MODE is off or SWWAF_WAF_EXEMPT_PATHS exempts its path, as // SWWAF_WAF_MODE is off or SWWAF_WAF_EXEMPT_PATHS exempts its path, as
// pathExempt decides, and notes the rules it matched and its score in the // pathExempt decides, and notes the rules it matched and its score in the
// log line, and the rules in the metrics. A score at or over // log line, and the rules in the metrics. A score at or over
// SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert, // SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
// and in block mode refuses the request with 403, which is an offence its // and in block mode refuses the request, which is an offence its client's
// client's history counts. Its figures are the score and the threshold. // history counts, and so returns ActionWAFBlocked. It returns "" for a
type coreRuleSet struct{} // request it does not refuse.
func (rq *request) checkCoreRuleSet() string {
func (coreRuleSet) name() string { return "core_rule_set" }
func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
cfg := rq.h.config cfg := rq.h.config
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) { if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
return nil return ""
} }
start := time.Now() start := time.Now()
@@ -47,21 +40,18 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
threshold := cfg.WAFAnomalyThreshold threshold := cfg.WAFAnomalyThreshold
if threshold == 0 || result.Score < threshold { if threshold == 0 || result.Score < threshold {
return nil return ""
} }
rq.alertWAFBlock(result) rq.alertWAFBlock(result)
if cfg.WAFMode == config.WAFModeDetect { if cfg.WAFMode == config.WAFModeDetect {
return nil return ""
} }
rq.wafBlocked = true rq.wafBlocked = true
return &verdict{ return requestlog.ActionWAFBlocked
action: requestlog.ActionWAFBlocked,
figures: map[string]any{figureScore: result.Score, "threshold": threshold},
}
} }
// alertWAFBlock raises the waf_block alert for the request, which the Core // alertWAFBlock raises the waf_block alert for the request, which the Core
-10
View File
@@ -110,16 +110,6 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
} }
} }
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionWAFBlocked)
wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`)
}
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) { func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
t.Parallel() t.Parallel()
+8 -29
View File
@@ -1,42 +1,21 @@
package servewaf package servewaf
// The heuristic countries. It reads SWWAF_DENIED_COUNTRIES and
// SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
import ( import (
"context"
"slices" "slices"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// countries refuses a request by the client's country as it was looked up. A // countryDenied reports whether the country lists refuse the request, by
// client without a country, or whose country cannot be found, is refused only // the client's country as it was looked up. A client without a country,
// by SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES. Its figures are the country and the // or whose country cannot be found, is refused only by
// setting that refused it. // SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
type countries struct{} func (rq *request) countryDenied() bool {
func (countries) name() string { return "countries" }
func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
denied := rq.h.config.DeniedCountries denied := rq.h.config.DeniedCountries
allowed := rq.h.config.ExclusivelyAllowedCountries allowed := rq.h.config.ExclusivelyAllowedCountries
country := rq.line.Country country := rq.line.Country
var setting string if slices.Contains(denied, country) {
return true
switch {
case slices.Contains(denied, country):
setting = "SWWAF_DENIED_COUNTRIES"
case len(allowed) > 0 && !slices.Contains(allowed, country):
setting = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
default:
return nil
} }
return &verdict{ return len(allowed) > 0 && !slices.Contains(allowed, country)
action: requestlog.ActionCountryDenied,
figures: map[string]any{"country": country, "setting": setting},
}
} }
-17
View File
@@ -90,23 +90,6 @@ func TestCountryLists(t *testing.T) {
} }
} }
func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
t.Parallel()
geojsURL, _ := startGeoJS(t)
s, _, _ := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h", deniedCountries: "kp", allowedCountries: "de",
})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
wantHeuristic(t, line, "countries",
`{"country": "KP", "setting": "SWWAF_DENIED_COUNTRIES"}`)
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
wantHeuristic(t, line, "countries",
`{"country": "", "setting": "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
}
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) { func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
t.Parallel() t.Parallel()
-49
View File
@@ -1,49 +0,0 @@
package servewaf
// The heuristic crowdsec. It reads SWWAF_CROWDSEC_LAPI_URL, and the decisions
// through the reputation lists.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// crowdSec refuses a request when a decision of the CrowdSec decision list on
// the client is in force at now. It then notes the list, as noteHit does, and
// bans the client until that decision ends. Its figures are the decision's
// scenario and when it ends.
type crowdSec struct{}
func (crowdSec) name() string { return "crowdsec" }
func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
decision, listed := rq.crowdSecDecisionInForce(now)
if !listed {
return nil
}
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
"listed by the CrowdSec decision list")
rq.banForCrowdSec(now, decision)
return &verdict{
action: requestlog.ActionBanned,
figures: map[string]any{
"scenario": decision.Scenario,
"expires": requestlog.FormatTime(decision.Expires),
},
}
}
// crowdSecDecisionInForce returns the decision of the CrowdSec decision list on
// the client that is in force at now, and reports whether there is one, which
// the heuristic crowdsec refuses the request for.
func (rq *request) crowdSecDecisionInForce(
now time.Time,
) (reputation.Decision, bool) {
return rq.h.lists.CrowdSecDecision(rq.client, now)
}
-15
View File
@@ -95,21 +95,6 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
float64(fetched.Unix())) float64(fetched.Unix()))
} }
func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
t.Parallel()
s, clk, server, _ := startWithAlerts(t, map[string]string{
crowdSecURL: lapi, crowdSecKey: bouncerKey,
})
loadDecisions(t, server, clk.Now(), `[{"duration": "4h0m0s", `+
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
`"value": "`+client+`"}]`)
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
}
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) { func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
t.Parallel() t.Parallel()
-29
View File
@@ -1,29 +0,0 @@
package servewaf
// The heuristic deny_nets. It reads SWWAF_DENY_NETS.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// denyNets refuses a client in SWWAF_DENY_NETS. Its figure is the netblock of
// the setting the client is in.
type denyNets struct{}
func (denyNets) name() string { return "deny_nets" }
func (denyNets) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, netblock := range rq.h.config.DenyNets {
if netblock.Contains(rq.client) {
return &verdict{
action: requestlog.ActionDenied,
figures: map[string]any{figureNetblock: netblock.String()},
}
}
}
return nil
}
-36
View File
@@ -1,36 +0,0 @@
package servewaf
// The heuristic dnsbl. It reads SWWAF_REPUTATION_ACTION, and the DNSBL zones'
// verdicts through the DNSBL checker.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
// does, and refuses the request when SWWAF_REPUTATION_ACTION is deny. Being
// limit, it lowers the client's limits instead (see limitPercentages), and
// being log, it does nothing more. A zone without a verdict on the client is
// asked about it in the background, and the request does not wait for the
// answer. Its figure is the zones that list the client, their keys masked.
type dnsbl struct{}
func (dnsbl) name() string { return "dnsbl" }
func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
listedBy := rq.h.dnsbl.ListedBy(ctx, rq.client)
rq.dnsblListed = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a DNSBL zone")
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
return nil
}
return &verdict{
action: requestlog.ActionDenied,
figures: map[string]any{"zones": listedBy},
}
}
@@ -106,25 +106,6 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
} }
} }
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _, _ := startForErrorBurst(t, "1", nil)
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
line := blockRule.send(s, client)
wantHeuristic(t, line, "rule_files", ruleFigures)
wantAfterAnswerHeuristic(t, line, "", "null")
// The second refusal is the request refusal_limit bans for, and the line
// names both.
line = blockRule.send(s, client)
wantHeuristic(t, line, "rule_files", ruleFigures)
wantAfterAnswerHeuristic(t, line, "refusal_limit",
`{"window": "minute", "count": 2, "limit": 1}`)
}
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) { func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
t.Parallel() t.Parallel()
-71
View File
@@ -1,71 +0,0 @@
package servewaf
// The heuristic header_limits. It reads no setting: its limits are fixed.
import (
"context"
"strings"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// The header limits: the most cookies and header fields a request may have
// when the checks reach the Core Rule Set. Real clients never reach them.
const (
maxCookies = 50
maxHeaderFields = 100
)
// headerLimits refuses a request over the header limits, with 431. Its figures
// are the request's header fields and cookies, and the limit of each.
type headerLimits struct{}
func (headerLimits) name() string { return "header_limits" }
func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict {
fields, cookies := rq.headerCounts()
if !overHeaderLimits(fields, cookies) {
return nil
}
return &verdict{
action: requestlog.ActionTooLarge,
figures: map[string]any{
"fields": fields, "fields_limit": maxHeaderFields,
"cookies": cookies, "cookies_limit": maxCookies,
},
}
}
// overHeaderLimits reports whether a request with these counts of header fields
// and cookies, as headerCounts counts them, is over the header limits: it has
// more than maxHeaderFields fields or more than maxCookies cookies.
func overHeaderLimits(fields, cookies int) bool {
return fields > maxHeaderFields || cookies > maxCookies
}
// headerCounts returns the request's header fields, each value of a header sent
// twice being one, and its cookies, each part of a Cookie header between
// semicolons being one unless it is empty or only spaces and tabs. The Core
// Rule Set runs each of its rules on each cookie, so that thousands of small
// cookies keep it busy for over a second. It trims only spaces and control
// characters, of which a header value can hold only tabs, so a part of Unicode
// spaces is a cookie to it.
func (rq *request) headerCounts() (int, int) {
fields, cookies := 0, 0
for _, values := range rq.in.Header {
fields += len(values)
}
for _, value := range rq.in.Header.Values("Cookie") {
for part := range strings.SplitSeq(value, ";") {
if strings.Trim(part, " \t") != "" {
cookies++
}
}
}
return fields, cookies
}
-133
View File
@@ -1,133 +0,0 @@
package servewaf
import (
"context"
"time"
)
// To add a heuristic, write one file with a type that implements heuristic,
// and add one line for it to one of the lists below. Its settings go in
// config.Config, and the comment at the top of its file names them.
// heuristic is one of the checks that decide what servewaf does with a request.
type heuristic interface {
// name is the heuristic's name, which the log line gives.
name() string
// check looks at the request at now, and returns nil to let it through.
// ctx is the request's own context.
check(ctx context.Context, rq *request, now time.Time) *verdict
}
// verdict is a heuristic refusing a request, or banning its client: the action
// the log line names, one of requestlog's Action constants, and the figures the
// heuristic acted on, by name, such as the count and the limit.
type verdict struct {
action string
figures map[string]any
}
// The names of the figures that more than one heuristic gives.
const (
figureWindow = "window"
figureCount = "count"
figureLimit = "limit"
figureScore = "score"
figureNetblock = "netblock"
)
// The lists of heuristics, each in the order its heuristics run. A client in
// SWWAF_ALLOW_NETS skips them all.
// beforeLookup are the heuristics that run before the client's AS number and
// country are looked up, so that a client they refuse is not looked up.
func beforeLookup() []heuristic {
return []heuristic{
denyNets{},
activeBan{},
}
}
// afterLookup are the heuristics that run once the lookup has been made.
func afterLookup() []heuristic {
return []heuristic{
countries{},
blocklists{},
crowdSec{},
dnsbl{},
abuseIPDB{},
rateLimits{},
trapPaths{},
ruleFiles{},
headerLimits{},
coreRuleSet{},
}
}
// afterAnswer are the heuristics that run on a request once servewaf has
// answered it, before its decision line is written. They ban the client, and do
// not change what the request got: their verdict's action is not used.
func afterAnswer() []heuristic {
return []heuristic{
refusalLimit{},
}
}
// onReport are the heuristics that run on an allowed request when rproxy's
// report of it comes, before its report line is written. Like afterAnswer, they
// only ban.
func onReport() []heuristic {
return []heuristic{
byteLimits{},
}
}
// runAfterAnswer runs the heuristics of afterAnswer on the request, at the time
// it is called.
func (rq *request) runAfterAnswer(ctx context.Context) {
rq.runBanningHeuristics(ctx, afterAnswer())
}
// runHeuristics runs list, beforeLookup or afterLookup, on the request at now,
// and returns the first verdict, or nil when none gives one. The log line's
// heuristic and heuristic_figures name the heuristic that gave it, and give its
// figures. ctx is the request's own context.
func (rq *request) runHeuristics(
ctx context.Context, list []heuristic, now time.Time,
) *verdict {
name, v := rq.firstVerdict(ctx, list, now)
if v != nil {
rq.line.Heuristic = name
rq.line.HeuristicFigures = v.figures
}
return v
}
// runBanningHeuristics runs list, afterAnswer or onReport, on the request at
// the time it is called. The log line's after_answer_heuristic and
// after_answer_heuristic_figures name the heuristic that gave a verdict, and
// give its figures, so that heuristic and heuristic_figures still name the one
// that refused the request. ctx is the request's own context.
func (rq *request) runBanningHeuristics(ctx context.Context, list []heuristic) {
name, v := rq.firstVerdict(ctx, list, rq.h.now())
if v != nil {
rq.line.AfterAnswerHeuristic = name
rq.line.AfterAnswerHeuristicFigures = v.figures
}
}
// firstVerdict runs list on the request at now, in order, until a heuristic
// gives a verdict, and returns that heuristic's name and its verdict, or "" and
// nil when none gives one. ctx is the request's own context.
func (rq *request) firstVerdict(
ctx context.Context, list []heuristic, now time.Time,
) (string, *verdict) {
for _, each := range list {
v := each.check(ctx, rq, now)
if v != nil {
return each.name(), v
}
}
return "", nil
}
-12
View File
@@ -14,18 +14,6 @@ import (
// observe is the value of SWWAF_MODE for observe mode. // observe is the value of SWWAF_MODE for observe mode.
const observe = "observe" const observe = "observe"
func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
mode: observe, denyNets: client,
})
line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`)
}
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) { func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
t.Parallel() t.Parallel()
+23 -13
View File
@@ -4,6 +4,7 @@
package servewaf package servewaf
import ( import (
"fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -60,6 +61,9 @@ type Params struct {
// LookupFile is the lookup database they are looked up in while // LookupFile is the lookup database they are looked up in while
// SWWAF_LOOKUP_SOURCE is file, and nil otherwise. // SWWAF_LOOKUP_SOURCE is file, and nil otherwise.
LookupFile *lookup.File LookupFile *lookup.File
// CoreRuleSet is what NewCoreRuleSet returns: what inspects requests
// while SWWAF_WAF_MODE is not off, and nil while it is.
CoreRuleSet *waf.CoreRuleSet
// Now tells the time by which requests are counted for the rate // Now tells the time by which requests are counted for the rate
// limits, bans are made and run out, and GeoJS's answers are kept, // limits, bans are made and run out, and GeoJS's answers are kept,
// normally time.Now in UTC, the time the state files give. // normally time.Now in UTC, the time the state files give.
@@ -144,7 +148,7 @@ func newHandler(params Params) *handler {
dnsbl: dnsbl, dnsbl: dnsbl,
abuseIPDB: abuseIPDB, abuseIPDB: abuseIPDB,
rules: params.Rules, rules: params.Rules,
coreRuleSet: newCoreRuleSet(params.Config), coreRuleSet: params.CoreRuleSet,
alerts: params.Alerts, alerts: params.Alerts,
} }
h.geojs = lookup.New(lookup.Params{ h.geojs = lookup.New(lookup.Params{
@@ -202,29 +206,35 @@ func newReputation(
return lists, dnsbl, abuseIPDB return lists, dnsbl, abuseIPDB
} }
// newCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL, // NewCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL,
// without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies // without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies
// up to SWWAF_WAF_BODY_LIMIT, or nil while SWWAF_WAF_MODE is off. It reads at // up to SWWAF_WAF_BODY_LIMIT, with the exclusions in
// most QuestionBodyMaxBytes less one byte: a question's body of // SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWWAF_WAF_MODE is off. It
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a // QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
// JSON or XML body over its limit. // JSON or XML body over its limit. An exclusions file that cannot be read, does
func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet { // not parse or names an id of smallwebwaf's own rules is an error in every
if cfg.WAFMode == config.WAFModeOff { // mode. While the mode is off, a set file is loaded with the Core Rule Set only
return nil // to check it.
func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) {
if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
} }
coreRuleSet, err := waf.New(waf.Params{ coreRuleSet, err := waf.New(waf.Params{
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules, ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1), BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
}) })
if err != nil { if err != nil {
// The Core Rule Set is built in, and the settings cannot break it: return nil, fmt.Errorf("SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
// the paranoia level is from 1 to 4, and the id of no rule switches
// nothing off.
panic(err)
} }
return coreRuleSet if cfg.WAFMode == config.WAFModeOff {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
}
return coreRuleSet, nil
} }
// handler is servewaf's handler. It holds what every request shares; what // handler is servewaf's handler. It holds what every request shares; what
+6
View File
@@ -463,6 +463,11 @@ func newServeWAFWith(
} }
} }
coreRuleSet, err := servewaf.NewCoreRuleSet(cfg)
if err != nil {
t.Fatalf("Core Rule Set: %v", err)
}
server := build(servewaf.Params{ server := build(servewaf.Params{
Config: cfg, Config: cfg,
RequestLog: out, RequestLog: out,
@@ -470,6 +475,7 @@ func newServeWAFWith(
GeoJSURL: geojsURL, GeoJSURL: geojsURL,
AbuseIPDBURL: abuseIPDBURL, AbuseIPDBURL: abuseIPDBURL,
LookupFile: lookupFile, LookupFile: lookupFile,
CoreRuleSet: coreRuleSet,
Now: now, Now: now,
Rules: ruleFiles, Rules: ruleFiles,
Alerts: alertQueue, Alerts: alertQueue,
-65
View File
@@ -1,65 +0,0 @@
package servewaf
// The heuristic rate_limits. It reads SWWAF_RATE_LIMIT_EXEMPT_NETS,
// SWWAF_RATE_LIMIT_EXEMPT_PATHS and SWWAF_BAN_RESPONSE, and the rate limits and
// biased thresholds through the limiter and limitPercentages.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// rateLimits counts the request for the rate limits, unless
// countedByRateLimits exempts it, by the client's limit percentages, which it
// notes for the log line with the client's counts. A request that takes the
// client over a rate limit, as its limit percentage lowers it, breaks it: it is
// refused, and bans the client. A request exempt from the rate limits is exempt
// from the byte limits too. Its figures are the window, the count and the
// limit.
type rateLimits struct{}
func (rateLimits) name() string { return "rate_limits" }
func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
rq.counted = rq.countedByRateLimits()
if !rq.counted {
return nil
}
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
rq.limitPercent.percent)
rq.line.Counts = counts
if !over {
return nil
}
rq.banForLimit(now, hit, rq.h.config.BanResponse)
return &verdict{action: requestlog.ActionRateLimited, figures: limitFigures(hit)}
}
// overAWholeRateLimit reports whether the heuristic rate_limits will refuse the
// request at now, whatever its client's limit percentages: the rate limits
// count it, and one more request from its client takes it over a whole rate
// limit. It counts nothing.
func (rq *request) overAWholeRateLimit(now time.Time) bool {
return rq.countedByRateLimits() &&
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now)
}
// countedByRateLimits reports whether the rate limits count the request: its
// client is not in SWWAF_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
// under SWWAF_RATE_LIMIT_EXEMPT_PATHS.
func (rq *request) countedByRateLimits() bool {
cfg := rq.h.config
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
}
-11
View File
@@ -72,17 +72,6 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
} }
} }
func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`)
}
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) { func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
t.Parallel() t.Parallel()
-54
View File
@@ -1,54 +0,0 @@
package servewaf
// The heuristic refusal_limit. It reads SWWAF_ERROR_BURST_THRESHOLD and
// SWWAF_BAN_RESPONSE.
import (
"context"
"net/http"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// refusalLimit counts the request once it has been answered, if servewaf
// refused it after a rule file match, a trap path or a Core Rule Set match, and
// in observe mode if enforce mode would have: more than
// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
// a limit, which bans the client. A client in SWWAF_ALLOW_NETS, which the
// heuristics skip, is not counted, and nothing is while the threshold is off.
// Its figures are the window, the count and the limit.
type refusalLimit struct{}
func (refusalLimit) name() string { return "refusal_limit" }
func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdict {
cfg := rq.h.config
if cfg.ErrorBurstThreshold == 0 {
return nil
}
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
return nil
}
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
cfg.ErrorBurstThreshold)
if !over {
return nil
}
// What the client was sent, or in observe mode would have been.
status := rq.out.status
switch rq.line.WouldAction {
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
status = http.StatusForbidden
case requestlog.ActionBanned:
status = cfg.BanResponse
}
rq.banForLimit(now, hit, status)
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
}
+77 -3
View File
@@ -1,17 +1,91 @@
package servewaf package servewaf
// What the reputation heuristics share: blocklists, crowdsec, dnsbl and
// abuseipdb.
import ( import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
) )
// deny is the SWWAF_BLOCKLIST_ACTION and the SWWAF_REPUTATION_ACTION that // deny is the SWWAF_BLOCKLIST_ACTION and the SWWAF_REPUTATION_ACTION that
// refuses the requests of a client a source lists. // refuses the requests of a client a source lists.
const deny = "deny" const deny = "deny"
// blocklistDenied notes the blocklists that list the client, as
// noteListed does, and reports whether SWWAF_BLOCKLIST_ACTION, being deny,
// refuses the request. Being limit, it lowers the client's limits instead
// (see limitPercentages), and being log, it does nothing more.
func (rq *request) blocklistDenied() bool {
listedBy := rq.h.lists.ListedBy(rq.client)
rq.blocklisted = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a blocklist")
return rq.blocklisted && rq.h.config.BlocklistAction == deny
}
// crowdSecBanned reports whether a decision of the CrowdSec decision list
// on the client is in force at now. If one is, it notes the list, as
// noteHit does, and bans the client until that decision ends.
func (rq *request) crowdSecBanned(now time.Time) bool {
decision, listed := rq.h.lists.CrowdSecDecision(rq.client, now)
if !listed {
return false
}
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
"listed by the CrowdSec decision list")
rq.banForCrowdSec(now, decision)
return true
}
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as
// noteListed does, and reports whether SWWAF_REPUTATION_ACTION, being
// deny, refuses the request. Being limit, it lowers the client's limits
// instead (see limitPercentages), and being log, it does nothing more. A
// zone without a verdict on the client is asked about it in the
// background, and the request does not wait for the answer. ctx is the
// request's own context.
func (rq *request) dnsblDenied(ctx context.Context) bool {
listedBy := rq.h.dnsbl.ListedBy(ctx, rq.client)
rq.dnsblListed = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a DNSBL zone")
return rq.dnsblListed && rq.h.config.ReputationAction == deny
}
// abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
// its score of the client is a hit, and reports whether
// SWWAF_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
// does for a zone. While SWWAF_ABUSEIPDB_KEY is unset it does nothing. A
// client without a score is checked in the background, by the request's
// address, if its history counts an offence, and the request does not
// wait for the answer. The score is then used for each address of the
// client. ctx is the request's own context.
func (rq *request) abuseIPDBDenied(ctx context.Context) bool {
if rq.h.config.AbuseIPDBKey == "" {
return false
}
client := rq.h.clientGroup(rq.client)
held, _ := rq.h.limiter.Client(client)
offender := held.History.Offences != ratelimit.Offences{}
score, hit := rq.h.abuseIPDB.Hit(ctx, client, rq.client, offender)
if !hit {
return false
}
rq.abuseIPDBHit = true
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
"scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE")
return rq.h.config.ReputationAction == deny
}
// noteListed notes each of sources, the URLs of the blocklists or the // noteListed notes each of sources, the URLs of the blocklists or the
// DNSBL zones, their keys masked, that list the client, as noteHit does, // DNSBL zones, their keys masked, that list the client, as noteHit does,
// with reason. // with reason.
-50
View File
@@ -118,56 +118,6 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
} }
} }
func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
heuristic string
env map[string]string
load func(*testing.T, *servewaf.Server)
figures string
}{
{
"blocklists",
map[string]string{blocklistURLs: dropURL, blocklistAction: actionDeny},
func(t *testing.T, server *servewaf.Server) {
t.Helper()
loadLists(t, server, map[string][]string{dropURL: {client}})
},
`{"lists": ["` + dropURL + `"]}`,
},
{
"dnsbl",
map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: actionDeny,
},
func(_ *testing.T, server *servewaf.Server) {
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
},
`{"zones": ["` + dnsblZone + `"]}`,
},
{
"abuseipdb",
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
func(_ *testing.T, server *servewaf.Server) {
loadScores(server, map[string]int64{client: 80})
},
`{"score": 80, "min_score": 75}`,
},
} {
t.Run(tc.heuristic, func(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, tc.env)
tc.load(t, server)
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, tc.heuristic, tc.figures)
})
}
}
func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) { func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) {
t.Parallel() t.Parallel()
+85 -23
View File
@@ -160,20 +160,16 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
} }
// check is the one place where a request can be refused. It returns nil to let // check is the one place where a request can be refused. It returns nil to let
// the request through. The heuristics of checkClient decide: a request they // the request through. The checks of checkClient decide: a request they refuse
// refuse is answered with SWWAF_BAN_RESPONSE, 403 for a block rule or the Core // is answered with SWWAF_BAN_RESPONSE, 403 for a block rule or the Core Rule Set,
// Rule Set, or 431 for the header limits. In observe mode nothing is refused, // or 431 for the header limits. In observe mode nothing is refused, and the log
// and the log line names what enforce mode would have done. ctx is the // line names what enforce mode would have done. ctx is the request's own context.
// request's own context.
func (rq *request) check(ctx context.Context) *refusal { func (rq *request) check(ctx context.Context) *refusal {
v := rq.checkClient(ctx) action := rq.checkClient(ctx)
if v == nil {
return nil
}
action := v.action
switch { switch {
case action == "":
return nil
case rq.h.config.Observe: case rq.h.config.Observe:
rq.line.WouldAction = action rq.line.WouldAction = action
@@ -187,21 +183,35 @@ func (rq *request) check(ctx context.Context) *refusal {
} }
} }
// checkClient runs the heuristics of beforeLookup on the request, then looks // checkClient runs the checks on the request's client, and returns the
// up the client's AS number and country, then runs those of afterLookup. It // action of the first that refuses the request, or "" when none does. A
// returns the first verdict, or nil when none gives one. A client in // client in SWWAF_ALLOW_NETS skips them, and is not looked up. For any
// SWWAF_ALLOW_NETS skips them all, and is not looked up. ctx is the request's // other client, SWWAF_DENY_NETS comes first, then a ban on its netblock,
// own context. // so that a client either refuses is not looked up, then the lookup of
func (rq *request) checkClient(ctx context.Context) *verdict { // its AS number and country, then the country lists, then the blocklists,
if isInside(rq.client, rq.h.config.AllowNets) { // then the CrowdSec decision list, which bans the client it lists, then
return nil // the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of
// them refuses is not counted for the rate limits. Then come the
// rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
// every other request is counted, each of them by the client's limit
// percentages, then SWWAF_TRAP_PATHS, then the rule files, then the header
// limits, and last the Core Rule Set. A request exempt from the rate limits
// is exempt from the byte limits too. ctx is the request's own context.
func (rq *request) checkClient(ctx context.Context) string {
cfg := rq.h.config
if isInside(rq.client, cfg.AllowNets) {
return ""
} }
now := rq.h.now() now := rq.h.now()
v := rq.runHeuristics(ctx, beforeLookup(), now) if isInside(rq.client, cfg.DenyNets) {
if v != nil { return requestlog.ActionDenied
return v }
if rq.banned(now) {
return requestlog.ActionBanned
} }
rq.lookUp(ctx, now) rq.lookUp(ctx, now)
@@ -210,7 +220,59 @@ func (rq *request) checkClient(ctx context.Context) *verdict {
// again: a request counted at the time it arrived could be counted // again: a request counted at the time it arrived could be counted
// after a later one of its client's, which the rate limits would take // after a later one of its client's, which the rate limits would take
// for the clock set back. // for the clock set back.
return rq.runHeuristics(ctx, afterLookup(), rq.h.now()) now = rq.h.now()
if rq.countryDenied() {
return requestlog.ActionCountryDenied
}
if rq.blocklistDenied() {
return requestlog.ActionDenied
}
if rq.crowdSecBanned(now) {
return requestlog.ActionBanned
}
if rq.dnsblDenied(ctx) || rq.abuseIPDBDenied(ctx) {
return requestlog.ActionDenied
}
rq.counted = rq.countedByRateLimits()
if rq.counted {
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
}
if rq.counted && rq.limitBroken(now) {
return requestlog.ActionRateLimited
}
if rq.trapPath(now) {
return requestlog.ActionBanned
}
action := rq.checkRules(now)
if action != "" {
return action
}
if rq.overHeaderLimits() {
return requestlog.ActionTooLarge
}
return rq.checkCoreRuleSet()
}
// countedByRateLimits reports whether the rate limits count the request: its
// client is not in SWWAF_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
// under SWWAF_RATE_LIMIT_EXEMPT_PATHS.
func (rq *request) countedByRateLimits() bool {
cfg := rq.h.config
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
} }
// pathExempt reports whether r is exempt under prefixes, // pathExempt reports whether r is exempt under prefixes,
-43
View File
@@ -1,7 +1,6 @@
package servewaf_test package servewaf_test
import ( import (
"encoding/json"
"io" "io"
"math" "math"
"net/http" "net/http"
@@ -273,48 +272,6 @@ func wantTimings(t *testing.T, line logLine, want ...string) {
} }
} }
// wantHeuristic checks that line names heuristic as the one that refused the
// request, with figures, a JSON object. A heuristic of "" with figures "null" is
// a line that names none.
func wantHeuristic(t *testing.T, line logLine, heuristic, figures string) {
t.Helper()
if line.Heuristic != heuristic ||
!reflect.DeepEqual(line.HeuristicFigures, decodeFigures(t, figures)) {
t.Errorf("log line has heuristic %q with heuristic_figures %v, want %q with %s",
line.Heuristic, line.HeuristicFigures, heuristic, figures)
}
}
// wantAfterAnswerHeuristic checks after_answer_heuristic and
// after_answer_heuristic_figures as wantHeuristic checks heuristic and
// heuristic_figures.
func wantAfterAnswerHeuristic(t *testing.T, line logLine, heuristic, figures string) {
t.Helper()
if line.AfterAnswerHeuristic != heuristic ||
!reflect.DeepEqual(line.AfterAnswerHeuristicFigures, decodeFigures(t, figures)) {
t.Errorf("log line has after_answer_heuristic %q with "+
"after_answer_heuristic_figures %v, want %q with %s",
line.AfterAnswerHeuristic, line.AfterAnswerHeuristicFigures, heuristic, figures)
}
}
// decodeFigures decodes figures, a JSON object of a heuristic's figures, as
// the log line's figures are decoded.
func decodeFigures(t *testing.T, figures string) map[string]any {
t.Helper()
var decoded map[string]any
err := json.Unmarshal([]byte(figures), &decoded)
if err != nil {
t.Fatalf("decode %s: %v", figures, err)
}
return decoded
}
// microseconds is a timing in whole microseconds. // microseconds is a timing in whole microseconds.
func microseconds(milliseconds float64) int64 { func microseconds(milliseconds float64) int64 {
return int64(math.Round(milliseconds * 1000)) return int64(math.Round(milliseconds * 1000))
-55
View File
@@ -1,55 +0,0 @@
package servewaf
// The heuristic rule_files. It reads the rules of the rule files, which
// SWWAF_RULES_DIR and SWWAF_RULES_ENABLED name.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// ruleFiles checks the request against the rules of the rule files at now,
// notes the ids of those it matches in the log line, and refuses the request
// when the last rule it matches refuses it: with ActionRuleBlocked for a block
// rule, and ActionBanned for a ban rule. A ban rule bans the client's netblock
// for a clear sign of attack, or in observe mode raises the alert for the ban
// it would have made. Either rule's match is noted as an offence, for the
// client's history. Its figures are the rule's id and action.
type ruleFiles struct{}
func (ruleFiles) name() string { return "rule_files" }
func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict {
matched := rq.h.rules.Match(rq.in)
for _, rule := range matched {
rq.line.RuleIDs = append(rq.line.RuleIDs, rule.ID)
rq.h.metrics.RuleMatched(rule.ID, rule.Action)
}
if len(matched) == 0 {
return nil
}
// Only the last rule matched can refuse the request.
last := matched[len(matched)-1]
figures := map[string]any{"rule_id": last.ID, "rule_action": last.Action}
switch last.Action {
case rules.ActionBlock:
rq.ruleBlocked = true
return &verdict{action: requestlog.ActionRuleBlocked, figures: figures}
case rules.ActionBan:
rq.attack = true
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
return &verdict{action: requestlog.ActionBanned, figures: figures}
default:
return nil
}
}
-15
View File
@@ -93,21 +93,6 @@ func TestEachRuleAction(t *testing.T) {
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned) s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
} }
func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
})
line := s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionRuleBlocked)
wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`)
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`)
}
func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) { func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
t.Parallel() t.Parallel()
+64
View File
@@ -0,0 +1,64 @@
package servewaf
import (
"slices"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// trapPath reports whether the request asks for a path in
// SWWAF_TRAP_PATHS: its path as a path rule sees it, without the query,
// as sent or once percent-decoded, is one of them. Such a request is a
// clear sign of attack, as a ban rule's match is: it bans the client's
// netblock, or in observe mode raises the alert for the ban it would have
// made. The ban's notes give the path as sent.
func (rq *request) trapPath(now time.Time) bool {
path := rules.Path(rq.in)
if !slices.Contains(rq.h.config.TrapPaths, path) &&
!slices.Contains(rq.h.config.TrapPaths, rules.DecodeOnce(path)) {
return false
}
rq.attack = true
rq.banForAttack(now, bans.Notes{TrapPath: path})
return true
}
// checkRules checks the request against the rules of the rule files at
// now, notes the ids of those it matches in the log line, and returns the
// action of the rule that refuses it, ActionRuleBlocked for a block rule
// and ActionBanned for a ban rule, or "" when none does. A ban rule bans
// the client's netblock for a clear sign of attack, or in observe mode
// raises the alert for the ban it would have made. Either rule's match
// is noted as an offence, for the client's history.
func (rq *request) checkRules(now time.Time) string {
matched := rq.h.rules.Match(rq.in)
for _, rule := range matched {
rq.line.RuleIDs = append(rq.line.RuleIDs, rule.ID)
rq.h.metrics.RuleMatched(rule.ID, rule.Action)
}
if len(matched) == 0 {
return ""
}
// Only the last rule matched can refuse the request.
switch last := matched[len(matched)-1]; last.Action {
case rules.ActionBlock:
rq.ruleBlocked = true
return requestlog.ActionRuleBlocked
case rules.ActionBan:
rq.attack = true
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
return requestlog.ActionBanned
default:
return ""
}
}
+54 -20
View File
@@ -2,7 +2,6 @@ package servewaf
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -53,6 +52,13 @@ const ReportPath = "/_smallwebwaf/report"
// QuestionBodyMaxBytes is the most of a visitor's body a question carries. // QuestionBodyMaxBytes is the most of a visitor's body a question carries.
const QuestionBodyMaxBytes = 64 << 10 const QuestionBodyMaxBytes = 64 << 10
// The header limits: the most cookies and header fields a request may have
// when the checks reach the Core Rule Set. Real clients never reach them.
const (
maxCookies = 50
maxHeaderFields = 100
)
const ( const (
// maxAwaitingReport is the most answered requests held until their // maxAwaitingReport is the most answered requests held until their
// reports come. Past it, the request answered longest ago is dropped. // reports come. Past it, the request answered longest ago is dropped.
@@ -141,20 +147,48 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// refusedWhateverTheAnswer reports whether the request is one that does not wait // refusedWhateverTheAnswer reports whether the request is one that does not wait
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about // for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
// its client: the heuristic crowdsec, header_limits, blocklists or rate_limits // its client: it is over the header limits, a blocklist lists its client while
// will refuse it, as a function in that heuristic's file says. In observe mode, // SWWAF_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is
// which refuses nothing, none is. rproxy lets a request through when servewaf // over a whole rate limit. In observe mode, which refuses nothing, none is.
// answers too late. // rproxy lets a request through when servewaf answers too late.
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool { func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
if rq.h.config.Observe { if rq.h.config.Observe {
return false return false
} }
_, decided := rq.crowdSecDecisionInForce(now) lists := rq.h.lists
_, decided := lists.CrowdSecDecision(rq.client, now)
return decided || overHeaderLimits(rq.headerCounts()) || return decided || rq.overHeaderLimits() ||
rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) || (rq.h.config.BlocklistAction == deny && len(lists.ListedBy(rq.client)) > 0) ||
rq.overAWholeRateLimit(now) (rq.countedByRateLimits() &&
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now))
}
// overHeaderLimits reports whether servewaf refuses the request for its
// headers: it has more than maxHeaderFields fields, each value of a header sent
// twice being one, or more than maxCookies cookies, each part of a Cookie header
// between semicolons being one unless it is empty or only spaces and tabs. The
// Core Rule Set runs each of its rules on each cookie, so that thousands of small
// cookies keep it busy for over a second. It trims only spaces and control
// characters, of which a header value can hold only tabs, so a part of Unicode
// spaces is a cookie to it.
func (rq *request) overHeaderLimits() bool {
fields, cookies := 0, 0
for _, values := range rq.in.Header {
fields += len(values)
}
for _, value := range rq.in.Header.Values("Cookie") {
for part := range strings.SplitSeq(value, ";") {
if strings.Trim(part, " \t") != "" {
cookies++
}
}
}
return fields > maxHeaderFields || cookies > maxCookies
} }
// answerQuestion runs the checks on the visitor's request a question // answerQuestion runs the checks on the visitor's request a question
@@ -185,18 +219,18 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
if refused == nil && !underOwnPrefix { if refused == nil && !underOwnPrefix {
// In observe mode, what enforce mode would have refused counts here. // In observe mode, what enforce mode would have refused counts here.
defer rq.runAfterAnswer(r.Context()) defer rq.countRefusal()
rq.allow() rq.allow()
return return
} }
// The last deferred runs first: runAfterAnswer before addToHistory, so that // The last deferred runs first: countRefusal before addToHistory, so that a
// a broken refusal limit is in the client's history. // broken error burst is in the client's history.
defer rq.addToHistory() defer rq.addToHistory()
defer rq.countAnomalies() defer rq.countAnomalies()
defer rq.runAfterAnswer(r.Context()) defer rq.countRefusal()
if refused != nil { if refused != nil {
rq.deny(*refused) rq.deny(*refused)
@@ -378,7 +412,7 @@ func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
// Remove reports true to one report only, of several that name the id. A // Remove reports true to one report only, of several that name the id. A
// question still being handled is held as nil. // question still being handled is held as nil.
if found && held != nil && h.awaitingReport.Remove(report.RequestID) { if found && held != nil && h.awaitingReport.Remove(report.RequestID) {
h.finishWith(r.Context(), held, report) h.finishWith(held, report)
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -441,11 +475,11 @@ func (report *Report) checkRanges() error {
// finishWith finishes a request with what its report says, and writes the // finishWith finishes a request with what its report says, and writes the
// request's last log line, which holds its decision's fields and the report's. // request's last log line, which holds its decision's fields and the report's.
// For an allowed request it first runs the heuristics of onReport, and adds the // For an allowed request it first counts the request's bytes, and adds it to its
// request to its client's history, the anomaly counters and the metrics, with // client's history, the anomaly counters and the metrics, with the status and
// the status and bytes the report gives. A denied request's counting was // bytes the report gives. A denied request's counting was finished at its
// finished at its decision. ctx is the report's own context. // decision.
func (h *handler) finishWith(ctx context.Context, held *decision, report Report) { func (h *handler) finishWith(held *decision, report Report) {
rq := &request{ rq := &request{
decision: *held, decision: *held,
h: h, h: h,
@@ -462,7 +496,7 @@ func (h *handler) finishWith(ctx context.Context, held *decision, report Report)
if rq.allowed { if rq.allowed {
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal
rq.runBanningHeuristics(ctx, onReport()) rq.countBytes()
rq.countAnomalies() rq.countAnomalies()
rq.addToHistory() rq.addToHistory()
-9
View File
@@ -104,15 +104,6 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
} }
} }
func TestDenyNetsRefusalNamesItsHeuristicAndNetblock(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet})
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`)
}
func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) { func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
t.Parallel() t.Parallel()
-39
View File
@@ -1,39 +0,0 @@
package servewaf
// The heuristic trap_paths. It reads SWWAF_TRAP_PATHS.
import (
"context"
"slices"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// trapPaths refuses a request for a path in SWWAF_TRAP_PATHS: its path as a
// path rule sees it, without the query, as sent or once percent-decoded, is one
// of them. Such a request is a clear sign of attack, as a ban rule's match is:
// it bans the client's netblock, or in observe mode raises the alert for the
// ban it would have made. The ban's notes give the path as sent, and so does
// its figure.
type trapPaths struct{}
func (trapPaths) name() string { return "trap_paths" }
func (trapPaths) check(_ context.Context, rq *request, now time.Time) *verdict {
path := rules.Path(rq.in)
if !slices.Contains(rq.h.config.TrapPaths, path) &&
!slices.Contains(rq.h.config.TrapPaths, rules.DecodeOnce(path)) {
return nil
}
rq.attack = true
rq.banForAttack(now, bans.Notes{TrapPath: path})
return &verdict{
action: requestlog.ActionBanned,
figures: map[string]any{"path": path},
}
}
-10
View File
@@ -83,16 +83,6 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
} }
} }
func TestTrapPathRefusalNamesItsHeuristicAndPath(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden,
requestlog.ActionBanned)
wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`)
}
func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) { func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
t.Parallel() t.Parallel()
+12 -3
View File
@@ -29,6 +29,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/servewaf" "sneak.berlin/go/smallwebwaf/internal/servewaf"
"sneak.berlin/go/smallwebwaf/internal/state" "sneak.berlin/go/smallwebwaf/internal/state"
"sneak.berlin/go/smallwebwaf/internal/waf"
) )
// shutdownTimeout is how long requests in progress may take to finish // shutdownTimeout is how long requests in progress may take to finish
@@ -150,7 +151,14 @@ func RunServeWAF(ctx context.Context, params Params) int {
return 1 return 1
} }
server, err := newServer(cfg, stdout, processLog, now, ruleFiles, alertQueue) crs, err := servewaf.NewCoreRuleSet(cfg)
if err != nil {
processLog.Error("cannot use the Core Rule Set", "error", err.Error())
return 1
}
server, err := newServer(cfg, stdout, processLog, now, ruleFiles, alertQueue, crs)
if err != nil { if err != nil {
processLog.Error("cannot use the lookup database", "error", err.Error()) processLog.Error("cannot use the lookup database", "error", err.Error())
@@ -170,8 +178,7 @@ func RunServeWAF(ctx context.Context, params Params) int {
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr) listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
if err != nil { if err != nil {
processLog.Error("cannot listen on SWWAF_LISTEN_ADDR", processLog.Error("cannot listen on SWWAF_LISTEN_ADDR", "error", err.Error())
"error", err.Error())
return 1 return 1
} }
@@ -190,6 +197,7 @@ func RunServeWAF(ctx context.Context, params Params) int {
func newServer( func newServer(
cfg *config.Config, stdout io.Writer, processLog *slog.Logger, cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue, now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
crs *waf.CoreRuleSet,
) (*servewaf.Server, error) { ) (*servewaf.Server, error) {
var lookupFile *lookup.File var lookupFile *lookup.File
@@ -214,6 +222,7 @@ func newServer(
GeoJSURL: lookup.URL, GeoJSURL: lookup.URL,
AbuseIPDBURL: reputation.AbuseIPDBURL, AbuseIPDBURL: reputation.AbuseIPDBURL,
LookupFile: lookupFile, LookupFile: lookupFile,
CoreRuleSet: crs,
Now: now, Now: now,
Rules: ruleFiles, Rules: ruleFiles,
Alerts: alertQueue, Alerts: alertQueue,
+51
View File
@@ -19,6 +19,7 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest" "sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
"sneak.berlin/go/smallwebwaf/internal/servewaf" "sneak.berlin/go/smallwebwaf/internal/servewaf"
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf" "sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
@@ -551,6 +552,56 @@ func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
": no such file or directory") ": no such file or directory")
} }
func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "crs-exclusions.conf")
out := &output{}
status := run(t.Context(), map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
"SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
}, out)
if status != 1 {
t.Fatalf("exit status %d, want 1", status)
}
line := out.line(t, "msg", "cannot use the Core Rule Set")
want := "SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " +
path + ": no such file or directory"
if line["error"] != want {
t.Errorf("start refused with %v, want the error %q", line, want)
}
}
func TestCoreRuleSetExclusionsFileThatCannotBeUsedStopsTheStartWhileTheCoreRuleSetIsOff(
t *testing.T,
) {
t.Parallel()
broken := filepath.Join(t.TempDir(), "broken.conf")
err := os.WriteFile(broken, []byte("SecNoSuchDirective On\n"), 0o600)
if err != nil {
t.Fatal(err)
}
for _, path := range []string{filepath.Join(t.TempDir(), "missing.conf"), broken} {
out := &output{}
status := run(t.Context(), map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
"SWWAF_WAF_MODE": config.WAFModeOff, "SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
}, out)
if status != 1 {
t.Fatalf("%s: exit status %d, want 1", path, status)
}
out.line(t, "msg", "cannot use the Core Rule Set")
}
}
func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) { func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
t.Parallel() t.Parallel()
+236
View File
@@ -0,0 +1,236 @@
# The eighth change: on gitea's own paths, the fields in which gitea sends a
# search, a file name or a branch name skip the rules that refused ordinary
# gitea requests there. Each exclusion runs with the request, before the rules
# it switches off, those of phase 1 included, so this file is loaded before the
# Core Rule Set's rules. Each comment names a request the rule refused.
# gitea's searches send the text searched for in q: searches of code, of issues
# and pull requests, of commits, and of repositories, branches, tags, packages
# and users. q skips these rules there:
# 930110: ../
# 930120: .gitignore
# 931100: http://127.0.0.1:8080
# 932120: Invoke-WebRequest
# 932130: $(pwd)
# 932140: if (a == b) {
# 932160: /bin/sh
# 932235: `make check`
# 932250: ls -la
# 932260: python3
# 932380: `whoami`
# 933100: <?php echo
# 933130: $_GET[
# 933135: ${VAR}
# 934100: process.env
# 934110: http://127.0.0.1:8080
# 934130: __proto__
# 941100: <script>
# 941110: <script>
# 941160: <img src=
# 941180: document.cookie
# 941390: fetch(
# 942100: SELECT * FROM users WHERE
# 942140: information_schema
# 942190: union select
# 942230: if (a == b) {
# 944100: java.lang.Runtime
# 944110: java.lang.Runtime
# 944130: java.lang.Runtime
SecRule REQUEST_FILENAME "@rx ^/(?:explore/(?:repos|code)|issues(?:/search)?|pulls|\
user/search_candidates|[^/]+/-/(?:code|packages)|\
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|branches|tags|\
commits/(?:branch|tag|commit)/.+/search)|\
api/v1/repos/(?:search|issues/search|[^/]+/[^/]+/issues))$" \
"id:900600,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930110;ARGS:q,\
ctl:ruleRemoveTargetById=930120;ARGS:q,\
ctl:ruleRemoveTargetById=931100;ARGS:q,\
ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932130;ARGS:q,\
ctl:ruleRemoveTargetById=932140;ARGS:q,\
ctl:ruleRemoveTargetById=932160;ARGS:q,\
ctl:ruleRemoveTargetById=932235;ARGS:q,\
ctl:ruleRemoveTargetById=932250;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q,\
ctl:ruleRemoveTargetById=932380;ARGS:q,\
ctl:ruleRemoveTargetById=933100;ARGS:q,\
ctl:ruleRemoveTargetById=933130;ARGS:q,\
ctl:ruleRemoveTargetById=933135;ARGS:q,\
ctl:ruleRemoveTargetById=934100;ARGS:q,\
ctl:ruleRemoveTargetById=934110;ARGS:q,\
ctl:ruleRemoveTargetById=934130;ARGS:q,\
ctl:ruleRemoveTargetById=941100;ARGS:q,\
ctl:ruleRemoveTargetById=941110;ARGS:q,\
ctl:ruleRemoveTargetById=941160;ARGS:q,\
ctl:ruleRemoveTargetById=941180;ARGS:q,\
ctl:ruleRemoveTargetById=941390;ARGS:q,\
ctl:ruleRemoveTargetById=942100;ARGS:q,\
ctl:ruleRemoveTargetById=942140;ARGS:q,\
ctl:ruleRemoveTargetById=942190;ARGS:q,\
ctl:ruleRemoveTargetById=942230;ARGS:q,\
ctl:ruleRemoveTargetById=944100;ARGS:q,\
ctl:ruleRemoveTargetById=944110;ARGS:q,\
ctl:ruleRemoveTargetById=944130;ARGS:q"
# A page opened from the results of one of those searches has the search's
# address in Referer, which skips these rules then:
# 941110: <script>
# 941120: onClick={
# 941160: <img src=
# 942100: LIKE '%foo%'
# 944100: java.lang.Runtime
SecRule REQUEST_HEADERS:Referer "@rx ^https?://[^/]+/(?:explore/(?:repos|code)|\
issues(?:/search)?|pulls|user/search_candidates|[^/]+/-/(?:code|packages)|\
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|branches|tags|\
commits/(?:branch|tag|commit)/.+/search)|\
api/v1/repos/(?:search|issues/search|[^/]+/[^/]+/issues))\?" \
"id:900601,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=941110;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=941120;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=941160;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=942100;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=944100;REQUEST_HEADERS:Referer"
# A file's page, raw file and download, its blame, history, edit and delete
# pages, and the API's call for its contents name the file in the path, which
# skips these rules there:
# 920500: notes.txt~
# 934100: process.env.d.ts
# 941130: docs/a b.xhtml
# 944130: java.lang.String.md
SecRule REQUEST_FILENAME \
"@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_delete|contents)/" \
"id:900610,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME,\
ctl:ruleRemoveTargetById=934100;REQUEST_FILENAME,\
ctl:ruleRemoveTargetById=941130;REQUEST_FILENAME,\
ctl:ruleRemoveTargetById=944130;REQUEST_FILENAME"
# The requests below name a file in a query parameter. These rules refused
# them for the file named:
# 930120: .gitignore
# 932120: install-script.sh
# 932130: [draft].md
# 932160: bin/docker-entrypoint
# 932260: docker-compose.yml
# 934100: process.env.d.ts
# 934130: __proto__.json
# 941130: index.xhtml
# 941180: document.write.js
# 942140: information_schema.sql
# 944110: java/lang/Runtime.java
# 944130: java.lang.String.md
# Each request skips the rules that refused it. The fourth change already has
# path and redirect_to skip 930120, 932160 and 932260 on every path.
# A file's commit graph, in file.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
"id:900620,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:file,\
ctl:ruleRemoveTargetById=932120;ARGS:file,\
ctl:ruleRemoveTargetById=932160;ARGS:file,\
ctl:ruleRemoveTargetById=932260;ARGS:file,\
ctl:ruleRemoveTargetById=934100;ARGS:file,\
ctl:ruleRemoveTargetById=934130;ARGS:file,\
ctl:ruleRemoveTargetById=941130;ARGS:file,\
ctl:ruleRemoveTargetById=941180;ARGS:file,\
ctl:ruleRemoveTargetById=942140;ARGS:file,\
ctl:ruleRemoveTargetById=944110;ARGS:file,\
ctl:ruleRemoveTargetById=944130;ARGS:file"
# The API's list of a file's commits, in path.
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/commits$" \
"id:900621,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=932120;ARGS:path,\
ctl:ruleRemoveTargetById=934100;ARGS:path,\
ctl:ruleRemoveTargetById=934130;ARGS:path,\
ctl:ruleRemoveTargetById=941130;ARGS:path,\
ctl:ruleRemoveTargetById=941180;ARGS:path,\
ctl:ruleRemoveTargetById=942140;ARGS:path,\
ctl:ruleRemoveTargetById=944110;ARGS:path,\
ctl:ruleRemoveTargetById=944130;ARGS:path"
# The API's call for the contents of several files, in body, as JSON.
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/file-contents$" \
"id:900622,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:body,\
ctl:ruleRemoveTargetById=932120;ARGS:body,\
ctl:ruleRemoveTargetById=932160;ARGS:body,\
ctl:ruleRemoveTargetById=934100;ARGS:body,\
ctl:ruleRemoveTargetById=934130;ARGS:body,\
ctl:ruleRemoveTargetById=941130;ARGS:body,\
ctl:ruleRemoveTargetById=941180;ARGS:body,\
ctl:ruleRemoveTargetById=942140;ARGS:body,\
ctl:ruleRemoveTargetById=944110;ARGS:body,\
ctl:ruleRemoveTargetById=944130;ARGS:body"
# An issue opened from a template, in template.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/issues/new$" \
"id:900623,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:template,\
ctl:ruleRemoveTargetById=932120;ARGS:template,\
ctl:ruleRemoveTargetById=932130;ARGS:template,\
ctl:ruleRemoveTargetById=932160;ARGS:template,\
ctl:ruleRemoveTargetById=932260;ARGS:template,\
ctl:ruleRemoveTargetById=934100;ARGS:template,\
ctl:ruleRemoveTargetById=934130;ARGS:template,\
ctl:ruleRemoveTargetById=941130;ARGS:template,\
ctl:ruleRemoveTargetById=941180;ARGS:template,\
ctl:ruleRemoveTargetById=942140;ARGS:template,\
ctl:ruleRemoveTargetById=944110;ARGS:template,\
ctl:ruleRemoveTargetById=944130;ARGS:template"
# The sign-in page on the way to a file's edit page, in redirect_to.
SecRule REQUEST_FILENAME "@rx ^/user/login$" \
"id:900624,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=932120;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=932130;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=934100;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=934130;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=941130;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=941180;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=942140;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=944110;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=944130;ARGS:redirect_to"
# An attachment uploaded through the API to a release, an issue or a comment,
# in name.
SecRule REQUEST_FILENAME \
"@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
"id:900625,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:name,\
ctl:ruleRemoveTargetById=932120;ARGS:name,\
ctl:ruleRemoveTargetById=932260;ARGS:name,\
ctl:ruleRemoveTargetById=934100;ARGS:name,\
ctl:ruleRemoveTargetById=934130;ARGS:name,\
ctl:ruleRemoveTargetById=941130;ARGS:name,\
ctl:ruleRemoveTargetById=941180;ARGS:name,\
ctl:ruleRemoveTargetById=942140;ARGS:name,\
ctl:ruleRemoveTargetById=944130;ARGS:name"
# The requests below name a branch or tag in a query parameter, which skips
# the list of command names (932260) there. It refused names such as
# docker-build, python3-migration and feat/docker-support.
# Deleting or restoring a branch, in name.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/branches/(?:delete|restore)$" \
"id:900630,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:name"
# A release started from a tag, in tag.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/releases/new$" \
"id:900631,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:tag"
# A branch protection rule opened for editing, in rule_name.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/settings/branches/edit$" \
"id:900632,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:rule_name"
# A directory listing's last commits, in refSubUrl.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/lastcommit/" \
"id:900633,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:refSubUrl"
# An artifact upload from actions/upload-artifact@v3 sends the header
# Content-Range, which the Core Rule Set refused (920450).
SecRule REQUEST_FILENAME \
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
"id:900640,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
+367
View File
@@ -0,0 +1,367 @@
# Ordinary gitea 1.27 requests that the Core Rule Set must let through at the
# defaults. Each line is a path and query, and may end with one header after a
# space. The tests of internal/waf inspect each as a GET. The Core Rule Set reads
# the query and the headers the same way for any method.
# Searches with ordinary words and symbols.
/owner/repo/search?q=package.json
/owner/repo/search?q=.gitignore
/owner/repo/search?q=docker-compose.yml
/owner/repo/search?q=python3
/owner/repo/search?q=ssh%20key
/owner/repo/search?q=feat%2Fdocker-support
/owner/repo/search?q=ls%20-la
/owner/repo/search?q=sed%20-i
/owner/repo/search?q=%2Fbin%2Fsh
/owner/repo/search?q=%60make%20check%60
/owner/repo/search?q=%60whoami%60
/owner/repo/search?q=fetch%28
/owner/repo/search?q=%24%7BVAR%7D
/owner/repo/search?q=%24%28pwd%29
/owner/repo/search?q=process.env
/owner/repo/search?q=%3Cimg%20src%3D
/owner/repo/search?q=%3Cscript%3E
/owner/repo/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/search?q=union%20select
/owner/repo/search?q=http%3A%2F%2F127.0.0.1%3A8080
/owner/repo/search?q=..%2F
/owner/repo/search?q=%2Fetc%2Fpasswd
/owner/repo/search?q=eval%28
/owner/repo/search?q=chmod%20%2Bx
/owner/repo/search?q=echo%20%24PATH
/owner/repo/search?q=java.lang.Runtime
/owner/repo/search?q=document.cookie
/owner/repo/search?q=alert%281%29
/owner/repo/search?q=__proto__
/owner/repo/search?q=C%3A%5CWindows%5CSystem32
/owner/repo/search?q=Invoke-WebRequest
/owner/repo/search?q=if%20%28a%20%3D%3D%20b%29%20%7B
/owner/repo/search?q=sudo%20apt%20install
/owner/repo/search?q=~%2F.ssh%2Fconfig
/owner/repo/search?q=.htaccess
/owner/repo/search?q=%24_GET%5B
/owner/repo/search?q=%24_POST
/owner/repo/search?q=%24_SERVER%5B%27REQUEST_URI%27%5D
/owner/repo/search?q=%3C%3Fphp%20echo
/owner/repo/search?q=information_schema
/owner/repo/search?q=LIKE%20%27%25foo%25%27
/owner/repo/search?q=onClick%3D%7B
/explore/repos?q=SELECT%20%2A%20FROM%20users%20WHERE
/explore/repos?q=%3Cimg%20src%3D
/explore/code?q=SELECT%20%2A%20FROM%20users%20WHERE
/explore/code?q=%3Cimg%20src%3D
/owner/-/code?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/-/code?q=%3Cimg%20src%3D
/owner/repo/issues?type=all&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/issues?type=all&state=open&q=%3Cimg%20src%3D
/owner/repo/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/issues/search?q=%3Cimg%20src%3D
/owner/repo/pulls?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/pulls?q=%3Cimg%20src%3D
/issues?type=your_repositories&q=SELECT%20%2A%20FROM%20users%20WHERE
/issues?type=your_repositories&q=%3Cimg%20src%3D
/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/issues/search?q=%3Cimg%20src%3D
/pulls?type=your_repositories&q=SELECT%20%2A%20FROM%20users%20WHERE
/pulls?type=your_repositories&q=%3Cimg%20src%3D
/owner/repo/commits/branch/main/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/commits/branch/main/search?q=%3Cimg%20src%3D
/owner/repo/branches?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/branches?q=%3Cimg%20src%3D
/owner/repo/tags?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/tags?q=%3Cimg%20src%3D
/owner/-/packages?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/-/packages?q=%3Cimg%20src%3D
/user/search_candidates?q=SELECT%20%2A%20FROM%20users%20WHERE
/user/search_candidates?q=%3Cimg%20src%3D
/api/v1/repos/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/api/v1/repos/search?q=%3Cimg%20src%3D
/api/v1/repos/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/api/v1/repos/issues/search?q=%3Cimg%20src%3D
/api/v1/repos/owner/repo/issues?q=SELECT%20%2A%20FROM%20users%20WHERE
/api/v1/repos/owner/repo/issues?q=%3Cimg%20src%3D
# Pages, raw files and edit pages of files, and the API's call for their
# contents.
/owner/repo/src/branch/main/package.json
/owner/repo/raw/branch/main/package.json
/owner/repo/_edit/main/package.json
/api/v1/repos/owner/repo/contents/package.json
/owner/repo/src/branch/main/.gitignore
/owner/repo/raw/branch/main/.gitignore
/owner/repo/_edit/main/.gitignore
/api/v1/repos/owner/repo/contents/.gitignore
/owner/repo/src/branch/main/docker-compose.yml
/owner/repo/raw/branch/main/docker-compose.yml
/owner/repo/_edit/main/docker-compose.yml
/api/v1/repos/owner/repo/contents/docker-compose.yml
/owner/repo/src/branch/main/Dockerfile
/owner/repo/raw/branch/main/Dockerfile
/owner/repo/_edit/main/Dockerfile
/api/v1/repos/owner/repo/contents/Dockerfile
/owner/repo/src/branch/main/.htaccess
/owner/repo/raw/branch/main/.htaccess
/owner/repo/_edit/main/.htaccess
/api/v1/repos/owner/repo/contents/.htaccess
/owner/repo/src/branch/main/rootfs/etc/passwd
/owner/repo/raw/branch/main/rootfs/etc/passwd
/owner/repo/_edit/main/rootfs/etc/passwd
/api/v1/repos/owner/repo/contents/rootfs/etc/passwd
/owner/repo/src/branch/main/bin/docker-entrypoint
/owner/repo/raw/branch/main/bin/docker-entrypoint
/owner/repo/_edit/main/bin/docker-entrypoint
/api/v1/repos/owner/repo/contents/bin/docker-entrypoint
/owner/repo/src/branch/main/scripts/python3-setup.sh
/owner/repo/raw/branch/main/scripts/python3-setup.sh
/owner/repo/_edit/main/scripts/python3-setup.sh
/api/v1/repos/owner/repo/contents/scripts/python3-setup.sh
/owner/repo/src/branch/main/src/java.base/share/classes/java/lang/Runtime.java
/owner/repo/raw/branch/main/src/java.base/share/classes/java/lang/Runtime.java
/owner/repo/_edit/main/src/java.base/share/classes/java/lang/Runtime.java
/api/v1/repos/owner/repo/contents/src/java.base/share/classes/java/lang/Runtime.java
/owner/repo/src/branch/main/java.lang.String.md
/owner/repo/raw/branch/main/java.lang.String.md
/owner/repo/_edit/main/java.lang.String.md
/api/v1/repos/owner/repo/contents/java.lang.String.md
/owner/repo/src/branch/main/META-INF/services/org.apache.commons.logging.LogFactory
/owner/repo/raw/branch/main/META-INF/services/org.apache.commons.logging.LogFactory
/owner/repo/_edit/main/META-INF/services/org.apache.commons.logging.LogFactory
/api/v1/repos/owner/repo/contents/META-INF/services/org.apache.commons.logging.LogFactory
/owner/repo/src/branch/main/process.env.d.ts
/owner/repo/raw/branch/main/process.env.d.ts
/owner/repo/_edit/main/process.env.d.ts
/api/v1/repos/owner/repo/contents/process.env.d.ts
/owner/repo/src/branch/main/document.write.js
/owner/repo/raw/branch/main/document.write.js
/owner/repo/_edit/main/document.write.js
/api/v1/repos/owner/repo/contents/document.write.js
/owner/repo/src/branch/main/test/fixtures/__proto__.json
/owner/repo/raw/branch/main/test/fixtures/__proto__.json
/owner/repo/_edit/main/test/fixtures/__proto__.json
/api/v1/repos/owner/repo/contents/test/fixtures/__proto__.json
/owner/repo/src/branch/main/install-script.sh
/owner/repo/raw/branch/main/install-script.sh
/owner/repo/_edit/main/install-script.sh
/api/v1/repos/owner/repo/contents/install-script.sh
/owner/repo/src/branch/main/src/backend/catalog/information_schema.sql
/owner/repo/raw/branch/main/src/backend/catalog/information_schema.sql
/owner/repo/_edit/main/src/backend/catalog/information_schema.sql
/api/v1/repos/owner/repo/contents/src/backend/catalog/information_schema.sql
/owner/repo/src/branch/main/docs/a%20b.xhtml
/owner/repo/raw/branch/main/docs/a%20b.xhtml
/owner/repo/_edit/main/docs/a%20b.xhtml
/api/v1/repos/owner/repo/contents/docs/a%20b.xhtml
/owner/repo/src/branch/main/%5Bdraft%5D.md
/owner/repo/raw/branch/main/%5Bdraft%5D.md
/owner/repo/_edit/main/%5Bdraft%5D.md
/api/v1/repos/owner/repo/contents/%5Bdraft%5D.md
/owner/repo/src/branch/main/notes.txt~
/owner/repo/raw/branch/main/notes.txt~
/owner/repo/_edit/main/notes.txt~
/api/v1/repos/owner/repo/contents/notes.txt~
# The download, blame, history and delete pages of the files above whose names
# the Core Rule Set checks in the path.
/owner/repo/media/branch/main/notes.txt~
/owner/repo/blame/branch/main/notes.txt~
/owner/repo/commits/branch/main/notes.txt~
/owner/repo/_delete/main/notes.txt~
/owner/repo/media/branch/main/process.env.d.ts
/owner/repo/blame/branch/main/process.env.d.ts
/owner/repo/commits/branch/main/process.env.d.ts
/owner/repo/_delete/main/process.env.d.ts
/owner/repo/media/branch/main/docs/a%20b.xhtml
/owner/repo/blame/branch/main/docs/a%20b.xhtml
/owner/repo/commits/branch/main/docs/a%20b.xhtml
/owner/repo/_delete/main/docs/a%20b.xhtml
/owner/repo/media/branch/main/java.lang.String.md
/owner/repo/blame/branch/main/java.lang.String.md
/owner/repo/commits/branch/main/java.lang.String.md
/owner/repo/_delete/main/java.lang.String.md
# File names in the query string: the commit graph of a file, the API's lists
# of its commits and of its contents, an issue opened from a template, and the
# sign-in page on the way to its edit page.
/owner/repo/graph?file=package.json
/api/v1/repos/owner/repo/commits?path=package.json
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22package.json%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fpackage.json
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fpackage.json
/owner/repo/graph?file=.gitignore
/api/v1/repos/owner/repo/commits?path=.gitignore
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22.gitignore%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F.gitignore
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F.gitignore
/owner/repo/graph?file=docker-compose.yml
/api/v1/repos/owner/repo/commits?path=docker-compose.yml
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22docker-compose.yml%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fdocker-compose.yml
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fdocker-compose.yml
/owner/repo/graph?file=.htaccess
/api/v1/repos/owner/repo/commits?path=.htaccess
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22.htaccess%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F.htaccess
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F.htaccess
/owner/repo/graph?file=rootfs%2Fetc%2Fpasswd
/api/v1/repos/owner/repo/commits?path=rootfs%2Fetc%2Fpasswd
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22rootfs%2Fetc%2Fpasswd%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Frootfs%2Fetc%2Fpasswd
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Frootfs%2Fetc%2Fpasswd
/owner/repo/graph?file=bin%2Fdocker-entrypoint
/api/v1/repos/owner/repo/commits?path=bin%2Fdocker-entrypoint
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22bin%2Fdocker-entrypoint%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fbin%2Fdocker-entrypoint
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fbin%2Fdocker-entrypoint
/owner/repo/graph?file=scripts%2Fpython3-setup.sh
/api/v1/repos/owner/repo/commits?path=scripts%2Fpython3-setup.sh
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22scripts%2Fpython3-setup.sh%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fscripts%2Fpython3-setup.sh
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fscripts%2Fpython3-setup.sh
/owner/repo/graph?file=src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
/api/v1/repos/owner/repo/commits?path=src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fsrc%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fsrc%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
/owner/repo/graph?file=java.lang.String.md
/api/v1/repos/owner/repo/commits?path=java.lang.String.md
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22java.lang.String.md%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fjava.lang.String.md
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fjava.lang.String.md
/owner/repo/graph?file=META-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
/api/v1/repos/owner/repo/commits?path=META-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22META-INF%2Fservices%2Forg.apache.commons.logging.LogFactory%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2FMETA-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2FMETA-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
/owner/repo/graph?file=process.env.d.ts
/api/v1/repos/owner/repo/commits?path=process.env.d.ts
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22process.env.d.ts%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fprocess.env.d.ts
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fprocess.env.d.ts
/owner/repo/graph?file=document.write.js
/api/v1/repos/owner/repo/commits?path=document.write.js
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22document.write.js%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fdocument.write.js
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fdocument.write.js
/owner/repo/graph?file=test%2Ffixtures%2F__proto__.json
/api/v1/repos/owner/repo/commits?path=test%2Ffixtures%2F__proto__.json
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22test%2Ffixtures%2F__proto__.json%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Ftest%2Ffixtures%2F__proto__.json
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Ftest%2Ffixtures%2F__proto__.json
/owner/repo/graph?file=install-script.sh
/api/v1/repos/owner/repo/commits?path=install-script.sh
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22install-script.sh%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Finstall-script.sh
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Finstall-script.sh
/owner/repo/graph?file=src%2Fbackend%2Fcatalog%2Finformation_schema.sql
/api/v1/repos/owner/repo/commits?path=src%2Fbackend%2Fcatalog%2Finformation_schema.sql
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22src%2Fbackend%2Fcatalog%2Finformation_schema.sql%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fsrc%2Fbackend%2Fcatalog%2Finformation_schema.sql
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fsrc%2Fbackend%2Fcatalog%2Finformation_schema.sql
/owner/repo/graph?file=docs%2Fa%20b.xhtml
/api/v1/repos/owner/repo/commits?path=docs%2Fa%20b.xhtml
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22docs%2Fa%20b.xhtml%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fdocs%2Fa%20b.xhtml
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fdocs%2Fa%20b.xhtml
/owner/repo/graph?file=%5Bdraft%5D.md
/api/v1/repos/owner/repo/commits?path=%5Bdraft%5D.md
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22%5Bdraft%5D.md%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F%5Bdraft%5D.md
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F%5Bdraft%5D.md
# File names of attachments uploaded through the API, to a release, an issue and
# a comment.
/api/v1/repos/owner/repo/releases/1/assets?name=package.json
/api/v1/repos/owner/repo/releases/1/assets?name=.gitignore
/api/v1/repos/owner/repo/releases/1/assets?name=docker-compose.yml
/api/v1/repos/owner/repo/releases/1/assets?name=.htaccess
/api/v1/repos/owner/repo/releases/1/assets?name=docker-entrypoint
/api/v1/repos/owner/repo/releases/1/assets?name=python3-setup.sh
/api/v1/repos/owner/repo/releases/1/assets?name=Runtime.java
/api/v1/repos/owner/repo/releases/1/assets?name=org.apache.commons.logging.LogFactory
/api/v1/repos/owner/repo/releases/1/assets?name=%5Bdraft%5D.md
/api/v1/repos/owner/repo/releases/1/assets?name=notes.txt~
/api/v1/repos/owner/repo/releases/1/assets?name=install-script.sh
/api/v1/repos/owner/repo/issues/1/assets?name=install-script.sh
/api/v1/repos/owner/repo/issues/comments/1/assets?name=install-script.sh
/api/v1/repos/owner/repo/releases/1/assets?name=process.env.d.ts
/api/v1/repos/owner/repo/issues/1/assets?name=process.env.d.ts
/api/v1/repos/owner/repo/issues/comments/1/assets?name=process.env.d.ts
/api/v1/repos/owner/repo/releases/1/assets?name=java.lang.String.md
/api/v1/repos/owner/repo/issues/1/assets?name=java.lang.String.md
/api/v1/repos/owner/repo/issues/comments/1/assets?name=java.lang.String.md
/api/v1/repos/owner/repo/releases/1/assets?name=information_schema.sql
/api/v1/repos/owner/repo/issues/1/assets?name=information_schema.sql
/api/v1/repos/owner/repo/issues/comments/1/assets?name=information_schema.sql
/api/v1/repos/owner/repo/releases/1/assets?name=document.write.js
/api/v1/repos/owner/repo/issues/1/assets?name=document.write.js
/api/v1/repos/owner/repo/issues/comments/1/assets?name=document.write.js
/api/v1/repos/owner/repo/releases/1/assets?name=__proto__.json
/api/v1/repos/owner/repo/issues/1/assets?name=__proto__.json
/api/v1/repos/owner/repo/issues/comments/1/assets?name=__proto__.json
/api/v1/repos/owner/repo/releases/1/assets?name=index.xhtml
/api/v1/repos/owner/repo/issues/1/assets?name=index.xhtml
/api/v1/repos/owner/repo/issues/comments/1/assets?name=index.xhtml
# An artifact upload from actions/upload-artifact@v3, which sends Content-Range.
/api/actions_pipeline/_apis/pipelines/workflows/1/artifacts/9b7c1f3a/upload?itemPath=artifact%2Freport.txt Content-Range: bytes 0-9/10
# Branch and tag names in the query string: deleting and restoring a branch,
# starting a release from a tag, a branch protection rule, a directory listing's
# last commits, and a container image push that reuses a layer.
/owner/repo/branches/delete?name=docker-build&page=1
/owner/repo/branches/restore?branch_id=1&name=docker-build&page=1
/owner/repo/releases/new?tag=docker-build
/owner/repo/settings/branches/edit?rule_name=docker-build
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fdocker-build
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fdocker-build
/owner/repo/branches/delete?name=feat%2Fdocker-support&page=1
/owner/repo/branches/restore?branch_id=1&name=feat%2Fdocker-support&page=1
/owner/repo/releases/new?tag=feat%2Fdocker-support
/owner/repo/settings/branches/edit?rule_name=feat%2Fdocker-support
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Ffeat%2Fdocker-support
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Ffeat%2Fdocker-support
/owner/repo/branches/delete?name=renovate%2Fdocker-build-push-action-6.x&page=1
/owner/repo/branches/restore?branch_id=1&name=renovate%2Fdocker-build-push-action-6.x&page=1
/owner/repo/releases/new?tag=renovate%2Fdocker-build-push-action-6.x
/owner/repo/settings/branches/edit?rule_name=renovate%2Fdocker-build-push-action-6.x
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Frenovate%2Fdocker-build-push-action-6.x
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Frenovate%2Fdocker-build-push-action-6.x
/owner/repo/branches/delete?name=python3-migration&page=1
/owner/repo/branches/restore?branch_id=1&name=python3-migration&page=1
/owner/repo/releases/new?tag=python3-migration
/owner/repo/settings/branches/edit?rule_name=python3-migration
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fpython3-migration
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fpython3-migration
/owner/repo/branches/delete?name=ansible-roles&page=1
/owner/repo/branches/restore?branch_id=1&name=ansible-roles&page=1
/owner/repo/releases/new?tag=ansible-roles
/owner/repo/settings/branches/edit?rule_name=ansible-roles
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fansible-roles
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fansible-roles
/owner/repo/branches/delete?name=whoami&page=1
/owner/repo/branches/restore?branch_id=1&name=whoami&page=1
/owner/repo/releases/new?tag=whoami
/owner/repo/settings/branches/edit?rule_name=whoami
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fwhoami
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fwhoami
# Pages a signed-in user opens.
/
/owner/repo
/owner/repo/issues
/owner/repo/pulls
/owner/repo/releases
/owner/repo/wiki
/owner/repo/activity
/owner/repo/branches
/owner/repo/tags
/owner/repo/settings
/owner/repo/actions
/owner/repo/compare/main...main
/owner/repo/commits/branch/main
/owner/repo/archive/main.zip
/notifications
/user/settings
/user/settings/applications
/explore/repos
/-/admin
+110 -15
View File
@@ -1,8 +1,9 @@
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the // Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
// method, the URL with its query and the headers of a request, and on its // method, the URL with its query and the headers of a request, and on its
// body while SWWAF_WAF_BODY_LIMIT is set, with the six changes smallwebwaf // body while SWWAF_WAF_BODY_LIMIT is set, with the eight changes smallwebwaf
// makes to it, as "Attack detection" under "Settings" in SPEC.md describes // makes to it and the exclusions in SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE, as
// them. It reads no response. // "Attack detection" under "Settings" in SPEC.md describes them. It reads no
// response.
// //
// smallwebwaf writes only to its state directory, so Coraza is built with // smallwebwaf writes only to its state directory, so Coraza is built with
// its no_fs_access tag, as the Dockerfile and script/build build it: of a // its no_fs_access tag, as the Dockerfile and script/build build it: of a
@@ -11,10 +12,14 @@
package waf package waf
import ( import (
_ "embed"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/netip" "net/netip"
"os"
"regexp"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -26,12 +31,14 @@ import (
) )
// directives are the Core Rule Set as smallwebwaf runs it, with the // directives are the Core Rule Set as smallwebwaf runs it, with the
// paranoia level for %d, and bodyDirectives for %s while // paranoia level for %d, bodyDirectives for the first %s while
// SWWAF_WAF_BODY_LIMIT is set. Each rule smallwebwaf adds has an id from // SWWAF_WAF_BODY_LIMIT is set, the exclusions file for the second, and
// 900000 to 900999, the ids the Core Rule Set keeps for the rules that set // giteaExclusions for the third.
// it up, which SWWAF_WAF_DISABLED_RULES refuses, so that no setting // Each rule smallwebwaf adds has an id from 900000 to 900999, the ids the
// switches one off. Coraza joins a line ending in \ to the next, without // Core Rule Set keeps for the rules that set it up, which
// the spaces at the start of the next. // SWWAF_WAF_DISABLED_RULES and the exclusions file refuse, so that no
// setting switches one off. Coraza joins a line ending in \ to the next,
// without the spaces at the start of the next.
const directives = ` const directives = `
# The engine only detects. smallwebwaf compares the request's anomaly # The engine only detects. smallwebwaf compares the request's anomaly
# score with SWWAF_WAF_ANOMALY_THRESHOLD itself, in block and detect mode # score with SWWAF_WAF_ANOMALY_THRESHOLD itself, in block and detect mode
@@ -69,6 +76,14 @@ SecArgumentsLimit 1000
SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'" severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
# The exclusions file comes before the rules, where the Core Rule Set puts
# its own file of exclusions that run before the rules, so that they run
# before the rules of phase 1 too.
%s
# The eighth change comes before the rules for the same reason.
%s
# The sixth: only the rules for requests are loaded, and no response is # The sixth: only the rules for requests are loaded, and no response is
# inspected. # inspected.
Include @owasp_crs/REQUEST-*.conf Include @owasp_crs/REQUEST-*.conf
@@ -96,10 +111,19 @@ SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:artifactName|!ARGS:redirect_to" !ARGS:artifactName|!ARGS:redirect_to"
# The fifth, for Referer: it is not checked for a Unix command without # The fifth, for Referer: it is not checked for a Unix command without
# arguments, or for Java starting a process. The cookies are left out in # arguments, for Java starting a process, or for a Java class name. 944130
# Inspect. # refused every page opened from the page of java.lang.String.md. The
# cookies are left out in Inspect.
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
# The seventh: a container image push that reuses a layer names the image
# it takes the layer from in the query parameter from, which is not
# checked for command names. 932260 refused the push of a layer from
# owner/python3-app.
SecRule REQUEST_FILENAME "@rx ^/v2/.+/blobs/uploads/$" \
"id:900500,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:from"
` `
// bodyDirectives have the Core Rule Set read the part of a request body // bodyDirectives have the Core Rule Set read the part of a request body
@@ -144,6 +168,13 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'" severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
` `
// giteaExclusions are the eighth change: the fields of gitea's own
// requests that skip the rules that refused them there, as Coraza
// directives.
//
//go:embed gitea.conf
var giteaExclusions string
// cookiesNotRead are the cookies the Core Rule Set reads a request // cookiesNotRead are the cookies the Core Rule Set reads a request
// without, the rest of the fifth change. // without, the rest of the fifth change.
// //
@@ -160,6 +191,51 @@ type Params struct {
// BodyLimit is the most of a request body the Core Rule Set reads // BodyLimit is the most of a request body the Core Rule Set reads
// (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none. // (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none.
BodyLimit int64 BodyLimit int64
// ExclusionsFile is the path of a file of exclusions loaded before the
// Core Rule Set's rules (SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
// there is none.
ExclusionsFile string
}
// The ids of smallwebwaf's own rules, which an exclusions file may not
// name.
const (
firstOwnRuleID = 900000
lastOwnRuleID = 900999
)
// numberOrRange matches a number, or a range of numbers such as
// 900000-900999.
var numberOrRange = regexp.MustCompile(`(\d+)(?:-(\d+))?`)
// errSmallwebwafRuleID is the error of an exclusions file that names an
// id of smallwebwaf's own rules.
var errSmallwebwafRuleID = errors.New("an id of smallwebwaf's own rules")
// ownRuleNamed returns the first number or range in exclusions, the text
// of an exclusions file, that takes in an id of smallwebwaf's own rules,
// or "" if none does. Comment lines are skipped, as Coraza skips them.
func ownRuleNamed(exclusions string) string {
for line := range strings.Lines(exclusions) {
if strings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
for _, match := range numberOrRange.FindAllStringSubmatch(line, -1) {
first, _ := strconv.Atoi(match[1])
last := first
if match[2] != "" {
last, _ = strconv.Atoi(match[2])
}
if first <= lastOwnRuleID && last >= firstOwnRuleID {
return match[0]
}
}
}
return ""
} }
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe // CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
@@ -169,16 +245,35 @@ type CoreRuleSet struct {
bodyLimit int64 bodyLimit int64
} }
// New returns the Core Rule Set with the six changes, at params' // New returns the Core Rule Set with the eight changes, at params'
// paranoia level, without the rules it switches off, and reading request // paranoia level, without the rules it switches off, reading request
// bodies up to params' limit. // bodies up to params' limit, and with params' exclusions file loaded
// before the Core Rule Set's rules. An exclusions file that cannot be
// read, does not parse or names an id of smallwebwaf's own rules is an
// error.
func New(params Params) (*CoreRuleSet, error) { func New(params Params) (*CoreRuleSet, error) {
body := "" body := ""
if params.BodyLimit > 0 { if params.BodyLimit > 0 {
body = fmt.Sprintf(bodyDirectives, params.BodyLimit) body = fmt.Sprintf(bodyDirectives, params.BodyLimit)
} }
text := fmt.Sprintf(directives, params.ParanoiaLevel, body) exclusions := ""
if params.ExclusionsFile != "" {
file, err := os.ReadFile(params.ExclusionsFile)
if err != nil {
return nil, fmt.Errorf("read the exclusions file: %w", err)
}
exclusions = string(file)
if named := ownRuleNamed(exclusions); named != "" {
return nil, fmt.Errorf("the exclusions file names %s: %w", named,
errSmallwebwafRuleID)
}
}
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions, giteaExclusions)
if len(params.DisabledRules) > 0 { if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules)) ids := make([]string, len(params.DisabledRules))
+167 -2
View File
@@ -5,6 +5,7 @@ import (
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"net/url" "net/url"
"os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strconv" "strconv"
@@ -119,7 +120,7 @@ func atDefaults(t *testing.T) *waf.CoreRuleSet {
} }
// wantChange checks that crs lets through passes, a gitea request one of // wantChange checks that crs lets through passes, a gitea request one of
// the six changes is for, and still finds result in refused, a request // the eight changes is for, and still finds result in refused, a request
// like it that the change is not for. // like it that the change is not for.
func wantChange( func wantChange(
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result, t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
@@ -308,13 +309,14 @@ func TestCookiesGiteaFlashAndRedirectToAreNotRead(t *testing.T) {
matched(930120)) matched(930120))
} }
func TestRefererIsNotCheckedForACommandOrJavaStartingAProcess(t *testing.T) { func TestRefererIsNotCheckedForACommandOrJava(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
search = "https://git.example/explore/repos?q=env" search = "https://git.example/explore/repos?q=env"
runtimeJava = "https://git.example/openjdk/jdk/src/branch/master/src/" + runtimeJava = "https://git.example/openjdk/jdk/src/branch/master/src/" +
"java.base/share/classes/java/lang/Runtime.java" "java.base/share/classes/java/lang/Runtime.java"
stringMD = "https://git.example/owner/repo/src/branch/main/java.lang.String.md"
) )
crs := atDefaults(t) crs := atDefaults(t)
@@ -323,6 +325,8 @@ func TestRefererIsNotCheckedForACommandOrJavaStartingAProcess(t *testing.T) {
matched(932340)) matched(932340))
wantChange(t, crs, get("/", "Referer: "+runtimeJava), wantChange(t, crs, get("/", "Referer: "+runtimeJava),
get("/", "X-Page: "+runtimeJava), matched(944110)) get("/", "X-Page: "+runtimeJava), matched(944110))
wantChange(t, crs, get("/", "Referer: "+stringMD), get("/", "X-Page: "+stringMD),
matched(944130))
// It is still checked for script and SQL injection. // It is still checked for script and SQL injection.
wantResult(t, crs, wantResult(t, crs,
@@ -362,6 +366,167 @@ func TestEachDisabledRuleIsSwitchedOff(t *testing.T) {
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{}) wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{})
} }
func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
reuse := "?mount=sha256:0000&from=owner/python3-app"
wantChange(t, crs, get("/v2/owner/app/blobs/uploads/"+reuse), get("/upload/"+reuse),
matched(932260))
// A command after the image's name is still refused.
wantResult(t, crs,
get("/v2/owner/app/blobs/uploads/?from=owner/app;cat%20/etc/passwd"),
matched(930120, 932160))
}
// withExclusions returns the Core Rule Set at the defaults, with the
// exclusions file at path.
func withExclusions(path string) (*waf.CoreRuleSet, error) {
return waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path,
})
}
// exclusionsFile writes text to a file of exclusions and returns its path.
func exclusionsFile(t *testing.T, text string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "exclusions.conf")
err := os.WriteFile(path, []byte(text), 0o600)
if err != nil {
t.Fatal(err)
}
return path
}
// backupFileExclusion is an exclusions file that switches 920500, a rule
// of phase 1, off for the path of a backup file under /files/.
const backupFileExclusion = `SecRule REQUEST_FILENAME "@beginsWith /files/" ` +
`"id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME"` + "\n"
func TestExclusionsFileIsLoadedBeforeTheRules(t *testing.T) {
t.Parallel()
crs, err := withExclusions(exclusionsFile(t, backupFileExclusion))
if err != nil {
t.Fatalf("load the exclusions: %v", err)
}
wantChange(t, crs, get("/files/notes.txt~"), get("/notes.txt~"), matched(920500))
}
func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
t.Parallel()
crs, err := waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: []int{920500},
ExclusionsFile: exclusionsFile(t, backupFileExclusion),
})
if err != nil {
t.Fatalf("load the exclusions: %v", err)
}
wantResult(t, crs, get("/notes.txt~"), waf.Result{})
}
func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) {
t.Parallel()
_, err := withExclusions(exclusionsFile(t,
"# 942100: GET /issues/900123\n"+backupFileExclusion))
if err != nil {
t.Errorf("the exclusions did not load: %v", err)
}
}
func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) {
t.Parallel()
for _, path := range []string{
filepath.Join(t.TempDir(), "missing.conf"),
exclusionsFile(t, "SecNoSuchDirective On\n"),
// The Core Rule Set's rules are not loaded yet.
exclusionsFile(t, "SecRuleUpdateTargetById 942100 \"!ARGS:id\"\n"),
// smallwebwaf's own rules may not be switched off or changed, by
// id or in a range.
exclusionsFile(t, "SecRuleRemoveById 900300\n"),
exclusionsFile(t, "SecRuleRemoveById 899999-901000\n"),
exclusionsFile(t, "SecAction \"id:1,phase:1,pass,nolog,"+
"ctl:ruleRemoveById=899999-901000\"\n"),
exclusionsFile(t, "SecAction \"id:1,phase:1,pass,nolog,"+
"ctl:ruleRemoveTargetById=900500;ARGS:from\"\n"),
} {
_, err := withExclusions(path)
if err == nil {
t.Errorf("%s loaded", path)
}
}
}
// giteaRequests returns the recorded ordinary gitea requests in testdata.
// Each has the Referer of a page that names the same, as a page opened
// from it has.
func giteaRequests(t *testing.T) []request {
t.Helper()
text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt"))
if err != nil {
t.Fatal(err)
}
var requests []request
for line := range strings.Lines(string(text)) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
target, header, _ := strings.Cut(line, " ")
headers := []string{"Referer: http://git.example" + target}
if header != "" {
headers = append(headers, header)
}
requests = append(requests, get(target, headers...))
}
return requests
}
func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWWAF_WAF_ANOMALY_THRESHOLD's default
crs := atDefaults(t)
for _, r := range giteaRequests(t) {
if got := inspect(t, crs, r); got.Score >= threshold {
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
}
}
// In q too, off gitea's search pages.
for _, parameter := range []string{"id", "q"} {
for _, attack := range []string{
"1'%20OR%20'1'='1",
"..%2Fcustom%2Fconf%2Fapp.ini",
"%3Cscript%3Ealert(1)%3C%2Fscript%3E",
} {
target := "/owner/repo?" + parameter + "=" + attack
if got := inspect(t, crs, get(target)); got.Score < threshold {
t.Errorf("%s let through: %+v", target, got)
}
}
}
}
// bodyLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies. // bodyLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies.
const bodyLimit = 8 << 10 const bodyLimit = 8 << 10