Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
866b948ec0 |
@@ -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; `deploy/gitea/crs-exclusions.conf` is gitea's. |
|
||||||
| `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,14 @@ 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 seven 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. No setting undoes them; "Attack detection" in
|
||||||
[`SPEC.md`](SPEC.md) lists them.
|
[`SPEC.md`](SPEC.md) lists them.
|
||||||
|
- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's
|
||||||
|
own fields or paths. gitea's exclusions, in
|
||||||
|
`deploy/gitea/crs-exclusions.conf`, let its searches, file names and branch
|
||||||
|
names through.
|
||||||
- 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)).
|
||||||
|
|||||||
@@ -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 seven 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.
|
||||||
|
`deploy/gitea/crs-exclusions.conf` 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 seven 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:
|
||||||
|
|
||||||
@@ -675,6 +689,10 @@ every app, and no setting undoes them:
|
|||||||
must check it itself.
|
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.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -104,11 +104,12 @@ services:
|
|||||||
# address of an automation fleet on another host here too; without
|
# address of an automation fleet on another host here too; without
|
||||||
# that, it is counted like any client.
|
# that, it is counted like any client.
|
||||||
SWWAF_ALLOW_NETS: 10.99.0.128/25,fd10:99:0:0:8000::/65
|
SWWAF_ALLOW_NETS: 10.99.0.128/25,fd10:99:0:0:8000::/65
|
||||||
# In block mode the Core Rule Set refuses everyday Gitea searches
|
# The Core Rule Set's exclusions for Gitea's searches, file names
|
||||||
# and file names, and 31 refusals in a minute ban the address.
|
# and branch names.
|
||||||
SWWAF_WAF_MODE: detect
|
SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE: /etc/smallwebwaf/crs-exclusions.conf
|
||||||
volumes:
|
volumes:
|
||||||
- servewaf-state:/var/lib/smallwebwaf
|
- servewaf-state:/var/lib/smallwebwaf
|
||||||
|
- ./crs-exclusions.conf:/etc/smallwebwaf/crs-exclusions.conf:ro
|
||||||
networks:
|
networks:
|
||||||
- shared
|
- shared
|
||||||
logging: *logging
|
logging: *logging
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# The Core Rule Set's exclusions for Gitea, which servewaf loads from
|
||||||
|
# SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE. servewaf loads this file before the Core
|
||||||
|
# Rule Set's rules, so it holds only exclusions made while a request is checked:
|
||||||
|
# SecAction and SecRule lines with ctl:ruleRemoveTargetById or
|
||||||
|
# ctl:ruleRemoveById. Each switches one rule off for the Gitea fields or paths
|
||||||
|
# where it refused ordinary Gitea requests. A SecAction applies on every path, a
|
||||||
|
# SecRule on the paths it matches. Gitea sends these in the query string:
|
||||||
|
# - q: the text of every search;
|
||||||
|
# - file, path, template, body and redirect_to: a file name, alone or in
|
||||||
|
# JSON or a link;
|
||||||
|
# - name, tag, rule_name and refSubUrl: a branch or tag name.
|
||||||
|
# docs/gitea.md says how to add an exclusion.
|
||||||
|
|
||||||
|
# Searches.
|
||||||
|
# 930110: a search for ../
|
||||||
|
SecAction "id:10001,phase:1,pass,nolog,ctl:ruleRemoveTargetById=930110;ARGS:q"
|
||||||
|
# 931100: a search for http://127.0.0.1:8080
|
||||||
|
SecAction "id:10002,phase:1,pass,nolog,ctl:ruleRemoveTargetById=931100;ARGS:q"
|
||||||
|
# 932140: a search for if (a == b) {
|
||||||
|
SecAction "id:10003,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932140;ARGS:q"
|
||||||
|
# 932235: a search for `make check`
|
||||||
|
SecAction "id:10004,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932235;ARGS:q"
|
||||||
|
# 932250: a search for ls -la
|
||||||
|
SecAction "id:10005,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932250;ARGS:q"
|
||||||
|
# 932380: a search for `whoami`
|
||||||
|
SecAction "id:10006,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932380;ARGS:q"
|
||||||
|
# 933100: a search for <?php echo
|
||||||
|
SecAction "id:10007,phase:1,pass,nolog,ctl:ruleRemoveTargetById=933100;ARGS:q"
|
||||||
|
# 933130: a search for $_GET[
|
||||||
|
SecAction "id:10008,phase:1,pass,nolog,ctl:ruleRemoveTargetById=933130;ARGS:q"
|
||||||
|
# 933135: a search for ${VAR}
|
||||||
|
SecAction "id:10009,phase:1,pass,nolog,ctl:ruleRemoveTargetById=933135;ARGS:q"
|
||||||
|
# 934110: a search for http://127.0.0.1:8080
|
||||||
|
SecAction "id:10010,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934110;ARGS:q"
|
||||||
|
# 941100: a search for <script>
|
||||||
|
SecAction "id:10011,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941100;ARGS:q"
|
||||||
|
# 941110: a search for <script>, or a page opened from its results
|
||||||
|
SecAction "id:10012,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=941110;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=941110;REQUEST_HEADERS:Referer"
|
||||||
|
# 941120: a page opened from the results of a search for onClick={
|
||||||
|
SecAction "id:10013,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=941120;REQUEST_HEADERS:Referer"
|
||||||
|
# 941160: a search for <img src=, or a page opened from its results
|
||||||
|
SecAction "id:10014,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=941160;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=941160;REQUEST_HEADERS:Referer"
|
||||||
|
# 941390: a search for fetch(
|
||||||
|
SecAction "id:10015,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941390;ARGS:q"
|
||||||
|
# 942100: a search for SELECT * FROM users WHERE, or a page opened from the
|
||||||
|
# results of a search for LIKE '%foo%'
|
||||||
|
SecAction "id:10016,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=942100;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=942100;REQUEST_HEADERS:Referer"
|
||||||
|
# 942190: a search for union select
|
||||||
|
SecAction "id:10017,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942190;ARGS:q"
|
||||||
|
# 942230: a search for if (a == b) {
|
||||||
|
SecAction "id:10018,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942230;ARGS:q"
|
||||||
|
# 944100: a search for java.lang.Runtime, or a page opened from its results
|
||||||
|
SecAction "id:10019,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=944100;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=944100;REQUEST_HEADERS:Referer"
|
||||||
|
|
||||||
|
# Searches, and file, branch and tag names.
|
||||||
|
# 930120: a search for package.json, or the commit graph of .gitignore
|
||||||
|
SecAction "id:10020,phase:1,pass,nolog,ctl:ruleRemoveTargetById=930120;\
|
||||||
|
ARGS:/^(?:q|file|template|body|refsuburl|name|tag|rule_name)$/"
|
||||||
|
# 932160: a search for /bin/sh, or the API's contents of bin/docker-entrypoint
|
||||||
|
SecAction "id:10021,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932160;\
|
||||||
|
ARGS:/^(?:q|file|template|body|refsuburl|name|tag|rule_name)$/"
|
||||||
|
# 932260: a search for python3, or deleting the branch docker-build
|
||||||
|
SecAction "id:10022,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;\
|
||||||
|
ARGS:/^(?:q|file|template|body|refsuburl|name|tag|rule_name)$/"
|
||||||
|
|
||||||
|
# Searches, and file names in the query string.
|
||||||
|
# 932120: a search for Invoke-WebRequest, or the commits of install-script.sh
|
||||||
|
SecAction "id:10023,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932120;\
|
||||||
|
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
|
||||||
|
# 932130: a search for $(pwd), or an issue opened from the template [draft].md
|
||||||
|
SecAction "id:10024,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932130;\
|
||||||
|
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
|
||||||
|
# 934100: a search for process.env, or the commits of process.env.d.ts
|
||||||
|
SecAction "id:10025,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934100;\
|
||||||
|
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
|
||||||
|
# 934130: a search for __proto__, or the commits of __proto__.json
|
||||||
|
SecAction "id:10026,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934130;\
|
||||||
|
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
|
||||||
|
# 941130: the commits of index.xhtml
|
||||||
|
SecAction "id:10027,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941130;\
|
||||||
|
ARGS:/^(?:file|path|template|body|redirect_to)$/"
|
||||||
|
# 941180: a search for document.cookie, or the commits of document.write.js
|
||||||
|
SecAction "id:10028,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941180;\
|
||||||
|
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
|
||||||
|
# 942140: a search for information_schema, or the commits of
|
||||||
|
# information_schema.sql
|
||||||
|
SecAction "id:10029,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942140;\
|
||||||
|
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
|
||||||
|
# 944110: a search for java.lang.Runtime, or the commits of Runtime.java
|
||||||
|
SecAction "id:10030,phase:1,pass,nolog,ctl:ruleRemoveTargetById=944110;\
|
||||||
|
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
|
||||||
|
# 944130: a search for java.lang.Runtime, the commits of java.lang.String.md,
|
||||||
|
# or any page opened from that file's page, which names it in the Referer
|
||||||
|
SecAction "id:10031,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=944130;\
|
||||||
|
ARGS:/^(?:q|file|path|template|body|redirect_to)$/,\
|
||||||
|
ctl:ruleRemoveTargetById=944130;REQUEST_HEADERS:Referer"
|
||||||
|
|
||||||
|
# File names in the path of a file's page, raw file, blame, history and edit
|
||||||
|
# page, and of the API's calls for its contents.
|
||||||
|
# 920500: the page of notes.txt~
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_delete|contents)/" \
|
||||||
|
"id:10032,phase:1,pass,nolog,ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME"
|
||||||
|
# 934100: the page of process.env.d.ts
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_delete|contents)/" \
|
||||||
|
"id:10033,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934100;REQUEST_FILENAME"
|
||||||
|
# 941130: the page of docs/a b.xhtml
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_delete|contents)/" \
|
||||||
|
"id:10034,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941130;REQUEST_FILENAME"
|
||||||
|
# 944130: the page of java.lang.String.md
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_delete|contents)/" \
|
||||||
|
"id:10035,phase:1,pass,nolog,ctl:ruleRemoveTargetById=944130;REQUEST_FILENAME"
|
||||||
|
|
||||||
|
# The file name, in name, of an attachment uploaded through the API to a
|
||||||
|
# release, an issue or a comment.
|
||||||
|
# 932120: the attachment install-script.sh
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||||
|
"id:10036,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932120;ARGS:name"
|
||||||
|
# 934100: the attachment process.env.d.ts
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||||
|
"id:10037,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934100;ARGS:name"
|
||||||
|
# 934130: the attachment __proto__.json
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||||
|
"id:10038,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934130;ARGS:name"
|
||||||
|
# 941130: the attachment index.xhtml
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||||
|
"id:10039,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941130;ARGS:name"
|
||||||
|
# 941180: the attachment document.write.js
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||||
|
"id:10040,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941180;ARGS:name"
|
||||||
|
# 942140: the attachment information_schema.sql
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||||
|
"id:10041,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942140;ARGS:name"
|
||||||
|
# 944130: the attachment java.lang.String.md
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||||
|
"id:10042,phase:1,pass,nolog,ctl:ruleRemoveTargetById=944130;ARGS:name"
|
||||||
|
|
||||||
|
# The header Content-Range of an artifact upload.
|
||||||
|
# 920450: an artifact upload from actions/upload-artifact@v3
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
|
||||||
|
"id:10043,phase:1,pass,nolog,ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
||||||
+50
-184
@@ -13,7 +13,8 @@ Four containers share one Docker network, the shared network:
|
|||||||
plain HTTP to HTTPS;
|
plain HTTP to HTTPS;
|
||||||
- `rproxy`, in front of gitea;
|
- `rproxy`, in front of gitea;
|
||||||
- gitea 1.27.3, as its image comes;
|
- gitea 1.27.3, as its image comes;
|
||||||
- `servewaf`, with a volume for its state files.
|
- `servewaf`, with a volume for its state files, and with
|
||||||
|
`deploy/gitea/crs-exclusions.conf` mounted read-only.
|
||||||
|
|
||||||
To start it:
|
To start it:
|
||||||
|
|
||||||
@@ -30,7 +31,8 @@ To start it:
|
|||||||
`docker run --rm <gitea's image> gitea generate secret SECRET_KEY`, with
|
`docker run --rm <gitea's image> gitea generate secret SECRET_KEY`, with
|
||||||
gitea's image as `deploy/gitea/compose.yaml` names it. Do this once per
|
gitea's image as `deploy/gitea/compose.yaml` names it. Do this once per
|
||||||
install, and keep the key.
|
install, and keep the key.
|
||||||
5. Run `docker compose up --detach` in `deploy/gitea`.
|
5. Run `docker compose up --detach` in `deploy/gitea`, with
|
||||||
|
`crs-exclusions.conf` beside `compose.yaml`.
|
||||||
6. Make gitea's first user with `gitea admin user create`, in gitea's container,
|
6. Make gitea's first user with `gitea admin user create`, in gitea's container,
|
||||||
as the user `git`.
|
as the user `git`.
|
||||||
|
|
||||||
@@ -46,6 +48,12 @@ under a name of its own, and checks:
|
|||||||
`rproxy`, and that `servewaf` checks them;
|
`rproxy`, and that `servewaf` checks them;
|
||||||
- that gitea logs the visitor's address, not `rproxy`'s;
|
- that gitea logs the visitor's address, not `rproxy`'s;
|
||||||
- that a request from a container on the shared network skips the checks;
|
- that a request from a container on the shared network skips the checks;
|
||||||
|
- that the Core Rule Set refuses none of the ordinary gitea requests in
|
||||||
|
`internal/waf/testdata/gitea-requests.txt`, sent by a signed-in user;
|
||||||
|
- that it refuses none of the API calls with JSON bodies;
|
||||||
|
- that it refuses no fetch;
|
||||||
|
- that it refuses SQL injection, path traversal and script injection in a query
|
||||||
|
string;
|
||||||
- that with `servewaf` stopped, gitea still answers through `rproxy`.
|
- that with `servewaf` stopped, gitea still answers through `rproxy`.
|
||||||
|
|
||||||
## Uploads and downloads
|
## Uploads and downloads
|
||||||
@@ -79,11 +87,8 @@ GiB a day.
|
|||||||
|
|
||||||
## The checks
|
## The checks
|
||||||
|
|
||||||
`SWWAF_WAF_MODE` is `detect`. The Core Rule Set then logs a match, with its rule
|
The Core Rule Set runs in `block` mode, the default, with gitea's exclusions
|
||||||
ids, and refuses nothing. In `block` mode, the default, it refuses everyday
|
(see "The Core Rule Set's exclusions" below).
|
||||||
gitea searches and file names. The section "What the Core Rule Set refuses in
|
|
||||||
`block` mode" below lists them. Each such refusal counts toward the error burst,
|
|
||||||
so 31 in one minute ban the address.
|
|
||||||
|
|
||||||
Every other check stays at its default. A client may make 120 requests a minute,
|
Every other check stays at its default. A client may make 120 requests a minute,
|
||||||
1,500 an hour and 6,000 a day. Archive downloads and blame and history pages are
|
1,500 an hour and 6,000 a day. Archive downloads and blame and history pages are
|
||||||
@@ -153,188 +158,49 @@ within two minutes. Its address is then banned, its running jobs fail, and each
|
|||||||
repeat within a day makes the ban longer. Newer runners ask for work less often.
|
repeat within a day makes the ban longer. Newer runners ask for work less often.
|
||||||
Put the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
Put the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||||
|
|
||||||
## What the Core Rule Set refuses in `block` mode
|
## The Core Rule Set's exclusions
|
||||||
|
|
||||||
These notes record what the Core Rule Set does in front of gitea in `block`
|
`deploy/gitea/crs-exclusions.conf` switches single Core Rule Set rules off where
|
||||||
mode, at the other defaults. They are observations, not requirements.
|
they refused ordinary gitea requests. `servewaf` loads it from
|
||||||
`script/gitea-requests` runs gitea behind `rproxy` and `servewaf` at the
|
`SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE`, before the Core Rule Set's rules. Each
|
||||||
defaults. It sends through them a sample of gitea's ordinary use and the
|
exclusion covers one rule, in these places only:
|
||||||
requests named below. It reports each request the Core Rule Set refuses, so that
|
|
||||||
these notes can be checked again when gitea or the Core Rule Set changes
|
|
||||||
version. In `detect` mode, as the setup runs it, the same requests are logged
|
|
||||||
with their rule ids, and pass.
|
|
||||||
|
|
||||||
Each refusal answers only that request ("Bans" in `SPEC.md` says what it counts
|
- `q`, the text of every search;
|
||||||
toward). The request log names the rule in `waf_rule_ids`, which
|
- the query parameters that hold a file name: `file`, `path`, `template`, `body`
|
||||||
`SWWAF_WAF_DISABLED_RULES` can switch off.
|
and `redirect_to`;
|
||||||
|
- the query parameters that hold a branch or tag name: `name`, `tag`,
|
||||||
|
`rule_name` and `refSubUrl`;
|
||||||
|
- `Referer`, on a page opened from search results or from a file's page;
|
||||||
|
- the path of a file's page, raw file, blame, history and edit page, and of the
|
||||||
|
API's call for its contents;
|
||||||
|
- `name`, the file name of an attachment uploaded through the API;
|
||||||
|
- the header `Content-Range` of an Actions artifact upload.
|
||||||
|
|
||||||
### What it lets through
|
gitea reads these as text to search for, as names in a repository, or, for
|
||||||
|
`Content-Range`, as the part of a file an upload carries. It never runs them as
|
||||||
|
SQL, a command or a page's script. So an attack that only the excluded rules
|
||||||
|
would catch there reaches gitea and does nothing. Every other field and path
|
||||||
|
keeps every rule.
|
||||||
|
|
||||||
The Core Rule Set lets gitea's ordinary use through, apart from the refusals in
|
To add an exclusion for a request the Core Rule Set refuses:
|
||||||
the sections after this one:
|
|
||||||
|
|
||||||
- browsing and views of files in a repository, with their history, blame and the
|
1. Find the request in `servewaf`'s log. Its line gives the path and query, and
|
||||||
file tree;
|
names the rules in `waf_rule_ids`.
|
||||||
- diffs, including their hidden lines and large files, and pull request review;
|
2. For each rule, add a line with `ctl:ruleRemoveTargetById` for the field it
|
||||||
- git's clone, fetch and push over HTTP;
|
matched. For a field on every path, copy one of the file's `SecAction` lines.
|
||||||
- signing in, including the return to the page a visitor came from, and sign-in
|
For some paths only, copy one of its `SecRule REQUEST_FILENAME` lines. Give
|
||||||
with Git Credential Manager, git-credential-oauth or tea;
|
it a new id. An id from 900000 to 900999 stops the start.
|
||||||
- the API's calls for a file and its commits;
|
3. Above it, write a one-line comment naming the request it refused.
|
||||||
- pushing and pulling container images and packages;
|
4. Add the request to `internal/waf/testdata/gitea-requests.txt`, and run
|
||||||
- Actions runners, and artifacts uploaded with `actions/upload-artifact@v4`;
|
`make check`.
|
||||||
- posting issues, pull requests, comments, wiki pages and files saved in the web
|
5. Restart `servewaf`.
|
||||||
editor, code included, since no body is read;
|
|
||||||
- the page gitea shows after a change, with its message naming what was done.
|
|
||||||
|
|
||||||
### A query string that reads as an attack
|
The Core Rule Set still refuses the page of a file in a directory named `...`,
|
||||||
|
which reads as path traversal.
|
||||||
|
|
||||||
Most often this is a search. A search term is a query parameter, which the Core
|
With `SWWAF_WAF_BODY_LIMIT` set, it also refuses issue text, comments, wiki
|
||||||
Rule Set checks against its lists of system files and commands (see the fourth
|
pages and files saved in the web editor that hold shell commands or code.
|
||||||
change under "Attack detection" in `SPEC.md`). A release attachment uploaded
|
|
||||||
through the API as `docker-compose.yml` is refused the same way. Examples:
|
|
||||||
|
|
||||||
- a name on those lists, such as `package.json`, `.gitignore` or
|
`make gitea-requests` runs gitea behind `rproxy` and `servewaf` at the defaults,
|
||||||
`docker-compose.yml`;
|
without these exclusions. It reports each request the Core Rule Set refuses
|
||||||
- text that starts with a command name, such as `python3` or `ssh key`, or has
|
there.
|
||||||
one right after a `/` in its first word, such as `feat/docker-support`;
|
|
||||||
- a shell command with its options or a system path (`ls -la`, `sed -i`,
|
|
||||||
`/bin/sh`), or a command in backticks;
|
|
||||||
- script code (`fetch(`, `${VAR}`, `process.env`), HTML (`<img src=`) or an SQL
|
|
||||||
statement (`SELECT * FROM users WHERE`);
|
|
||||||
- a URL naming an IP address or `localhost`.
|
|
||||||
|
|
||||||
### A branch, tag or file name that starts with a command name
|
|
||||||
|
|
||||||
The Core Rule Set's list of commands (932260) holds names such as `docker-`,
|
|
||||||
`python3`, `ansible`, `base64` and `whoami`. A query parameter holding a branch,
|
|
||||||
tag or file name that starts with one is refused. So is one that has such a name
|
|
||||||
right after a `/`, such as `feat/docker-support`, `fix/docker-build` or
|
|
||||||
`renovate/docker-build-push-action-6.x`. gitea's own pages send the name in a
|
|
||||||
query parameter that 930120, 932160 and 932260 still check:
|
|
||||||
|
|
||||||
- `refSubUrl`, the branch or tag of a directory listing. A listing sends it when
|
|
||||||
it asks for its entries' last commits in a second request. It does so whenever
|
|
||||||
gitea takes more than a second to work them out;
|
|
||||||
- `name`, when a branch is deleted or restored on the branches page;
|
|
||||||
- `tag`, when a release is started from a tag;
|
|
||||||
- `template`, the file of an issue template, when an issue is opened from it;
|
|
||||||
- `rule_name`, when a branch protection rule is opened for editing in the
|
|
||||||
repository's settings or has just been saved.
|
|
||||||
|
|
||||||
What a visitor sees, for a branch named `docker-build`:
|
|
||||||
|
|
||||||
- A directory listing that makes that second request leaves those last commits
|
|
||||||
out and shows an error.
|
|
||||||
- The branches page shows an error instead of deleting or restoring the branch.
|
|
||||||
- A release started from such a tag gets the `403` answer of `smallwebwaf` in
|
|
||||||
place of its form. So does an issue opened from such a template, and such a
|
|
||||||
rule opened for editing.
|
|
||||||
- Saving such a rule stores it. gitea then returns to the branch settings page,
|
|
||||||
which gets the `403` answer, so the save looks as if it failed.
|
|
||||||
|
|
||||||
### A file name in a query parameter that trips the code rules
|
|
||||||
|
|
||||||
The Core Rule Set's rules for Java, script, PowerShell and SQL find what they
|
|
||||||
look for in some file names. This holds too in the ten query parameters that
|
|
||||||
930120, 932160 and 932260 do not check (see the fourth change under "Attack
|
|
||||||
detection" in `SPEC.md`). Examples:
|
|
||||||
|
|
||||||
- `runtime` in OpenJDK's `src/java.base/share/classes/java/lang/Runtime.java`
|
|
||||||
(944110);
|
|
||||||
- a Java class, as in `java.lang.String.md` (944130);
|
|
||||||
- `document.write.js` (941180);
|
|
||||||
- `process.env.d.ts` (934100);
|
|
||||||
- `__proto__` (934130);
|
|
||||||
- the PowerShell command `Install-Script`, as in `install-script.sh` (932120);
|
|
||||||
- a database's name, as in PostgreSQL's `information_schema.sql` (942140).
|
|
||||||
|
|
||||||
For such a file, these requests are refused:
|
|
||||||
|
|
||||||
- the API's list of its commits (`path`);
|
|
||||||
- the API's call for the contents of files that names it in its query (`body`);
|
|
||||||
- the commit graph of that file alone (`file`);
|
|
||||||
- the hidden lines of its diff (`path`), which stay hidden;
|
|
||||||
- the sign-in page, for a visitor who opens a page of the file that needs
|
|
||||||
signing in, such as its edit page (`redirect_to`). The visitor can still sign
|
|
||||||
in by opening the sign-in page itself.
|
|
||||||
|
|
||||||
### A path
|
|
||||||
|
|
||||||
- A file name ending in `~`, or an `.xhtml` file whose path holds a space.
|
|
||||||
- A path that holds what the Core Rule Set takes for a Java class (944130), as
|
|
||||||
`java.lang.String.md` and
|
|
||||||
`META-INF/services/org.apache.commons.logging.LogFactory` do.
|
|
||||||
- A path that holds what it takes for Node.js code (934100), as
|
|
||||||
`process.env.d.ts` does.
|
|
||||||
- A path with a directory named `...` (930100, 930110), such as `.../notes.txt`.
|
|
||||||
|
|
||||||
For such a file, these are refused:
|
|
||||||
|
|
||||||
- the page of the file;
|
|
||||||
- its raw file;
|
|
||||||
- its history and its blame;
|
|
||||||
- its edit page.
|
|
||||||
|
|
||||||
The directory that holds it lists it, and a clone carries it.
|
|
||||||
|
|
||||||
### A container image push that reuses a layer
|
|
||||||
|
|
||||||
A push can reuse a layer of an image docker has pushed to that gitea before.
|
|
||||||
docker then asks gitea to take the layer from that image, naming it in the query
|
|
||||||
parameter `from`, which 932260 checks. The request is refused when the image's
|
|
||||||
owner starts with an entry on the list of commands. It is refused too when the
|
|
||||||
image's name after the owner starts with one. Examples are `owner/python3-app`,
|
|
||||||
`owner/docker-base`, and any image of an owner named `ansible` or `docker-team`.
|
|
||||||
|
|
||||||
What happens next depends on docker's image store. A docker that keeps its
|
|
||||||
images in containerd's image store fails the push, and fails each new try the
|
|
||||||
same way. A new install of Docker Engine 29 does this. A docker that keeps them
|
|
||||||
in its own older image store uploads the layer instead. The push then succeeds
|
|
||||||
after one refusal for each layer it reuses, and each of those refusals counts
|
|
||||||
toward the error burst ("Attack detection" in `SPEC.md`).
|
|
||||||
|
|
||||||
### Artifact uploads from `actions/upload-artifact@v3`
|
|
||||||
|
|
||||||
This action sends the header `Content-Range`, which the Core Rule Set refuses
|
|
||||||
(920450). The action sends two files at a time, does not retry a `403`, and
|
|
||||||
sends no further file once one is refused. So an upload is refused once or
|
|
||||||
twice, however many files it holds, and the step fails. One upload does not ban
|
|
||||||
the runner. The error burst ("Attack detection" in `SPEC.md`) bans it after more
|
|
||||||
than 30 such refusals from its address in a minute, as when many jobs upload at
|
|
||||||
once. `actions/upload-artifact@v4` does not send the header.
|
|
||||||
|
|
||||||
### What body inspection refuses
|
|
||||||
|
|
||||||
With `SWWAF_WAF_BODY_LIMIT` set, the Core Rule Set reads request bodies too. In
|
|
||||||
front of gitea it then refuses these, until their rule ids, named in the request
|
|
||||||
log, are added to `SWWAF_WAF_DISABLED_RULES`:
|
|
||||||
|
|
||||||
- issue and comment text, wiki pages and files saved in the web editor that hold
|
|
||||||
shell commands or code (932125, 932235, 932250 and others);
|
|
||||||
- package descriptions that show code;
|
|
||||||
- PyPI uploads (922130);
|
|
||||||
- attachments named like `debug.log` or `config.yml` (932180).
|
|
||||||
|
|
||||||
### What `script/gitea-requests` sends
|
|
||||||
|
|
||||||
Of the requests above, the script sends:
|
|
||||||
|
|
||||||
- for each file named under "A file name in a query parameter that trips the
|
|
||||||
code rules", the API's list of its commits;
|
|
||||||
- for `Runtime.java` alone, the commit graph of that file, the hidden lines of
|
|
||||||
its diff, and the API's call for the contents of files that names it;
|
|
||||||
- the sign-in page for a visitor who opens the edit page of `document.write.js`;
|
|
||||||
- the pages of `java.lang.String.md`,
|
|
||||||
`META-INF/services/org.apache.commons.logging.LogFactory`, `process.env.d.ts`
|
|
||||||
and `.../notes.txt`;
|
|
||||||
- one container image push that reuses a layer, which it does not try again.
|
|
||||||
|
|
||||||
It does not send the raw file, history, blame or edit page of those four files,
|
|
||||||
or the pages of the directories `META-INF/services` and `...`. It also sends a
|
|
||||||
short sample of gitea's ordinary use:
|
|
||||||
|
|
||||||
- signing in;
|
|
||||||
- pages of files and commits, and a diff;
|
|
||||||
- the API;
|
|
||||||
- a push, and clone and fetch over HTTP;
|
|
||||||
- two container image pushes.
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
+337
@@ -0,0 +1,337 @@
|
|||||||
|
# Ordinary gitea requests that the Core Rule Set must let through with
|
||||||
|
# deploy/gitea/crs-exclusions.conf. 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,
|
||||||
|
# and script/gitea-setup sends each to gitea through the setup 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/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/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
|
||||||
|
/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/wiki/search?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||||
|
/owner/repo/wiki/search?q=%3Cimg%20src%3D
|
||||||
|
/owner/repo/branches?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||||
|
/owner/repo/branches?q=%3Cimg%20src%3D
|
||||||
|
/owner/repo/releases?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||||
|
/owner/repo/releases?q=%3Cimg%20src%3D
|
||||||
|
/owner/-/packages?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||||
|
/owner/-/packages?q=%3Cimg%20src%3D
|
||||||
|
/user/search?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||||
|
/user/search?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~
|
||||||
|
|
||||||
|
# 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 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?name=docker-build
|
||||||
|
/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?name=feat%2Fdocker-support
|
||||||
|
/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?name=renovate%2Fdocker-build-push-action-6.x
|
||||||
|
/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?name=python3-migration
|
||||||
|
/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?name=ansible-roles
|
||||||
|
/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?name=whoami
|
||||||
|
/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
|
||||||
+94
-13
@@ -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 seven 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,13 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,12 +30,13 @@ 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, and the exclusions file for the second.
|
||||||
// 900000 to 900999, the ids the Core Rule Set keeps for the rules that set
|
// Each rule smallwebwaf adds has an id from 900000 to 900999, the ids the
|
||||||
// it up, which SWWAF_WAF_DISABLED_RULES refuses, so that no setting
|
// Core Rule Set keeps for the rules that set it up, which
|
||||||
// switches one off. Coraza joins a line ending in \ to the next, without
|
// SWWAF_WAF_DISABLED_RULES and the exclusions file refuse, so that no
|
||||||
// the spaces at the start of the next.
|
// 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 +74,11 @@ 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 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
|
||||||
@@ -100,6 +110,13 @@ SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|||||||
# Inspect.
|
# Inspect.
|
||||||
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
||||||
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 944110 "!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
|
||||||
@@ -160,6 +177,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 +231,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 seven 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)
|
||||||
|
|
||||||
if len(params.DisabledRules) > 0 {
|
if len(params.DisabledRules) > 0 {
|
||||||
ids := make([]string, len(params.DisabledRules))
|
ids := make([]string, len(params.DisabledRules))
|
||||||
|
|||||||
+163
-1
@@ -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 seven 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,
|
||||||
@@ -362,6 +363,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,
|
||||||
|
// which script/gitea-setup sends too. 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 TestGiteaExclusionsLetOrdinaryGiteaRequestsThroughAndRefuseAttacks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs, err := withExclusions(filepath.Join("..", "..", "deploy", "gitea",
|
||||||
|
"crs-exclusions.conf"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load gitea's exclusions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold = 5 // SWWAF_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, attack := range []string{
|
||||||
|
"/owner/repo?id=1'%20OR%20'1'='1",
|
||||||
|
"/owner/repo?id=..%2Fcustom%2Fconf%2Fapp.ini",
|
||||||
|
"/owner/repo?id=%3Cscript%3Ealert(1)%3C%2Fscript%3E",
|
||||||
|
} {
|
||||||
|
if got := inspect(t, crs, get(attack)); got.Score < threshold {
|
||||||
|
t.Errorf("%s let through: %+v", attack, 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
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
# default but these: SWRP_UPSTREAM_URL names gitea's container, SWRP_WAF_BACKEND
|
# default but these: SWRP_UPSTREAM_URL names gitea's container, SWRP_WAF_BACKEND
|
||||||
# names servewaf's, and SWWAF_LOG_REQUEST_HEADERS adds the header that labels a
|
# names servewaf's, and SWWAF_LOG_REQUEST_HEADERS adds the header that labels a
|
||||||
# request. Then send through rproxy the gitea requests that reading gitea's
|
# request. Then send through rproxy the gitea requests that reading gitea's
|
||||||
# source found the Core Rule Set refuses, which docs/gitea.md records, and a
|
# source found the Core Rule Set refuses without the exclusions in
|
||||||
|
# deploy/gitea/crs-exclusions.conf, and a
|
||||||
# short sample of gitea's ordinary use: signing in, pages of files and commits,
|
# short sample of gitea's ordinary use: signing in, pages of files and commits,
|
||||||
# a diff, the API, a push, clone and fetch over HTTP, and two container image
|
# a diff, the API, a push, clone and fetch over HTTP, and two container image
|
||||||
# pushes. Last, print each request servewaf logged, with what it did with it and
|
# pushes. Last, print each request servewaf logged, with what it did with it and
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
# rproxy, and that servewaf checks them;
|
# rproxy, and that servewaf checks them;
|
||||||
# - that gitea logs the visitor's address, not rproxy's;
|
# - that gitea logs the visitor's address, not rproxy's;
|
||||||
# - that a request from a container on the shared network skips the checks;
|
# - that a request from a container on the shared network skips the checks;
|
||||||
|
# - that the Core Rule Set refuses none of the ordinary gitea requests in
|
||||||
|
# internal/waf/testdata/gitea-requests.txt;
|
||||||
|
# - that it refuses none of the API calls with JSON bodies;
|
||||||
|
# - that it refuses no fetch;
|
||||||
|
# - that it refuses SQL injection, path traversal and script injection in a
|
||||||
|
# query string;
|
||||||
# - that with servewaf stopped, gitea still answers through rproxy.
|
# - that with servewaf stopped, gitea still answers through rproxy.
|
||||||
# It publishes no port of traefik's, since this host's ports 80 and 443 may be
|
# It publishes no port of traefik's, since this host's ports 80 and 443 may be
|
||||||
# taken. It reaches traefik at its address on the shared network instead, so
|
# taken. It reaches traefik at its address on the shared network instead, so
|
||||||
@@ -136,6 +142,71 @@ passed_through() {
|
|||||||
"\"client_ip\":\"$gateway\"" '"action":"forward"' '"counts":' '"waf_score":'
|
"\"client_ip\":\"$gateway\"" '"action":"forward"' '"counts":' '"waf_score":'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# refused: print each request servewaf logged as refused, for any reason.
|
||||||
|
refused() {
|
||||||
|
compose logs --no-color --no-log-prefix servewaf 2>&1 |
|
||||||
|
grep -F '"servewaf_line":"report"' | grep -v -F '"action":"forward"' || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# ordinary_requests: sign in as the owner, save a file and open an issue
|
||||||
|
# through the API with JSON bodies, then send, signed in, each request in
|
||||||
|
# internal/waf/testdata/gitea-requests.txt, with the header its line ends with,
|
||||||
|
# if any, and that request's own address as its Referer, as a page opened from it
|
||||||
|
# has. One a second keeps them under the default limit of 120 requests a minute,
|
||||||
|
# since a request refused for a limit is never inspected. Fail if servewaf
|
||||||
|
# refuses any of them.
|
||||||
|
ordinary_requests() {
|
||||||
|
send /user/login --cookie-jar "$WORK/cookies" >/dev/null
|
||||||
|
status="$(send /user/login --cookie "$WORK/cookies" --cookie-jar "$WORK/cookies" \
|
||||||
|
--header "Origin: https://$SITE" --data-urlencode user_name=owner \
|
||||||
|
--data-urlencode "password=$PASSWORD")"
|
||||||
|
[ "$status" = 303 ] || fail "signing in was answered $status"
|
||||||
|
|
||||||
|
content="$(printf 'declare const env: Record<string, string>;\n' | base64)"
|
||||||
|
status="$(send /api/v1/repos/owner/repo/contents/process.env.d.ts \
|
||||||
|
--user "owner:$PASSWORD" --header "Content-Type: application/json" \
|
||||||
|
--data "{\"message\":\"Add process.env.d.ts\",\"content\":\"$content\"}")"
|
||||||
|
[ "$status" = 201 ] || fail "saving a file through the API was answered $status"
|
||||||
|
status="$(send /api/v1/repos/owner/repo/issues --user "owner:$PASSWORD" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data '{"title":"ls -la fails","body":"SELECT * FROM users; <img src=x>"}')"
|
||||||
|
[ "$status" = 201 ] || fail "opening an issue through the API was answered $status"
|
||||||
|
|
||||||
|
grep -v -e '^#' -e '^$' "$ROOT/internal/waf/testdata/gitea-requests.txt" |
|
||||||
|
while read -r target header; do
|
||||||
|
if [ -n "$header" ]; then
|
||||||
|
send "$target" --globoff --cookie "$WORK/cookies" \
|
||||||
|
--header "Referer: https://$SITE$target" --header "$header" >/dev/null
|
||||||
|
else
|
||||||
|
send "$target" --globoff --cookie "$WORK/cookies" \
|
||||||
|
--header "Referer: https://$SITE$target" >/dev/null
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# The last request's report is logged once its answer has ended.
|
||||||
|
send /api/v1/settings/api >/dev/null
|
||||||
|
wait_for "servewaf logged no report of the last ordinary request" logged servewaf \
|
||||||
|
'"servewaf_line":"report"' '"path":"/api/v1/settings/api"'
|
||||||
|
refusals="$(refused)"
|
||||||
|
[ -z "$refusals" ] || fail "servewaf refused ordinary requests:
|
||||||
|
$refusals"
|
||||||
|
}
|
||||||
|
|
||||||
|
# attacks: send SQL injection, path traversal and script injection in a query
|
||||||
|
# string, and fail unless the Core Rule Set refuses each.
|
||||||
|
attacks() {
|
||||||
|
# The traversal climbs one directory: the default rule file refuses two or
|
||||||
|
# more first, before the Core Rule Set runs.
|
||||||
|
for query in "id=1'%20OR%20'1'%3D'1" "id=..%2Fcustom%2Fconf%2Fapp.ini" \
|
||||||
|
"id=%3Cscript%3Ealert(1)%3C%2Fscript%3E"; do
|
||||||
|
status="$(send "/owner/repo?$query")"
|
||||||
|
[ "$status" = 403 ] || fail "the attack ?$query was answered $status"
|
||||||
|
wait_for "servewaf logged no refusal of ?$query" logged servewaf \
|
||||||
|
'"servewaf_line":"report"' '"action":"waf_blocked"' "\"query\":\"$query\""
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
@@ -204,6 +275,12 @@ main() {
|
|||||||
esac
|
esac
|
||||||
echo "gitea-setup: a request from the shared network skips the checks"
|
echo "gitea-setup: a request from the shared network skips the checks"
|
||||||
|
|
||||||
|
site_git -C "$WORK/clone" fetch --quiet || fail "the fetch failed"
|
||||||
|
ordinary_requests
|
||||||
|
echo "gitea-setup: the Core Rule Set refuses no ordinary gitea request"
|
||||||
|
attacks
|
||||||
|
echo "gitea-setup: the Core Rule Set refuses the attacks"
|
||||||
|
|
||||||
compose stop servewaf >/dev/null 2>&1 || fail "servewaf did not stop"
|
compose stop servewaf >/dev/null 2>&1 || fail "servewaf did not stop"
|
||||||
status="$(send /owner/repo)"
|
status="$(send /owner/repo)"
|
||||||
[ "$status" = 200 ] ||
|
[ "$status" = 200 ] ||
|
||||||
|
|||||||
Reference in New Issue
Block a user