Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f1e97cb4e | ||
|
|
2349b2a081 | ||
|
|
42e06bf298 |
No files matched your search
+11
-2
@@ -136,10 +136,19 @@ RUN mkdir /etc/sysusers.d \
|
|||||||
# ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
|
# ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
|
||||||
# them over plain HTTP and checks each one against the archive's signed
|
# them over plain HTTP and checks each one against the archive's signed
|
||||||
# package lists, so no CA certificates are needed.
|
# package lists, so no CA certificates are needed.
|
||||||
RUN apt-get update \
|
#
|
||||||
|
# nix-bin depends on xz-utils, whose install links man pages with
|
||||||
|
# update-alternatives. The image's dpkg config drops man pages, so each
|
||||||
|
# link warns. z-man, read after /etc/dpkg/dpkg.cfg.d/excludes, re-includes
|
||||||
|
# the man1 pages for this install alone; the same step deletes them again,
|
||||||
|
# so no man page or package is added.
|
||||||
|
RUN echo 'path-include=/usr/share/man/man1/*' > /etc/dpkg/dpkg.cfg.d/z-man \
|
||||||
|
&& apt-get update \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
||||||
ca-certificates nix-bin runit \
|
ca-certificates nix-bin runit \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/* /etc/dpkg/dpkg.cfg.d/z-man \
|
||||||
|
&& find /usr/share/man/man1 ! -type d -delete \
|
||||||
|
&& find /etc/alternatives -type l -xtype l -delete
|
||||||
|
|
||||||
# Nix run by root expects a group of build users, which nix-bin does not
|
# Nix run by root expects a group of build users, which nix-bin does not
|
||||||
# create; with the setting empty, root's builds run without them.
|
# create; with the setting empty, root's builds run without them.
|
||||||
|
|||||||
@@ -256,14 +256,14 @@ Every setting the code reads, with its default:
|
|||||||
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
||||||
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
||||||
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
||||||
| `SWW_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
|
|
||||||
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
||||||
| `SWW_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
|
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
|
||||||
| `SWW_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
|
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=cn`. |
|
||||||
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWW_ASN_LIMIT_PERCENT`. |
|
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
|
||||||
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWW_COUNTRY_LIMIT_PERCENT`. |
|
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
|
||||||
|
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
||||||
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
||||||
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
|
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). |
|
||||||
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
||||||
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
||||||
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
||||||
@@ -413,8 +413,7 @@ services:
|
|||||||
SWW_RATE_LIMIT_PER_HOUR: "600"
|
SWW_RATE_LIMIT_PER_HOUR: "600"
|
||||||
SWW_RATE_LIMIT_PER_DAY: "3000"
|
SWW_RATE_LIMIT_PER_DAY: "3000"
|
||||||
# Hosting networks and one country get a share of those limits.
|
# Hosting networks and one country get a share of those limits.
|
||||||
SWW_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
|
SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=cn:25
|
||||||
SWW_COUNTRY_LIMIT_PERCENT: cn:25
|
|
||||||
# Paths only scanners ask for; one request bans the client.
|
# Paths only scanners ask for; one request bans the client.
|
||||||
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
||||||
# Answer a refused client with 429 rather than 403.
|
# Answer a refused client with 429 rather than 403.
|
||||||
@@ -509,28 +508,31 @@ of the request reaches the app.
|
|||||||
3. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
3. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
||||||
4. The bans.
|
4. The bans.
|
||||||
5. The lookup of the client's AS number and country.
|
5. The lookup of the client's AS number and country.
|
||||||
6. The country lists.
|
6. The tag lists.
|
||||||
7. The reputation sources.
|
7. The country list.
|
||||||
8. The client's limit percentage, from its AS number, its country or a
|
8. The reputation sources.
|
||||||
reputation hit.
|
9. The tag refusals of `SWW_TAG_DENY`.
|
||||||
9. The rate limits.
|
10. The client's limit percentage, from its tags, its AS number, its country or
|
||||||
10. The trap paths.
|
a reputation hit.
|
||||||
11. The rule files.
|
11. The rate limits.
|
||||||
12. The header limits, and then the Core Rule Set.
|
12. The trap paths.
|
||||||
13. A path under `/_sww/` is answered `404`, so that the app never sees that
|
13. The rule files.
|
||||||
|
14. The header limits, and then the Core Rule Set.
|
||||||
|
15. The tag refusals again, so that `exploitsignature` can refuse too.
|
||||||
|
16. A path under `/_sww/` is answered `404`, so that the app never sees that
|
||||||
prefix.
|
prefix.
|
||||||
|
|
||||||
`rproxy` takes the rest, with `discriminator` counting at the end:
|
`rproxy` takes the rest, with `discriminator` counting at the end:
|
||||||
|
|
||||||
14. The request is passed to the app, streaming.
|
17. The request is passed to the app, streaming.
|
||||||
15. After the answer, `rproxy` sends `discriminator` a report with the status
|
18. After the answer, `rproxy` sends `discriminator` a report with the status
|
||||||
and the bytes each way. For an allowed request, `discriminator` counts the
|
and the bytes each way. For an allowed request, `discriminator` counts the
|
||||||
bytes toward the byte limits and adds the request to the client's history.
|
bytes toward the byte limits and adds the request to the client's history.
|
||||||
|
|
||||||
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
||||||
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
||||||
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
|
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
|
||||||
request over the header limits of step 12 is answered `431`, and bans nobody
|
request over the header limits of step 14 is answered `431`, and bans nobody
|
||||||
either. `rproxy` itself answers `405` to a method it does not pass on, and the
|
either. `rproxy` itself answers `405` to a method it does not pass on, and the
|
||||||
status for a size or time limit, such as `413` for a body too large.
|
status for a size or time limit, such as `413` for a body too large.
|
||||||
|
|
||||||
@@ -549,6 +551,21 @@ status for a size or time limit, such as `413` for a body too large.
|
|||||||
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
|
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
|
||||||
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
|
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
|
||||||
|
|
||||||
|
### Request tags
|
||||||
|
|
||||||
|
Each request gets tags from what `discriminator` knows of its client, written in
|
||||||
|
the `tags` field of its log line:
|
||||||
|
|
||||||
|
- `asn=<number>` and `country=<code>`, from the lookup;
|
||||||
|
- one tag per list of `SWW_TAG_LISTS` that holds the client, such as `torexit`
|
||||||
|
or `vpn=mullvad`;
|
||||||
|
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
|
||||||
|
the client;
|
||||||
|
- `exploitsignature`, when a rule file or the Core Rule Set matched the request.
|
||||||
|
|
||||||
|
`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a
|
||||||
|
client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
||||||
|
|
||||||
### Attack detection
|
### Attack detection
|
||||||
|
|
||||||
- The Core Rule Set reads the method, the URL with its query string, and the
|
- The Core Rule Set reads the method, the URL with its query string, and the
|
||||||
|
|||||||
@@ -119,12 +119,14 @@ to them.
|
|||||||
|
|
||||||
## What happens to a request
|
## What happens to a request
|
||||||
|
|
||||||
`rproxy` runs steps 1, 2 and 14, and sends the report of step 15.
|
`rproxy` runs steps 1, 2 and 17, and sends the report of step 18.
|
||||||
`discriminator` runs steps 3 to 13, and does the counting of step 15. The first
|
`discriminator` runs steps 3 to 16, and does the counting of step 18. The first
|
||||||
step that produces a final answer ends the checks. Step 15 runs for each request
|
step that produces a final answer ends the checks. Step 18 runs for each request
|
||||||
`discriminator` answers with `allow`, `deny` or `close`. `rproxy`'s size and
|
`discriminator` answers with `allow`, `deny` or `close`. `rproxy`'s size and
|
||||||
time limits apply to the whole exchange. Each step names the section that holds
|
time limits apply to the whole exchange. Each step names the section that holds
|
||||||
its rules.
|
its rules. A step that names a heuristic in parentheses runs that heuristic. The
|
||||||
|
request log's `heuristic` field gives that name. `refusal_limit` and
|
||||||
|
`byte_limits` give theirs in `after_answer_heuristic` instead.
|
||||||
|
|
||||||
1. `GET /_sww/healthz` is answered at once by `rproxy`, before any check, so a
|
1. `GET /_sww/healthz` is answered at once by `rproxy`, before any check, so a
|
||||||
health checker never needs an exemption and is never refused.
|
health checker never needs an exemption and is never refused.
|
||||||
@@ -141,55 +143,67 @@ its rules.
|
|||||||
- An IPv6 client is its group: the netblock of `SWW_IPV6_GROUP_PREFIX`
|
- An IPv6 client is its group: the netblock of `SWW_IPV6_GROUP_PREFIX`
|
||||||
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 `SWW_ALLOW_NETS` skips steps 4 to 12, the byte
|
3. The static lists. A client in `SWW_ALLOW_NETS` skips steps 4 to 15, the byte
|
||||||
limits and the error burst. A client in `SWW_DENY_NETS` is refused; a client
|
limits and the error burst. A client in `SWW_DENY_NETS` is refused
|
||||||
in both lists is let through.
|
(`deny_nets`); a client in both lists is let through.
|
||||||
4. Bans. A client under a ban is refused (see "Bans").
|
4. Bans. A client under a ban is refused (`active_ban`; see "Bans").
|
||||||
5. The lookup, unless `SWW_LOOKUP_SOURCE` is `off` (see "Lookup of AS number and
|
5. The lookup, unless `SWW_LOOKUP_SOURCE` is `off` (see "Lookup of AS number and
|
||||||
country"). The request goes on at once. It waits for the first GeoJS answer,
|
country"). The request goes on at once. It waits for the first GeoJS answer,
|
||||||
up to `SWW_LOOKUP_TIMEOUT`, only when one of these needs it:
|
up to `SWW_LOOKUP_TIMEOUT`, only when one of these needs it:
|
||||||
- a country list;
|
- the country list;
|
||||||
- a biased threshold;
|
- a biased threshold;
|
||||||
|
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming an `asn=` or `country=`
|
||||||
|
tag;
|
||||||
- `SWW_ADD_LOOKUP_HEADERS`.
|
- `SWW_ADD_LOOKUP_HEADERS`.
|
||||||
|
|
||||||
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 (see "Country lists").
|
6. The tag lists (`tag_lists`; see "Request tags"). Each list that holds the
|
||||||
7. The reputation sources (see "Reputation"). A client on a fetched blocklist
|
client adds its tag.
|
||||||
gets `SWW_BLOCKLIST_ACTION`. A client on the CrowdSec decision list is
|
7. The country list (`countries`; see "Country list").
|
||||||
banned. A client listed by a DNS blocklist, or scored at least
|
8. The reputation sources (see "Reputation"), in this order:
|
||||||
`SWW_ABUSEIPDB_MIN_SCORE` by AbuseIPDB, gets `SWW_REPUTATION_ACTION`.
|
- A client on a fetched blocklist gets `SWW_BLOCKLIST_ACTION`
|
||||||
8. The client's limit percentage (see "Biased thresholds").
|
(`blocklists`).
|
||||||
9. Rate limiting (see "Request rate limits, per client"). A request over a limit
|
- A client on the CrowdSec decision list is banned (`crowdsec`).
|
||||||
times the percentage is refused and bans the client.
|
- A client listed by a DNS blocklist gets `SWW_REPUTATION_ACTION` (`dnsbl`).
|
||||||
10. The trap paths (see "Attack detection"). A request for one is refused and
|
- A client scored at least `SWW_ABUSEIPDB_MIN_SCORE` by AbuseIPDB gets
|
||||||
bans the client.
|
`SWW_REPUTATION_ACTION` (`abuseipdb`).
|
||||||
11. The rule files (see "Rule files"). Matching stops at the first rule that
|
9. The tag refusals (`tag_deny`; see "Request tags"). A request carrying a tag
|
||||||
refuses.
|
in `SWW_TAG_DENY` is refused.
|
||||||
12. The header limits (see "Answering in time"), and then the Core Rule Set (see
|
10. The client's limit percentage (see "Biased thresholds").
|
||||||
"Attack detection"). A Core Rule Set match is refused with `403` while
|
11. Rate limiting (`rate_limits`; see "Request rate limits, per client"). A
|
||||||
`SWW_WAF_MODE` is `block`, and only logged and alerted while it is `detect`.
|
request over a limit times the percentage is refused and bans the client.
|
||||||
13. A path under `/_sww/` is answered `404`, so that the app never sees that
|
12. The trap paths (`trap_paths`; see "Attack detection"). A request for one is
|
||||||
|
refused and bans the client.
|
||||||
|
13. The rule files (`rule_files`; see "Rule files"). Matching stops at the first
|
||||||
|
rule that refuses.
|
||||||
|
14. The header limits (`header_limits`; see "Answering in time"), and then the
|
||||||
|
Core Rule Set (`core_rule_set`; see "Attack detection"). A Core Rule Set
|
||||||
|
match is refused with `403` while `SWW_WAF_MODE` is `block`, and only logged
|
||||||
|
and alerted while it is `detect`.
|
||||||
|
15. The tag refusals again (`tag_deny`), so that `exploitsignature` can refuse
|
||||||
|
too.
|
||||||
|
16. A path under `/_sww/` is answered `404`, so that the app never sees that
|
||||||
prefix.
|
prefix.
|
||||||
14. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing
|
17. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing
|
||||||
a request to the app"). With `SWW_ADD_LOOKUP_HEADERS`, `X-Client-ASN` and
|
a request to the app"). With `SWW_ADD_LOOKUP_HEADERS`, `X-Client-ASN` and
|
||||||
`X-Client-Country` are added for the app's own logs; a header whose value is
|
`X-Client-Country` are added for the app's own logs; a header whose value is
|
||||||
unknown is left out. Whatever that setting says, any such header the client
|
unknown is left out. Whatever that setting says, any such header the client
|
||||||
sent is removed.
|
sent is removed.
|
||||||
15. After the visitor's answer has ended, `rproxy` sends the report (see "The
|
18. After the visitor's answer has ended, `rproxy` sends the report (see "The
|
||||||
report"). `discriminator` then counts:
|
report"). `discriminator` then counts:
|
||||||
- For a request it allowed, the response bytes, the request bytes or both
|
- For a request it allowed, the response bytes, the request bytes or both
|
||||||
are added to the byte counters, as `SWW_BYTES_COUNT` says. For a
|
are added to the byte counters, as `SWW_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. The response in progress is not cut off.
|
is banned (`byte_limits`). 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, `discriminator` adds nothing to the byte limits.
|
For a request it refused, `discriminator` adds nothing to the byte limits.
|
||||||
It updates the history and the anomaly counters at its decision. It counts
|
It updates the history and the anomaly counters at its decision. It counts
|
||||||
the refusals the error burst counts at the decision too (see "Attack
|
the refusals the error burst counts at the decision too (`refusal_limit`;
|
||||||
detection").
|
see "Attack detection").
|
||||||
|
|
||||||
In `SWW_MODE=observe`, every refusal above is logged as what would have
|
In `SWW_MODE=observe`, every refusal above is logged as what would have
|
||||||
happened, nothing is refused, and `discriminator` makes no ban of its own. A
|
happened, nothing is refused, and `discriminator` makes no ban of its own. A
|
||||||
@@ -279,7 +293,8 @@ no offence:
|
|||||||
|
|
||||||
- `SWW_DENY_NETS`;
|
- `SWW_DENY_NETS`;
|
||||||
- a blocklist or reputation hit whose action is `deny`;
|
- a blocklist or reputation hit whose action is `deny`;
|
||||||
- the country lists.
|
- the country list;
|
||||||
|
- `SWW_TAG_DENY`.
|
||||||
|
|
||||||
Every ban carries notes with what an admin needs to decide whether to lift it,
|
Every ban carries notes with what an admin needs to decide whether to lift it,
|
||||||
drawn from what `discriminator` already knows. No lookup is made for the notes
|
drawn from what `discriminator` already knows. No lookup is made for the notes
|
||||||
@@ -436,7 +451,7 @@ the same way.
|
|||||||
By default there is no minute limit, so a client can download in a burst at full
|
By default there is no minute limit, so a client can download in a burst at full
|
||||||
speed. The hour limit defaults to 50 GiB and the day limit to 100 GiB. They
|
speed. The hour limit defaults to 50 GiB and the day limit to 100 GiB. They
|
||||||
allow several downloads of a few GiB each. A response's bytes are counted when
|
allow several downloads of a few GiB each. A response's bytes are counted when
|
||||||
it ends (see step 15).
|
it ends (see step 18).
|
||||||
|
|
||||||
### Lookup of AS number and country
|
### Lookup of AS number and country
|
||||||
|
|
||||||
@@ -470,7 +485,7 @@ service that keeps its visitors' addresses on its own host.
|
|||||||
until it comes, and its later requests do not wait. Its address is asked about
|
until it comes, and its later requests do not wait. Its address is asked about
|
||||||
again in the background.
|
again in the background.
|
||||||
- A client the lookup cannot place has an unknown country and AS number, which
|
- A client the lookup cannot place has an unknown country and AS number, which
|
||||||
the country lists and the biased thresholds treat as described there. Nothing
|
the country list and the biased thresholds treat as described there. Nothing
|
||||||
else treats it differently.
|
else treats it differently.
|
||||||
- GeoJS publishes no rate limit, but its terms forbid "an excessive amount of
|
- GeoJS publishes no rate limit, but its terms forbid "an excessive amount of
|
||||||
API requests", judged by GeoJS alone, and let it block a caller. While GeoJS
|
API requests", judged by GeoJS alone, and let it block a caller. While GeoJS
|
||||||
@@ -490,12 +505,12 @@ service that keeps its visitors' addresses on its own host.
|
|||||||
`file_error` alert and ignored. The database already loaded stays in use. A
|
`file_error` alert and ignored. The database already loaded stays in use. A
|
||||||
client whose address the file does not hold counts as unknown.
|
client whose address the file does not hold counts as unknown.
|
||||||
|
|
||||||
### Country lists
|
### Country list
|
||||||
|
|
||||||
Both are empty by default: the defaults judge a client by what it does, not by
|
`SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` is empty by default: the defaults judge a
|
||||||
where it comes from.
|
client by what it does, not by where it comes from. A `country=` tag in
|
||||||
|
`SWW_TAG_DENY` refuses one country (see "Request tags").
|
||||||
|
|
||||||
- `SWW_DENIED_COUNTRIES` refuses every request from a listed country.
|
|
||||||
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` lets through only clients placed in a
|
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` lets through only clients placed in a
|
||||||
listed country. Every request from any other country is refused. So is every
|
listed country. Every request from any other country is refused. So is every
|
||||||
request from a client the lookup cannot place, such as internal monitoring or
|
request from a client the lookup cannot place, such as internal monitoring or
|
||||||
@@ -504,34 +519,70 @@ where it comes from.
|
|||||||
client in whenever GeoJS stops answering. The cost: while GeoJS fails, this
|
client in whenever GeoJS stops answering. The cost: while GeoJS fails, this
|
||||||
list refuses every new client. No other failure of GeoJS, a reputation source
|
list refuses every new client. No other failure of GeoJS, a reputation source
|
||||||
or an alert destination comes this close to taking the service down.
|
or an alert destination comes this close to taking the service down.
|
||||||
- Both may be set. `SWW_DENIED_COUNTRIES` then adds nothing, since the exclusive
|
- A code in this list whose `country=` tag is in `SWW_TAG_DENY` stops the start
|
||||||
list already refuses every other country. A code on both lists stops the start
|
|
||||||
with a message naming it.
|
with a message naming it.
|
||||||
- A refused request is answered with `SWW_BAN_RESPONSE`, as soon as the client's
|
- A refused request is answered with `SWW_BAN_RESPONSE`, as soon as the client's
|
||||||
address has been looked up. It is logged with the action `country_denied`, and
|
address has been looked up. It is logged with the action `country_denied`, and
|
||||||
counted in the metrics.
|
counted in the metrics.
|
||||||
|
|
||||||
|
### Request tags
|
||||||
|
|
||||||
|
Each request gets tags from what `discriminator` knows of its client and what
|
||||||
|
the checks found. The operator refuses or slows clients by tag. Tags appear in
|
||||||
|
the log line as `tags`, sorted, with empty lists left out (see
|
||||||
|
`docs/request-log.md`).
|
||||||
|
|
||||||
|
Each tag and where it comes from:
|
||||||
|
|
||||||
|
- `asn=<number>` and `country=<code>`, in lower case, such as `asn=64496` and
|
||||||
|
`country=de`, from the lookup. A client the lookup cannot place has neither.
|
||||||
|
- One tag per list of `SWW_TAG_LISTS` that holds the client (`tag_lists`), the
|
||||||
|
tag being the list's name, such as `torexit` or `vpn=mullvad`. These lists are
|
||||||
|
fetched and kept like a blocklist. `discriminator` ships none of its own.
|
||||||
|
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
|
||||||
|
the client, whatever the source's action. A zone with a key is named with the
|
||||||
|
key masked, as the `reputation` field gives it.
|
||||||
|
- `exploitsignature`, when a rule file or the Core Rule Set matched the request.
|
||||||
|
For the Core Rule Set a match is a score at or over
|
||||||
|
`SWW_WAF_ANOMALY_THRESHOLD`.
|
||||||
|
|
||||||
|
A client in `SWW_ALLOW_NETS` skips the checks and gets no tag. `SWW_TAG_DENY`
|
||||||
|
and `SWW_TAG_LIMIT_PERCENT` are read case-insensitively.
|
||||||
|
|
||||||
|
Two settings act on tags:
|
||||||
|
|
||||||
|
- `SWW_TAG_DENY` lists tags whose requests are refused with the action `denied`
|
||||||
|
(`tag_deny`). The check runs twice: just before the rate limits, and again
|
||||||
|
after the Core Rule Set, so that `exploitsignature` can refuse too.
|
||||||
|
- `SWW_TAG_LIMIT_PERCENT` lists tags with a percentage from 1 to 99. Their
|
||||||
|
clients get that percentage of every request and byte limit. The lowest
|
||||||
|
percentage among a client's tags applies. `exploitsignature` cannot be listed,
|
||||||
|
since a request gets it only after the rate limits have counted it.
|
||||||
|
|
||||||
|
The tag settings are empty by default, for the same reason as the country list.
|
||||||
|
The lookup waits for GeoJS when either names an `asn=` or `country=` tag. A
|
||||||
|
setting that names one stops the start while `SWW_LOOKUP_SOURCE` is `off`.
|
||||||
|
|
||||||
### Biased thresholds
|
### Biased thresholds
|
||||||
|
|
||||||
The biased thresholds lower the limits of clients in listed AS numbers and
|
The biased thresholds lower the limits of clients by AS number, country and tag.
|
||||||
countries. The lists are empty by default, for the same reason as the country
|
The lists are empty by default, for the same reason as the country list. The
|
||||||
lists. The request log and the metrics show which AS numbers and countries a
|
request log and the metrics show which AS numbers and countries a service's
|
||||||
service's abuse comes from.
|
abuse comes from.
|
||||||
|
|
||||||
- Each list gives AS numbers or countries a percentage, such as `AS14061:50`.
|
- `SWW_TAG_LIMIT_PERCENT` gives a tag a percentage, such as `asn=14061:50` (see
|
||||||
Their clients get that percentage of every request and byte limit
|
"Request tags"). A lowered limit is rounded down.
|
||||||
(`SWW_ASN_LIMIT_PERCENT`, `SWW_COUNTRY_LIMIT_PERCENT`). A lowered limit is
|
- `SWW_UNKNOWN_LIMIT_PERCENT` at `0` is a zero allowance: the client's first
|
||||||
rounded down. `0` is a zero allowance: the client's first request breaks a
|
request breaks a limit and bans it.
|
||||||
limit and bans it.
|
|
||||||
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` set the byte limits'
|
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` set the byte limits'
|
||||||
percentage alone, when it should differ from the request limits'. For an AS
|
percentage alone, when it should differ from the request limits'. For an AS
|
||||||
number they list, they replace its percentage from `SWW_ASN_LIMIT_PERCENT` and
|
number they list, they replace its `asn=` tag's percentage and its entry in
|
||||||
`SWW_ASN_LIMIT_PERCENT_URL`; for a country, the one from
|
`SWW_ASN_LIMIT_PERCENT_URL`; for a country, its `country=` tag's percentage.
|
||||||
`SWW_COUNTRY_LIMIT_PERCENT`.
|
|
||||||
- `SWW_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so one
|
- `SWW_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so one
|
||||||
abuse-source list can be shared by every `discriminator` in the fleet. It is
|
abuse-source list can be shared by every `discriminator` in the fleet. It is
|
||||||
fetched and kept like a blocklist (see "Reputation"). Where it and
|
fetched and kept like a blocklist (see "Reputation"). Each entry counts as an
|
||||||
`SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies.
|
`asn=` tag in `SWW_TAG_LIMIT_PERCENT` does. Where both give a percentage, the
|
||||||
|
lower applies.
|
||||||
- A client's limit percentage is the lowest of those that apply to it: its AS
|
- A client's limit percentage is the lowest of those that apply to it: its AS
|
||||||
number's, its country's, or a reputation hit's `limit:<percent>`. A client
|
number's, its country's, or a reputation hit's `limit:<percent>`. A client
|
||||||
without a country gets `SWW_UNKNOWN_LIMIT_PERCENT` in place of a country's.
|
without a country gets `SWW_UNKNOWN_LIMIT_PERCENT` in place of a country's.
|
||||||
@@ -542,7 +593,7 @@ service's abuse comes from.
|
|||||||
- No budget is shared by a whole country or AS number. One abuser could use it
|
- No budget is shared by a whole country or AS number. One abuser could use it
|
||||||
up and lock out everyone else there: a denial of service nobody chose, and the
|
up and lock out everyone else there: a denial of service nobody chose, and the
|
||||||
one this tool exists to prevent. Refusing a whole country is left to the
|
one this tool exists to prevent. Refusing a whole country is left to the
|
||||||
operator's choice, through the country lists.
|
operator's choice, through `SWW_TAG_DENY` or the country list.
|
||||||
|
|
||||||
### Attack detection
|
### Attack detection
|
||||||
|
|
||||||
@@ -897,7 +948,7 @@ left unset sends no alert. An alert needs a destination only the operator can
|
|||||||
supply, and what counts as unusual depends on each service's normal traffic,
|
supply, and what counts as unusual depends on each service's normal traffic,
|
||||||
which the metrics show. They reveal a widely distributed scraper, thousands of
|
which the metrics show. They reveal a widely distributed scraper, thousands of
|
||||||
addresses at low rates each, which the per-client limits do not see. A low
|
addresses at low rates each, which the per-client limits do not see. A low
|
||||||
`SWW_ASN_LIMIT_PERCENT` for its AS number is the response.
|
percentage for its `asn=` tag in `SWW_TAG_LIMIT_PERCENT` is the response.
|
||||||
|
|
||||||
- Requests and bytes are counted per minute and per hour, for each of:
|
- Requests and bytes are counted per minute and per hour, for each of:
|
||||||
- a client;
|
- a client;
|
||||||
@@ -1175,7 +1226,7 @@ the metrics.
|
|||||||
|
|
||||||
### The answer
|
### The answer
|
||||||
|
|
||||||
`discriminator` runs steps 3 to 13 of "What happens to a request", in that
|
`discriminator` runs steps 3 to 16 of "What happens to a request", in that
|
||||||
order. Every answer but the last below carries `X-Sww-Disposition`.
|
order. Every answer but the last below carries `X-Sww-Disposition`.
|
||||||
|
|
||||||
- `X-Sww-Disposition: allow`, status `200`, no body. `rproxy` passes the request
|
- `X-Sww-Disposition: allow`, status `200`, no body. `rproxy` passes the request
|
||||||
@@ -1219,8 +1270,8 @@ milliseconds. A client without an answer by then counts as unknown. No question
|
|||||||
waits on these, which run in the background:
|
waits on these, which run in the background:
|
||||||
|
|
||||||
- the DNS blocklists and AbuseIPDB;
|
- the DNS blocklists and AbuseIPDB;
|
||||||
- fetching the blocklists, the CrowdSec decision list and the file
|
- fetching the blocklists, the tag lists, the CrowdSec decision list and the
|
||||||
`SWW_ASN_LIMIT_PERCENT_URL` names.
|
file `SWW_ASN_LIMIT_PERCENT_URL` names.
|
||||||
|
|
||||||
In `enforce` mode, a question does not wait for GeoJS when it is a request:
|
In `enforce` mode, a question does not wait for GeoJS when it is a request:
|
||||||
|
|
||||||
|
|||||||
+34
-2
@@ -96,8 +96,8 @@ Every one of these lines has:
|
|||||||
|
|
||||||
- `action`: what was done:
|
- `action`: what was done:
|
||||||
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
||||||
- `denied`: refused for `SWW_DENY_NETS`, or for a reputation source with the
|
- `denied`: refused for `SWW_DENY_NETS`, for a reputation source with the
|
||||||
action `deny`;
|
action `deny`, or for a tag in `SWW_TAG_DENY`;
|
||||||
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
|
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
|
||||||
CrowdSec decision, each of which bans the client;
|
CrowdSec decision, each of which bans the client;
|
||||||
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
||||||
@@ -108,6 +108,36 @@ 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 `SWW_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;
|
||||||
|
- `tag_deny`: `tag`, the tag of `SWW_TAG_DENY` the request carries;
|
||||||
|
- `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`, `SWW_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`, `SWW_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
|
||||||
|
`discriminator` 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.
|
||||||
@@ -139,6 +169,8 @@ Every one of these lines has:
|
|||||||
- the CrowdSec decision list's URL;
|
- the CrowdSec decision list's URL;
|
||||||
- DNS blocklist zones;
|
- DNS blocklist zones;
|
||||||
- `abuseipdb`.
|
- `abuseipdb`.
|
||||||
|
- `tags`: the request's tags, sorted, left out when it has none. Each tag and
|
||||||
|
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
|
||||||
- `offence`: `limit`, for a request that broke a limit.
|
- `offence`: `limit`, for a request that broke a limit.
|
||||||
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
||||||
refused under one: a time, or `permanent`.
|
refused under one: a time, or `permanent`.
|
||||||
|
|||||||
+35
-15
@@ -130,9 +130,11 @@ between the two (see "The image's command" below).
|
|||||||
|
|
||||||
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
||||||
the start while a setting that needs a lookup is set:
|
the start while a setting that needs a lookup is set:
|
||||||
- the two country lists;
|
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
|
||||||
- `SWW_ADD_LOOKUP_HEADERS`;
|
- `SWW_ADD_LOOKUP_HEADERS`;
|
||||||
- the four biased threshold lists and `SWW_ASN_LIMIT_PERCENT_URL`;
|
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming an `asn=` or `country=`
|
||||||
|
tag;
|
||||||
|
- the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`;
|
||||||
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
|
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
|
||||||
- an anomaly threshold per AS number.
|
- an anomaly threshold per AS number.
|
||||||
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
||||||
@@ -144,28 +146,46 @@ between the two (see "The image's command" below).
|
|||||||
the client's AS number in `X-Client-ASN` and its country in
|
the client's AS number in `X-Client-ASN` and its country in
|
||||||
`X-Client-Country`.
|
`X-Client-Country`.
|
||||||
|
|
||||||
## Country lists
|
## Country list
|
||||||
|
|
||||||
- `SWW_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
|
|
||||||
such as `cn,ru,kp`.
|
|
||||||
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
||||||
countries whose clients get through, such as `us,de`. A country on both lists
|
countries whose clients get through, such as `us,de`. A country whose
|
||||||
stops the start.
|
`country=` tag is in `SWW_TAG_DENY` stops the start.
|
||||||
|
|
||||||
|
## Request tags
|
||||||
|
|
||||||
|
Every request gets tags from what `discriminator` knows of its client. See
|
||||||
|
"Request tags" in [`SPEC.md`](../SPEC.md).
|
||||||
|
|
||||||
|
- `SWW_TAG_LISTS` (default empty): address lists that each add a tag, as
|
||||||
|
`name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the
|
||||||
|
tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or
|
||||||
|
`https` URL fetched like a blocklist. A name or URL listed twice is refused.
|
||||||
|
So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`,
|
||||||
|
or the CrowdSec decision list.
|
||||||
|
- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as
|
||||||
|
`torexit,country=cn`. A tag listed twice, an `asn=` tag without a number, or a
|
||||||
|
`country=` tag without a country, is refused.
|
||||||
|
- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as
|
||||||
|
tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from
|
||||||
|
`1` to `99`, of every rate limit and byte limit. The lowest percentage among a
|
||||||
|
client's tags applies. A tag listed twice is refused. So is
|
||||||
|
`exploitsignature`: a request gets that tag after the rate limits have counted
|
||||||
|
it.
|
||||||
|
|
||||||
## Biased thresholds
|
## Biased thresholds
|
||||||
|
|
||||||
- `SWW_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:`
|
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a
|
||||||
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
|
percentage of the byte limits alone, for the AS numbers or countries listed,
|
||||||
listed twice is refused.
|
such as `AS14061:50` or `cn:25`. It takes the place of the percentage
|
||||||
- `SWW_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
|
`SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or
|
||||||
`cn:25,ru:50`.
|
country listed twice is refused.
|
||||||
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): the
|
|
||||||
same, for the byte limits alone.
|
|
||||||
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
||||||
lookup cannot place.
|
lookup cannot place.
|
||||||
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
||||||
text file with one AS number and percentage on each line, such as
|
text file with one AS number and percentage on each line, such as
|
||||||
`AS64496:50`. A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
|
`AS64496:50`. Each entry counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`
|
||||||
|
does. A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
|
||||||
|
|
||||||
## Attack detection
|
## Attack detection
|
||||||
|
|
||||||
|
|||||||
+243
-26
@@ -93,29 +93,30 @@ type Config struct {
|
|||||||
LookupDBPath string
|
LookupDBPath string
|
||||||
LookupTimeout time.Duration
|
LookupTimeout time.Duration
|
||||||
AddLookupHeaders bool
|
AddLookupHeaders bool
|
||||||
// DeniedCountries are the countries whose clients are refused
|
// ExclusivelyAllowedCountries, when not empty, are the only countries
|
||||||
// (SWW_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not
|
// whose clients are let through (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES),
|
||||||
// empty, are the only countries whose clients are let through
|
// two-letter codes in capitals, as GeoJS gives them.
|
||||||
// (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES). Both hold two-letter codes in
|
|
||||||
// capitals, as GeoJS gives them.
|
|
||||||
DeniedCountries []string
|
|
||||||
ExclusivelyAllowedCountries []string
|
ExclusivelyAllowedCountries []string
|
||||||
// The biased thresholds. ASNLimitPercent and CountryLimitPercent give
|
// TagLists are the address lists that each add a tag to the requests of
|
||||||
// the clients of the AS numbers and the countries they list that
|
// the clients they hold (SWW_TAG_LISTS). TagDeny are the tags whose
|
||||||
// percentage of every rate limit and byte limit
|
// requests are refused (SWW_TAG_DENY). TagLimitPercent gives the clients
|
||||||
// (SWW_ASN_LIMIT_PERCENT and SWW_COUNTRY_LIMIT_PERCENT).
|
// with a tag that percentage of every rate limit and byte limit, from 1
|
||||||
// ASNBytesPercent and CountryBytesPercent give those they list a
|
// to 99 (SWW_TAG_LIMIT_PERCENT). All hold tags in lower case, as requests
|
||||||
// percentage of the byte limits in place of that one
|
// carry them.
|
||||||
|
TagLists []TagList
|
||||||
|
TagDeny []string
|
||||||
|
TagLimitPercent map[string]int64
|
||||||
|
// The biased thresholds. ASNBytesPercent and CountryBytesPercent give
|
||||||
|
// those they list a percentage of the byte limits in place of the one
|
||||||
|
// TagLimitPercent gives their asn= or country= tag
|
||||||
// (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds
|
// (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds
|
||||||
// percentages from 0 to 100, by AS number, written as AS64496, or by
|
// percentages from 0 to 100, by AS number, written as AS64496, or by
|
||||||
// country, a two-letter code in capitals, as the lookup gives them.
|
// country, a two-letter code in capitals, as the lookup gives them.
|
||||||
// UnknownLimitPercent is the percentage of every limit a client without
|
// UnknownLimitPercent is the percentage of every limit a client without
|
||||||
// a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
|
// a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
|
||||||
// where a file of AS:percent lines is fetched from, whose percentages
|
// where a file of AS:percent lines is fetched from, whose percentages
|
||||||
// count as those of ASNLimitPercent do (SWW_ASN_LIMIT_PERCENT_URL), ""
|
// count as asn= tags in TagLimitPercent do (SWW_ASN_LIMIT_PERCENT_URL),
|
||||||
// while it is unset.
|
// "" while it is unset.
|
||||||
ASNLimitPercent map[string]int64
|
|
||||||
CountryLimitPercent map[string]int64
|
|
||||||
ASNBytesPercent map[string]int64
|
ASNBytesPercent map[string]int64
|
||||||
CountryBytesPercent map[string]int64
|
CountryBytesPercent map[string]int64
|
||||||
UnknownLimitPercent int64
|
UnknownLimitPercent int64
|
||||||
@@ -367,7 +368,7 @@ var (
|
|||||||
errHeaderTakenOut = errors.New(
|
errHeaderTakenOut = errors.New(
|
||||||
"is taken out of every request by Go's HTTP server, so it can never " +
|
"is taken out of every request by Go's HTTP server, so it can never " +
|
||||||
"be logged")
|
"be logged")
|
||||||
errOnBothLists = errors.New("is in SWW_DENIED_COUNTRIES too")
|
errOnBothLists = errors.New("is in SWW_TAG_DENY too")
|
||||||
errNotLookupSource = errors.New("is not geojs, file or off")
|
errNotLookupSource = errors.New("is not geojs, file or off")
|
||||||
errNeedsLookups = errors.New("it needs each client looked up")
|
errNeedsLookups = errors.New("it needs each client looked up")
|
||||||
errNeedsDBPath = errors.New("it names the file to look clients up in")
|
errNeedsDBPath = errors.New("it names the file to look clients up in")
|
||||||
@@ -451,6 +452,18 @@ var (
|
|||||||
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
|
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
|
||||||
errAnotherList = errors.New(
|
errAnotherList = errors.New(
|
||||||
"is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too")
|
"is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too")
|
||||||
|
errFetchedOtherwise = errors.New("is in SWW_BLOCKLIST_URLS, is " +
|
||||||
|
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too")
|
||||||
|
errNotTagList = errors.New(
|
||||||
|
"is not a name, = and an http or https URL, such as " +
|
||||||
|
"torexit=https://example.com/tor.txt")
|
||||||
|
errNotTagPercentItem = errors.New(
|
||||||
|
"is not a tag, : and a percentage, such as vpn:50 or asn=14061:25")
|
||||||
|
errNotTagPercent = errors.New("is not a percentage from 1 to 99")
|
||||||
|
errNotASNTag = errors.New("is not asn= and a number, such as asn=14061")
|
||||||
|
errExploitTag = errors.New(
|
||||||
|
"cannot lower limits: the rate limits count a request before its " +
|
||||||
|
"rules are matched")
|
||||||
)
|
)
|
||||||
|
|
||||||
// FromEnvironment reads the settings with lookupEnv, normally
|
// FromEnvironment reads the settings with lookupEnv, normally
|
||||||
@@ -483,11 +496,11 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
|||||||
LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""),
|
LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""),
|
||||||
LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"),
|
LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"),
|
||||||
AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"),
|
AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"),
|
||||||
DeniedCountries: env.countries("SWW_DENIED_COUNTRIES", ""),
|
|
||||||
ExclusivelyAllowedCountries: env.countries(
|
ExclusivelyAllowedCountries: env.countries(
|
||||||
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
|
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
|
||||||
ASNLimitPercent: env.percents("SWW_ASN_LIMIT_PERCENT", ParseASN),
|
TagLists: env.tagLists("SWW_TAG_LISTS"),
|
||||||
CountryLimitPercent: env.percents("SWW_COUNTRY_LIMIT_PERCENT", parseCountry),
|
TagDeny: env.tags("SWW_TAG_DENY"),
|
||||||
|
TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"),
|
||||||
ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN),
|
ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN),
|
||||||
CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry),
|
CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry),
|
||||||
UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"),
|
UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"),
|
||||||
@@ -567,6 +580,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
|||||||
env.checkCountriesAndLookups(cfg)
|
env.checkCountriesAndLookups(cfg)
|
||||||
env.checkASNLimitPercentURL(cfg)
|
env.checkASNLimitPercentURL(cfg)
|
||||||
env.checkCrowdSec(cfg)
|
env.checkCrowdSec(cfg)
|
||||||
|
env.checkTagLists(cfg)
|
||||||
|
|
||||||
if env.err != nil {
|
if env.err != nil {
|
||||||
return nil, env.err
|
return nil, env.err
|
||||||
@@ -841,6 +855,31 @@ func (e *environment) percents(
|
|||||||
return percents
|
return percents
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tagLists reads SWW_TAG_LISTS, which is empty by default.
|
||||||
|
func (e *environment) tagLists(name string) []TagList {
|
||||||
|
lists, err := parseTagLists(e.value(name, ""))
|
||||||
|
e.check(name, err)
|
||||||
|
|
||||||
|
return lists
|
||||||
|
}
|
||||||
|
|
||||||
|
// tags reads a setting that is a list of tags. It is empty by default.
|
||||||
|
func (e *environment) tags(name string) []string {
|
||||||
|
tags, err := parseTags(e.value(name, ""))
|
||||||
|
e.check(name, err)
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagPercents reads a setting that is a list of tags, each with a
|
||||||
|
// percentage. It is empty by default.
|
||||||
|
func (e *environment) tagPercents(name string) map[string]int64 {
|
||||||
|
percents, err := parseTagPercents(e.value(name, ""))
|
||||||
|
e.check(name, err)
|
||||||
|
|
||||||
|
return percents
|
||||||
|
}
|
||||||
|
|
||||||
// percent reads a setting that is a percentage, from 0 to 100.
|
// percent reads a setting that is a percentage, from 0 to 100.
|
||||||
func (e *environment) percent(name, defaultValue string) int64 {
|
func (e *environment) percent(name, defaultValue string) int64 {
|
||||||
percent, err := ParsePercent(e.value(name, defaultValue))
|
percent, err := ParsePercent(e.value(name, defaultValue))
|
||||||
@@ -975,15 +1014,17 @@ func (e *environment) checkLookupDBPath(cfg *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkCountriesAndLookups refuses a country on both country lists, and,
|
// checkCountriesAndLookups refuses a country in
|
||||||
|
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES whose tag is in SWW_TAG_DENY, and,
|
||||||
// while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked
|
// while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked
|
||||||
// up: the country lists, SWW_ADD_LOOKUP_HEADERS, the biased thresholds,
|
// up: the country list, SWW_ADD_LOOKUP_HEADERS, the tag settings that name
|
||||||
|
// an asn= or country= tag, the biased thresholds,
|
||||||
// SWW_ASN_LIMIT_PERCENT_URL among them, of which
|
// SWW_ASN_LIMIT_PERCENT_URL among them, of which
|
||||||
// SWW_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
|
// SWW_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
|
||||||
// limit, and the anomaly thresholds per AS number.
|
// limit, and the anomaly thresholds per AS number.
|
||||||
func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
||||||
for _, country := range cfg.ExclusivelyAllowedCountries {
|
for _, country := range cfg.ExclusivelyAllowedCountries {
|
||||||
if slices.Contains(cfg.DeniedCountries, country) {
|
if slices.Contains(cfg.TagDeny, countryTagPrefix+strings.ToLower(country)) {
|
||||||
e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
||||||
fmt.Errorf("%q %w", country, errOnBothLists))
|
fmt.Errorf("%q %w", country, errOnBothLists))
|
||||||
}
|
}
|
||||||
@@ -997,11 +1038,10 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
|||||||
name string
|
name string
|
||||||
set bool
|
set bool
|
||||||
}{
|
}{
|
||||||
{"SWW_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0},
|
|
||||||
{"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
|
{"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
|
||||||
{"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
|
{"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
|
||||||
{"SWW_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0},
|
{"SWW_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, isLookupTag)},
|
||||||
{"SWW_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0},
|
{"SWW_TAG_LIMIT_PERCENT", tagLimitNeedsLookups(cfg)},
|
||||||
{"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
|
{"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
|
||||||
{"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
|
{"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
|
||||||
{"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
|
{"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
|
||||||
@@ -1047,6 +1087,40 @@ func (e *environment) checkCrowdSec(cfg *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkTagLists refuses a list of SWW_TAG_LISTS whose URL is fetched as
|
||||||
|
// another list too: the file at a URL is read as one kind of list.
|
||||||
|
func (e *environment) checkTagLists(cfg *Config) {
|
||||||
|
for _, list := range cfg.TagLists {
|
||||||
|
if slices.Contains(cfg.BlocklistURLs, list.URL) ||
|
||||||
|
list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL {
|
||||||
|
e.check("SWW_TAG_LISTS", fmt.Errorf("%q %w", list.URL, errFetchedOtherwise))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT
|
||||||
|
// names an asn= or country= tag, which only the lookup can give a request.
|
||||||
|
func (c *Config) TagsNeedLookup() bool {
|
||||||
|
return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagLimitNeedsLookups reports whether SWW_TAG_LIMIT_PERCENT names an asn=
|
||||||
|
// or country= tag.
|
||||||
|
func tagLimitNeedsLookups(cfg *Config) bool {
|
||||||
|
for tag := range cfg.TagLimitPercent {
|
||||||
|
if isLookupTag(tag) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLookupTag reports whether tag is an asn= or country= tag.
|
||||||
|
func isLookupTag(tag string) bool {
|
||||||
|
return strings.HasPrefix(tag, asnTagPrefix) || strings.HasPrefix(tag, countryTagPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
// headerNames reads a setting that is a list of header names, and
|
// headerNames reads a setting that is a list of header names, and
|
||||||
// returns them in lower case.
|
// returns them in lower case.
|
||||||
func (e *environment) headerNames(name, defaultValue string) []string {
|
func (e *environment) headerNames(name, defaultValue string) []string {
|
||||||
@@ -1752,6 +1826,149 @@ func ParseASN(value string) (string, error) {
|
|||||||
return "AS" + strconv.FormatUint(number, 10), nil
|
return "AS" + strconv.FormatUint(number, 10), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The prefixes of the tags the lookup gives a request.
|
||||||
|
const (
|
||||||
|
asnTagPrefix = "asn="
|
||||||
|
countryTagPrefix = "country="
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExploitSignatureTag is the tag of a request a rule file or the Core Rule
|
||||||
|
// Set matched.
|
||||||
|
const ExploitSignatureTag = "exploitsignature"
|
||||||
|
|
||||||
|
// TagList is an address list of SWW_TAG_LISTS: the tag it adds to the
|
||||||
|
// requests of the clients it holds, and the URL it is fetched from.
|
||||||
|
type TagList struct {
|
||||||
|
Name string
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTagLists reads a comma-separated list of name=URL pairs, such as
|
||||||
|
// torexit=https://example.com/tor.txt, and returns each list with its name
|
||||||
|
// in lower case. A name can hold a =, as vpn=mullvad does, and ends at the
|
||||||
|
// = before http. A name or a URL listed twice is an error.
|
||||||
|
func parseTagLists(value string) ([]TagList, error) {
|
||||||
|
items, err := parseList(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lists := make([]TagList, 0, len(items))
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
end := strings.Index(item, "=http")
|
||||||
|
if end <= 0 || !isListURL(item[end+1:]) {
|
||||||
|
return nil, fmt.Errorf("%q %w", item, errNotTagList)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := TagList{Name: strings.ToLower(item[:end]), URL: item[end+1:]}
|
||||||
|
|
||||||
|
if slices.ContainsFunc(lists, func(earlier TagList) bool {
|
||||||
|
return earlier.Name == list.Name || earlier.URL == list.URL
|
||||||
|
}) {
|
||||||
|
return nil, fmt.Errorf("%q %w", item, errListedTwice)
|
||||||
|
}
|
||||||
|
|
||||||
|
lists = append(lists, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTags reads a comma-separated list of tags, which parseTag reads. A
|
||||||
|
// tag listed twice is an error.
|
||||||
|
func parseTags(value string) ([]string, error) {
|
||||||
|
items, err := parseList(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := make([]string, 0, len(items))
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
tag, err := parseTag(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(tags, tag) {
|
||||||
|
return nil, fmt.Errorf("%q %w", item, errListedTwice)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTag reads a tag in either case, and returns it as a request carries
|
||||||
|
// it: in lower case, an asn= tag's number without leading zeros. An asn= or
|
||||||
|
// country= tag must name a number or a country, so that a mistyped one
|
||||||
|
// stops the start instead of never matching.
|
||||||
|
func parseTag(value string) (string, error) {
|
||||||
|
tag := strings.ToLower(value)
|
||||||
|
|
||||||
|
if digits, isASN := strings.CutPrefix(tag, asnTagPrefix); isASN {
|
||||||
|
number, err := strconv.ParseUint(digits, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%q %w", value, errNotASNTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return asnTagPrefix + strconv.FormatUint(number, 10), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if code, isCountry := strings.CutPrefix(tag, countryTagPrefix); isCountry {
|
||||||
|
_, err := parseCountry(code)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTagPercents reads a comma-separated list of tags, each with : and a
|
||||||
|
// percentage from 1 to 99, such as vpn:50 or asn=14061:25. A tag listed
|
||||||
|
// twice is an error, and so is ExploitSignatureTag, which a request gets
|
||||||
|
// only after the rate limits have counted it.
|
||||||
|
func parseTagPercents(value string) (map[string]int64, error) {
|
||||||
|
items, err := parseList(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
percents := make(map[string]int64, len(items))
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
colon := strings.LastIndex(item, ":")
|
||||||
|
if colon <= 0 {
|
||||||
|
return nil, fmt.Errorf("%q %w", item, errNotTagPercentItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := parseTag(item[:colon])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
percent, err := strconv.ParseInt(item[colon+1:], 10, 64)
|
||||||
|
if err != nil || percent < 1 || percent > 99 {
|
||||||
|
return nil, fmt.Errorf("%q %w", item[colon+1:], errNotTagPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tag == ExploitSignatureTag {
|
||||||
|
return nil, fmt.Errorf("%q %w", tag, errExploitTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, listed := percents[tag]; listed {
|
||||||
|
return nil, fmt.Errorf("%q %w", item[:colon], errListedTwice)
|
||||||
|
}
|
||||||
|
|
||||||
|
percents[tag] = percent
|
||||||
|
}
|
||||||
|
|
||||||
|
return percents, nil
|
||||||
|
}
|
||||||
|
|
||||||
// parsePercents reads a comma-separated list of items, each an AS number
|
// parsePercents reads a comma-separated list of items, each an AS number
|
||||||
// or a country, which parseCode reads, then : and a percentage, such as
|
// or a country, which parseCode reads, then : and a percentage, such as
|
||||||
// AS64496:50 or cn:25, and returns each one's percentage. An empty value
|
// AS64496:50 or cn:25, and returns each one's percentage. An empty value
|
||||||
|
|||||||
+123
-49
@@ -42,10 +42,10 @@ const (
|
|||||||
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||||
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
||||||
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||||
deniedCountries = "SWW_DENIED_COUNTRIES"
|
|
||||||
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
|
tagLists = "SWW_TAG_LISTS"
|
||||||
countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
|
tagDeny = "SWW_TAG_DENY"
|
||||||
|
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
||||||
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||||
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||||
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||||
@@ -274,9 +274,13 @@ func TestDefaults(t *testing.T) {
|
|||||||
wantNetblocks(t, cfg.AllowNets)
|
wantNetblocks(t, cfg.AllowNets)
|
||||||
wantNetblocks(t, cfg.RateLimitExemptNets)
|
wantNetblocks(t, cfg.RateLimitExemptNets)
|
||||||
wantNetblocks(t, cfg.DenyNets)
|
wantNetblocks(t, cfg.DenyNets)
|
||||||
wantCountries(t, deniedCountries, cfg.DeniedCountries)
|
|
||||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
|
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
|
||||||
|
|
||||||
|
if len(cfg.TagLists) != 0 || len(cfg.TagDeny) != 0 || len(cfg.TagLimitPercent) != 0 {
|
||||||
|
t.Errorf("tag settings %v, %v and %v by default, want all empty",
|
||||||
|
cfg.TagLists, cfg.TagDeny, cfg.TagLimitPercent)
|
||||||
|
}
|
||||||
|
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
if err != nil || hostname == "" || cfg.InstanceName != hostname {
|
if err != nil || hostname == "" || cfg.InstanceName != hostname {
|
||||||
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
|
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
|
||||||
@@ -327,7 +331,6 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
rateLimitPerMinute: "60",
|
rateLimitPerMinute: "60",
|
||||||
rateLimitPerHour: "600",
|
rateLimitPerHour: "600",
|
||||||
rateLimitPerDay: "6000",
|
rateLimitPerDay: "6000",
|
||||||
deniedCountries: "cn, RU,kp,Xk",
|
|
||||||
allowedCountries: "de",
|
allowedCountries: "de",
|
||||||
banResponse: "429",
|
banResponse: "429",
|
||||||
limitBanDuration: "15m",
|
limitBanDuration: "15m",
|
||||||
@@ -370,7 +373,6 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
|
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
|
||||||
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
|
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
|
||||||
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
|
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
|
||||||
wantCountries(t, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK")
|
|
||||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1101,7 +1103,7 @@ func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
deniedCountries: "cn,ru",
|
tagDeny: "country=cn,country=ru",
|
||||||
allowedCountries: "de,RU",
|
allowedCountries: "de,RU",
|
||||||
}.lookupEnv)
|
}.lookupEnv)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1176,12 +1178,11 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for name, value := range map[string]string{
|
for name, value := range map[string]string{
|
||||||
deniedCountries: "kp",
|
|
||||||
allowedCountries: "de",
|
allowedCountries: "de",
|
||||||
addLookupHeaders: enabled,
|
addLookupHeaders: enabled,
|
||||||
|
tagDeny: "country=kp",
|
||||||
|
tagLimitPercent: "asn=64496:50",
|
||||||
asnLimitPercentURL: asnURL,
|
asnLimitPercentURL: asnURL,
|
||||||
asnLimitPercent: "AS64496:50",
|
|
||||||
countryLimitPercent: "cn:25",
|
|
||||||
asnBytesPercent: "AS64496:50",
|
asnBytesPercent: "AS64496:50",
|
||||||
countryBytesPercent: "cn:25",
|
countryBytesPercent: "cn:25",
|
||||||
unknownLimitPercent: "99",
|
unknownLimitPercent: "99",
|
||||||
@@ -1204,12 +1205,12 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set empty, the lists need nothing looked up, and nor does
|
// Set empty, the lists need nothing looked up, and nor does
|
||||||
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
|
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the
|
||||||
// threshold per AS number that is off, any other anomaly threshold, or
|
// lookup does not give, an anomaly threshold per AS number that is off,
|
||||||
// a blocklist.
|
// any other anomaly threshold, or a blocklist.
|
||||||
env := environment{
|
env := environment{
|
||||||
lookupSource: off, deniedCountries: "", allowedCountries: "",
|
lookupSource: off, allowedCountries: "", asnBytesPercent: "",
|
||||||
asnLimitPercent: "", countryLimitPercent: "", asnBytesPercent: "",
|
tagDeny: torExitTag, tagLimitPercent: "vpn:50",
|
||||||
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
|
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
|
||||||
blocklistURLs: dropURL,
|
blocklistURLs: dropURL,
|
||||||
}
|
}
|
||||||
@@ -1355,34 +1356,96 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// torExitTag is a tag these tests name.
|
||||||
|
const torExitTag = "torexit"
|
||||||
|
|
||||||
|
func TestTagSettingsAsSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const torURL = "https://example.com/tor.txt"
|
||||||
|
|
||||||
|
// Names and tags in either case, an AS number with leading zeros, and a
|
||||||
|
// name holding a =.
|
||||||
|
cfg := fromEnvironment(t, environment{
|
||||||
|
tagLists: "TorExit=" + torURL +
|
||||||
|
", vpn=mullvad=https://example.com/mullvad.txt",
|
||||||
|
tagDeny: "TorExit, Country=KP, asn=014061",
|
||||||
|
tagLimitPercent: "vpn:50, asn=014061:25, COUNTRY=cn:1, dnsbl=zen.example:99",
|
||||||
|
})
|
||||||
|
|
||||||
|
wantLists := []config.TagList{
|
||||||
|
{Name: torExitTag, URL: torURL},
|
||||||
|
{Name: "vpn=mullvad", URL: "https://example.com/mullvad.txt"},
|
||||||
|
}
|
||||||
|
if !slices.Equal(cfg.TagLists, wantLists) {
|
||||||
|
t.Errorf("%s gave %+v, want %+v", tagLists, cfg.TagLists, wantLists)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantDeny := []string{torExitTag, "country=kp", "asn=14061"}
|
||||||
|
if !slices.Equal(cfg.TagDeny, wantDeny) {
|
||||||
|
t.Errorf("%s gave %v, want %v", tagDeny, cfg.TagDeny, wantDeny)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPercents := map[string]int64{
|
||||||
|
"vpn": 50, "asn=14061": 25, "country=cn": 1, "dnsbl=zen.example": 99,
|
||||||
|
}
|
||||||
|
if !maps.Equal(cfg.TagLimitPercent, wantPercents) {
|
||||||
|
t.Errorf("%s gave %v, want %v", tagLimitPercent, cfg.TagLimitPercent, wantPercents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const lapi = "http://172.17.0.1:8080"
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, listURL string
|
||||||
|
env environment
|
||||||
|
}{
|
||||||
|
{"a blocklist", dropURL, environment{blocklistURLs: dropURL}},
|
||||||
|
{"the file of AS:percent lines", asnURL, environment{asnLimitPercentURL: asnURL}},
|
||||||
|
{
|
||||||
|
"the CrowdSec decision list", lapi + "/v1/decisions",
|
||||||
|
environment{"SWW_CROWDSEC_LAPI_URL": lapi, "SWW_CROWDSEC_LAPI_KEY": "key"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tc.env[tagLists] = "dup=" + tc.listURL
|
||||||
|
|
||||||
|
want := tagLists + `: "` + tc.listURL + `" is in SWW_BLOCKLIST_URLS, is ` +
|
||||||
|
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too"
|
||||||
|
|
||||||
|
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("error %v, want %s", err, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBiasedThresholdsAsSet(t *testing.T) {
|
func TestBiasedThresholdsAsSet(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := fromEnvironment(t, environment{})
|
cfg := fromEnvironment(t, environment{})
|
||||||
if len(cfg.ASNLimitPercent) != 0 || len(cfg.CountryLimitPercent) != 0 ||
|
if len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
|
||||||
len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
|
|
||||||
cfg.UnknownLimitPercent != 100 {
|
cfg.UnknownLimitPercent != 100 {
|
||||||
t.Errorf("biased thresholds %v, %v, %v, %v and %d by default, "+
|
t.Errorf("biased thresholds %v, %v and %d by default, "+
|
||||||
"want four empty lists and 100", cfg.ASNLimitPercent, cfg.CountryLimitPercent,
|
"want two empty lists and 100",
|
||||||
cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent)
|
cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AS numbers and countries in either case, an AS number with leading
|
// AS numbers and countries in either case, an AS number with leading
|
||||||
// zeros, 0 and 100.
|
// zeros, 0 and 100.
|
||||||
cfg = fromEnvironment(t, environment{
|
cfg = fromEnvironment(t, environment{
|
||||||
asnLimitPercent: "AS14061:50, as16276:0,AS045102:100",
|
|
||||||
countryLimitPercent: "cn:25,RU:50",
|
|
||||||
asnBytesPercent: "as16276:75",
|
asnBytesPercent: "as16276:75",
|
||||||
countryBytesPercent: "ru:10",
|
countryBytesPercent: "ru:10",
|
||||||
unknownLimitPercent: "0",
|
unknownLimitPercent: "0",
|
||||||
})
|
})
|
||||||
|
|
||||||
for name, tc := range map[string]struct{ got, want map[string]int64 }{
|
for name, tc := range map[string]struct{ got, want map[string]int64 }{
|
||||||
asnLimitPercent: {
|
|
||||||
cfg.ASNLimitPercent,
|
|
||||||
map[string]int64{"AS14061": 50, "AS16276": 0, "AS45102": 100},
|
|
||||||
},
|
|
||||||
countryLimitPercent: {cfg.CountryLimitPercent, map[string]int64{"CN": 25, "RU": 50}},
|
|
||||||
asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}},
|
asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}},
|
||||||
countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}},
|
countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}},
|
||||||
} {
|
} {
|
||||||
@@ -1406,16 +1469,16 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct{ name, value, want string }{
|
for _, tc := range []struct{ name, value, want string }{
|
||||||
{asnLimitPercent, "14061:50", `"14061"` + notASN},
|
{asnBytesPercent, "14061:50", `"14061"` + notASN},
|
||||||
{asnLimitPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
|
{asnBytesPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
|
||||||
{asnLimitPercent, "AS14061", `"AS14061"` + notItem},
|
{asnBytesPercent, "AS14061", `"AS14061"` + notItem},
|
||||||
{asnLimitPercent, "AS14061:101", `"101"` + notPercent},
|
{asnBytesPercent, "AS14061:101", `"101"` + notPercent},
|
||||||
{asnLimitPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
|
{asnBytesPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
|
||||||
{
|
{
|
||||||
countryLimitPercent, "nk:25",
|
countryBytesPercent, "nk:25",
|
||||||
`"nk" is not a two-letter country code such as de or kp`,
|
`"nk" is not a two-letter country code such as de or kp`,
|
||||||
},
|
},
|
||||||
{countryLimitPercent, "cn:25,CN:50", `"CN" is listed twice`},
|
{countryBytesPercent, "cn:25,CN:50", `"CN" is listed twice`},
|
||||||
{asnBytesPercent, "AS14061:-1", `"-1"` + notPercent},
|
{asnBytesPercent, "AS14061:-1", `"-1"` + notPercent},
|
||||||
{countryBytesPercent, "cn:50%", `"50%"` + notPercent},
|
{countryBytesPercent, "cn:50%", `"50%"` + notPercent},
|
||||||
{unknownLimitPercent, "101", `"101"` + notPercent},
|
{unknownLimitPercent, "101", `"101"` + notPercent},
|
||||||
@@ -2003,23 +2066,34 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
|||||||
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
|
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
|
||||||
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
|
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
|
||||||
{addLookupHeaders, "yes"},
|
{addLookupHeaders, "yes"},
|
||||||
{deniedCountries, "nk"},
|
{allowedCountries, "nk"},
|
||||||
{deniedCountries, "kp,,ir"},
|
{allowedCountries, "kp,,ir"},
|
||||||
{deniedCountries, "prk"},
|
{allowedCountries, "prk"},
|
||||||
{deniedCountries, "408"},
|
{allowedCountries, "408"},
|
||||||
{deniedCountries, "k"},
|
{allowedCountries, "k"},
|
||||||
{deniedCountries, "eu"},
|
{allowedCountries, "eu"},
|
||||||
{deniedCountries, "un"},
|
{allowedCountries, "un"},
|
||||||
{deniedCountries, "su"},
|
{allowedCountries, "su"},
|
||||||
{allowedCountries, "ac"},
|
{allowedCountries, "ac"},
|
||||||
{allowedCountries, "uk"},
|
{allowedCountries, "uk"},
|
||||||
{allowedCountries, "zz"},
|
{allowedCountries, "zz"},
|
||||||
{allowedCountries, "de,germany"},
|
{allowedCountries, "de,germany"},
|
||||||
{asnLimitPercent, "AS14061:50,,AS16276:50"}, {asnLimitPercent, "ASX:50"},
|
{asnBytesPercent, "AS14061:50,,AS16276:50"}, {asnBytesPercent, "ASX:50"},
|
||||||
{asnLimitPercent, "AS14061:"}, {asnLimitPercent, "AS14061 :50"},
|
{asnBytesPercent, "AS14061:"}, {asnBytesPercent, "AS14061 :50"},
|
||||||
{asnLimitPercent, "AS14061:1.5"}, {asnLimitPercent, "AS-1:50"},
|
{asnBytesPercent, "AS14061:1.5"}, {asnBytesPercent, "AS-1:50"},
|
||||||
{countryLimitPercent, "cn"}, {countryLimitPercent, "cn:"},
|
{countryBytesPercent, "cn"}, {countryBytesPercent, "cn:"},
|
||||||
{countryLimitPercent, "cn:25:50"}, {countryLimitPercent, "china:25"},
|
{countryBytesPercent, "cn:25:50"}, {countryBytesPercent, "china:25"},
|
||||||
|
{tagDeny, "a,,b"}, {tagDeny, "country=nk"}, {tagDeny, "asn=abc"},
|
||||||
|
{tagDeny, "asn="}, {tagDeny, "torexit,TOREXIT"},
|
||||||
|
{tagLimitPercent, "vpn"}, {tagLimitPercent, ":50"}, {tagLimitPercent, "vpn:"},
|
||||||
|
{tagLimitPercent, "vpn:0"}, {tagLimitPercent, "vpn:100"},
|
||||||
|
{tagLimitPercent, "vpn:1.5"}, {tagLimitPercent, "vpn:50,VPN:25"},
|
||||||
|
{tagLimitPercent, "exploitsignature:50"}, {tagLimitPercent, "asn=abc:50"},
|
||||||
|
{tagLists, torExitTag}, {tagLists, "=https://example.com/a.txt"},
|
||||||
|
{tagLists, "a,b=https://example.com/a.txt"},
|
||||||
|
{tagLists, "a=ftp://example.com/a.txt"},
|
||||||
|
{tagLists, "a=https://example.com/a.txt,A=https://example.com/b.txt"},
|
||||||
|
{tagLists, "a=https://example.com/a.txt,b=https://example.com/a.txt"},
|
||||||
{asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"},
|
{asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"},
|
||||||
{unknownLimitPercent, ""}, {unknownLimitPercent, "-1"},
|
{unknownLimitPercent, ""}, {unknownLimitPercent, "-1"},
|
||||||
{unknownLimitPercent, "50%"},
|
{unknownLimitPercent, "50%"},
|
||||||
@@ -2286,10 +2360,10 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
lookupDBPath: "",
|
lookupDBPath: "",
|
||||||
lookupTimeout: "800ms",
|
lookupTimeout: "800ms",
|
||||||
addLookupHeaders: "false",
|
addLookupHeaders: "false",
|
||||||
deniedCountries: "",
|
|
||||||
allowedCountries: "",
|
allowedCountries: "",
|
||||||
asnLimitPercent: "",
|
tagLists: "",
|
||||||
countryLimitPercent: "",
|
tagDeny: "",
|
||||||
|
tagLimitPercent: "",
|
||||||
asnBytesPercent: "",
|
asnBytesPercent: "",
|
||||||
countryBytesPercent: "",
|
countryBytesPercent: "",
|
||||||
unknownLimitPercent: "100",
|
unknownLimitPercent: "100",
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic abuseipdb. It reads SWW_ABUSEIPDB_KEY,
|
||||||
|
// SWW_ABUSEIPDB_MIN_SCORE and SWW_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 adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
|
||||||
|
// the score, when its score of the client is a hit, and refuses the request
|
||||||
|
// when SWW_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
|
||||||
|
// SWW_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
|
||||||
|
// SWW_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.addTag("abuseipdb")
|
||||||
|
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
||||||
|
"scored by AbuseIPDB at or over SWW_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,6 +91,18 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startDiscriminator(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()
|
||||||
|
|
||||||
@@ -323,7 +335,7 @@ func newDiscriminatorInBubble(
|
|||||||
) (*discriminator.Server, *output) {
|
) (*discriminator.Server, *output) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{deniedCountries: "KP"}
|
settings := map[string]string{tagDeny: "country=KP"}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, settings)
|
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, settings)
|
||||||
@@ -530,7 +542,7 @@ func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
|||||||
|
|
||||||
return discriminator.New(params)
|
return discriminator.New(params)
|
||||||
}, held, time.Now, map[string]string{
|
}, held, time.Now, map[string]string{
|
||||||
deniedCountries: "KP", wafMode: block,
|
tagDeny: "country=KP", wafMode: block,
|
||||||
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
||||||
abuseIPDBKey: accountKey,
|
abuseIPDBKey: accountKey,
|
||||||
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package discriminator
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic active_ban, and the code that makes bans, which several
|
||||||
|
// heuristics share. active_ban reads SWW_MODE; the bans are the ban ledger's.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"context"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,11 +21,15 @@ 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}
|
||||||
}
|
}
|
||||||
|
|
||||||
// banned reports whether a ban on a netblock the client is in covers the
|
// activeBan refuses a client a ban covers: a ban on a netblock the client is
|
||||||
// request at now, and notes for the log line when that ban ends. A
|
// in, in force at now. It notes for the log line when that ban ends. A request
|
||||||
// request that makes the ban permanent, or in observe mode would have,
|
// that makes the ban permanent, or in observe mode would have, raises the alert
|
||||||
// raises the alert for it.
|
// for it. Its figures are the ban's netblock and when it ends.
|
||||||
func (rq *request) banned(now time.Time) bool {
|
type activeBan struct{}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -38,85 +45,24 @@ func (rq *request) banned(now time.Time) bool {
|
|||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return banned
|
if !banned {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// limitBroken counts the request for the rate limits at now, notes the
|
return &verdict{
|
||||||
// client's counts for the log line, and reports whether the request takes
|
action: requestlog.ActionBanned,
|
||||||
// the client over a rate limit, as its limit percentage lowers it, which
|
figures: map[string]any{
|
||||||
// breaks it.
|
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
|
||||||
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 over
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// countRefusal counts the request for the error burst once it has been
|
// limitFigures are the figures of a heuristic that broke hit, a limit: its
|
||||||
// answered, if discriminator refused it after a rule file match, a trap path or a
|
// window, the count and the limit.
|
||||||
// Core Rule Set match, and in observe mode if enforce mode would have: more than
|
func limitFigures(hit ratelimit.Hit) map[string]any {
|
||||||
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute
|
return map[string]any{
|
||||||
// break a limit. A client in SWW_ALLOW_NETS, which the checks skip, is
|
figureWindow: hit.Window, figureCount: hit.Count, figureLimit: hit.Limit,
|
||||||
// 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
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
banScopeV4Prefix: "24",
|
banScopeV4Prefix: "24",
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
// fromDE's ban covers otherClient, which is refused unasked about.
|
// fromDE's ban covers otherClient, which is refused unasked about.
|
||||||
@@ -228,6 +228,22 @@ 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()
|
||||||
|
|
||||||
@@ -250,7 +266,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.setting != "" {
|
if tc.setting != "" {
|
||||||
@@ -260,7 +276,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
|||||||
s, _, _ := startWithClock(t, geojsURL, env)
|
s, _, _ := startWithClock(t, geojsURL, env)
|
||||||
|
|
||||||
s.get(denied, tc.status, requestlog.ActionDenied)
|
s.get(denied, tc.status, requestlog.ActionDenied)
|
||||||
s.get(fromKP, tc.status, requestlog.ActionCountryDenied)
|
s.get(fromKP, tc.status, requestlog.ActionDenied)
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(fromDE, tc.status, requestlog.ActionRateLimited)
|
s.get(fromDE, tc.status, requestlog.ActionRateLimited)
|
||||||
s.get(fromDE, tc.status, requestlog.ActionBanned)
|
s.get(fromDE, tc.status, requestlog.ActionBanned)
|
||||||
@@ -275,7 +291,7 @@ func TestBanNotes(t *testing.T) {
|
|||||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
|
|||||||
@@ -17,30 +17,31 @@ type percentage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// biasedThresholdsSet reports whether a biased threshold can lower a
|
// biasedThresholdsSet reports whether a biased threshold can lower a
|
||||||
// client's limits: one of its lists is not empty,
|
// client's limits by its lookup: SWW_ASN_BYTES_PERCENT or
|
||||||
// SWW_UNKNOWN_LIMIT_PERCENT is below 100, or SWW_ASN_LIMIT_PERCENT_URL
|
// SWW_COUNTRY_BYTES_PERCENT is not empty, SWW_UNKNOWN_LIMIT_PERCENT is
|
||||||
// is set. The client's lookup is then needed before its request goes on.
|
// below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
|
||||||
|
// then needed before its request goes on.
|
||||||
func biasedThresholdsSet(cfg *config.Config) bool {
|
func biasedThresholdsSet(cfg *config.Config) bool {
|
||||||
return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
|
return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
|
||||||
len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
|
|
||||||
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
|
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// limitPercentages returns the client's limit percentages, for the rate
|
// limitPercentages returns the client's limit percentages, for the rate
|
||||||
// limits and for the byte limits, by its AS number and country as looked
|
// limits and for the byte limits, by its tags, its AS number and country
|
||||||
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
|
// as looked up, each "" when unknown, and the blocklists, DNSBL zones and
|
||||||
// that list it. Each is the lowest of those the settings give it, the
|
// AbuseIPDB that list it. Each is the lowest of those the settings give
|
||||||
// first of them in the order below when several are lowest: the
|
// it, the first of them in the order below when several are lowest: the
|
||||||
// percentage SWW_ASN_LIMIT_PERCENT gives its AS number, the one the file
|
// percentage SWW_TAG_LIMIT_PERCENT gives its asn= tag, the one the file
|
||||||
// SWW_ASN_LIMIT_PERCENT_URL names gives it, the one
|
// SWW_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
|
||||||
// SWW_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
|
// SWW_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
|
||||||
// country, unless its request skipped the wait for GeoJS,
|
// other tags, for a client without a country, unless its request skipped
|
||||||
// SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the
|
// the wait for GeoJS, SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
|
||||||
// percentage of SWW_BLOCKLIST_ACTION while it is limit, and for a client
|
// lists, the percentage of SWW_BLOCKLIST_ACTION while it is limit, and
|
||||||
// a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the
|
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
|
||||||
// percentage of SWW_REPUTATION_ACTION while it is limit. For the byte
|
// a hit, the percentage of SWW_REPUTATION_ACTION while it is limit. For
|
||||||
// limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT take the
|
// the byte limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT
|
||||||
// place of the first three for an AS number or a country they list.
|
// take the place of the first three for an AS number or a country they
|
||||||
|
// list.
|
||||||
func (rq *request) limitPercentages() (percentage, percentage) {
|
func (rq *request) limitPercentages() (percentage, percentage) {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
asn, country := rq.line.ASN, rq.line.Country
|
asn, country := rq.line.ASN, rq.line.Country
|
||||||
@@ -65,10 +66,18 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
|||||||
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
|
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
|
||||||
}
|
}
|
||||||
|
|
||||||
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWW_ASN_LIMIT_PERCENT"),
|
const tagSetting = "SWW_TAG_LIMIT_PERCENT"
|
||||||
fetched)
|
|
||||||
countryRequests := given(cfg.CountryLimitPercent, country,
|
asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched)
|
||||||
"SWW_COUNTRY_LIMIT_PERCENT")
|
countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting)
|
||||||
|
|
||||||
|
otherTags := percentage{percent: whole}
|
||||||
|
|
||||||
|
for _, tag := range rq.line.Tags {
|
||||||
|
if tag != asnTag(asn) && tag != countryTag(country) {
|
||||||
|
otherTags = lowest(otherTags, given(cfg.TagLimitPercent, tag, tagSetting))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
asnBytes, countryBytes := asnRequests, countryRequests
|
asnBytes, countryBytes := asnRequests, countryRequests
|
||||||
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
||||||
@@ -79,12 +88,13 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
|||||||
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
|
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
|
return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted,
|
||||||
lowest(asnBytes, countryBytes, unknown, blocklisted, reputationListed)
|
reputationListed),
|
||||||
|
lowest(asnBytes, countryBytes, otherTags, unknown, blocklisted, reputationListed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// given returns the percentage percents, the setting named setting, gives
|
// given returns the percentage percents, the setting named setting, gives
|
||||||
// code, an AS number or a country, or whole when it does not list code.
|
// code, an AS number, a country or a tag, or whole when it does not list code.
|
||||||
func given(percents map[string]int64, code, setting string) percentage {
|
func given(percents map[string]int64, code, setting string) percentage {
|
||||||
percent, listed := percents[code]
|
percent, listed := percents[code]
|
||||||
if !listed {
|
if !listed {
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ import (
|
|||||||
|
|
||||||
// The biased thresholds.
|
// The biased thresholds.
|
||||||
const (
|
const (
|
||||||
asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
|
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
||||||
countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
|
|
||||||
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||||
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||||
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||||
@@ -35,6 +34,11 @@ const (
|
|||||||
asnDEHalf = asnDE + ":50"
|
asnDEHalf = asnDE + ":50"
|
||||||
asnDEQuarter = asnDE + ":25"
|
asnDEQuarter = asnDE + ":25"
|
||||||
countryDEHalf = "de:50"
|
countryDEHalf = "de:50"
|
||||||
|
// The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags.
|
||||||
|
asnTagDEHalf = "asn=64496:50"
|
||||||
|
asnTagDEQuarter = "asn=64496:25"
|
||||||
|
countryTagDEHalf = "country=de:50"
|
||||||
|
countryTagDEQuarter = "country=de:25"
|
||||||
// noCountry is in an AS of its own, AS64500, and in no country.
|
// noCountry is in an AS of its own, AS64500, and in no country.
|
||||||
noCountry = "192.0.2.80"
|
noCountry = "192.0.2.80"
|
||||||
// fourAMinute is the rate limit these tests set: half of it is 2
|
// fourAMinute is the rate limit these tests set: half of it is 2
|
||||||
@@ -54,8 +58,8 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting, value, from string
|
setting, value, from string
|
||||||
}{
|
}{
|
||||||
{asnLimitPercent, asnDEHalf, fromDE},
|
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||||
{countryLimitPercent, countryDEHalf, fromDE},
|
{tagLimitPercent, countryTagDEHalf, fromDE},
|
||||||
{unknownLimitPercent, "50", unplaced},
|
{unknownLimitPercent, "50", unplaced},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.setting, func(t *testing.T) {
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
@@ -96,8 +100,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting, value, from string
|
setting, value, from string
|
||||||
}{
|
}{
|
||||||
{asnLimitPercent, asnDEHalf, fromDE},
|
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||||
{countryLimitPercent, "DE:50", fromDE},
|
{tagLimitPercent, "Country=DE:50", fromDE},
|
||||||
{unknownLimitPercent, "50", unplaced},
|
{unknownLimitPercent, "50", unplaced},
|
||||||
{asnBytesPercent, "as64496:50", fromDE},
|
{asnBytesPercent, "as64496:50", fromDE},
|
||||||
{countryBytesPercent, countryDEHalf, fromDE},
|
{countryBytesPercent, countryDEHalf, fromDE},
|
||||||
@@ -151,13 +155,13 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"raising the byte limits back",
|
"raising the byte limits back",
|
||||||
map[string]string{asnLimitPercent: asnDEHalf, asnBytesPercent: asnDE + ":100"},
|
map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
|
||||||
"50 from " + asnLimitPercent, none, "",
|
"50 from " + tagLimitPercent, none, "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"raising the byte limits back, by country",
|
"raising the byte limits back, by country",
|
||||||
map[string]string{countryLimitPercent: countryDEHalf, countryBytesPercent: "de:100"},
|
map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
|
||||||
"50 from " + countryLimitPercent, none, "",
|
"50 from " + tagLimitPercent, none, "",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -184,19 +188,19 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
|||||||
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithLookups(t, map[string]string{asnLimitPercent: asnDE + ":0"})
|
s, _, _ := startWithLookups(t, map[string]string{unknownLimitPercent: "0"})
|
||||||
|
|
||||||
// The first request breaks the limit, and bans the client; the log line
|
// The first request breaks the limit, and bans the client; the log line
|
||||||
// gives the 0.
|
// gives the 0.
|
||||||
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
if line.fields["limit_percent"] != float64(0) ||
|
if line.fields["limit_percent"] != float64(0) ||
|
||||||
line.fields["limit_percent_setting"] != asnLimitPercent {
|
line.fields["limit_percent_setting"] != unknownLimitPercent {
|
||||||
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
|
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
|
||||||
line.fields["limit_percent"], line.fields["limit_percent_setting"],
|
line.fields["limit_percent"], line.fields["limit_percent_setting"],
|
||||||
asnLimitPercent)
|
unknownLimitPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(unplaced, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLowestPercentageApplies(t *testing.T) {
|
func TestLowestPercentageApplies(t *testing.T) {
|
||||||
@@ -211,29 +215,29 @@ func TestLowestPercentageApplies(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"the country's",
|
"the country's",
|
||||||
map[string]string{asnLimitPercent: asnDEHalf, countryLimitPercent: "de:25"},
|
map[string]string{tagLimitPercent: asnTagDEHalf + "," + countryTagDEQuarter},
|
||||||
fromDE, "25 from " + countryLimitPercent,
|
fromDE, "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the AS number's",
|
"the AS number's",
|
||||||
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: countryDEHalf},
|
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEHalf},
|
||||||
fromDE, "25 from " + asnLimitPercent,
|
fromDE, "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the AS number's, the first of two alike",
|
"the AS number's, the first of two alike",
|
||||||
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: "de:25"},
|
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEQuarter},
|
||||||
fromDE, "25 from " + asnLimitPercent,
|
fromDE, "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"that for a client without a country",
|
"that for a client without a country",
|
||||||
map[string]string{asnLimitPercent: "AS64500:50", unknownLimitPercent: "25"},
|
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
|
||||||
noCountry, "25 from " + unknownLimitPercent,
|
noCountry, "25 from " + unknownLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
||||||
"the AS number's, for a client without a country",
|
"the AS number's, for a client without a country",
|
||||||
map[string]string{asnLimitPercent: "AS64500:25"},
|
map[string]string{tagLimitPercent: "asn=64500:25"},
|
||||||
noCountry, "25 from " + asnLimitPercent,
|
noCountry, "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -304,8 +308,12 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
|||||||
setting, value string
|
setting, value string
|
||||||
waits bool
|
waits bool
|
||||||
}{
|
}{
|
||||||
{asnLimitPercent, asnDEHalf, true},
|
{tagLimitPercent, asnTagDEHalf, true},
|
||||||
{countryLimitPercent, countryDEHalf, true},
|
{tagLimitPercent, countryTagDEHalf, true},
|
||||||
|
{tagDeny, denyKP, true},
|
||||||
|
// A tag the lookup does not give needs no answer.
|
||||||
|
{tagLimitPercent, "vpn:50", false},
|
||||||
|
{tagDeny, "vpn", false},
|
||||||
{asnBytesPercent, asnDEHalf, true},
|
{asnBytesPercent, asnDEHalf, true},
|
||||||
{countryBytesPercent, countryDEHalf, true},
|
{countryBytesPercent, countryDEHalf, true},
|
||||||
{unknownLimitPercent, "99", true},
|
{unknownLimitPercent, "99", true},
|
||||||
@@ -363,15 +371,15 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
|||||||
{
|
{
|
||||||
// A quarter of 12 requests a minute is 3: the fourth breaks it.
|
// A quarter of 12 requests a minute is 3: the fourth breaks it.
|
||||||
"a rate limit",
|
"a rate limit",
|
||||||
map[string]string{rateLimitPerMinute: "12", asnLimitPercent: asnDEQuarter},
|
map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
|
||||||
3, http.StatusForbidden, requestlog.ActionRateLimited,
|
3, http.StatusForbidden, requestlog.ActionRateLimited,
|
||||||
"requests per minute over the limit of 3", "25 from " + asnLimitPercent,
|
"requests per minute over the limit of 3", "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// The byte limits' percentage, not the rate limits'.
|
// The byte limits' percentage, not the rate limits'.
|
||||||
"a byte limit",
|
"a byte limit",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
bytesLimitPerMinute: twoUploads, asnLimitPercent: asnDEQuarter,
|
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
|
||||||
asnBytesPercent: asnDEHalf,
|
asnBytesPercent: asnDEHalf,
|
||||||
},
|
},
|
||||||
0, http.StatusOK, requestlog.ActionForward,
|
0, http.StatusOK, requestlog.ActionForward,
|
||||||
@@ -487,7 +495,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// percentText gives a limit percentage and the setting that gave it as
|
// percentText gives a limit percentage and the setting that gave it as
|
||||||
// text, such as "50 from SWW_ASN_LIMIT_PERCENT", or none when both are
|
// text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are
|
||||||
// left out.
|
// left out.
|
||||||
func percentText(percent *int64, setting string) string {
|
func percentText(percent *int64, setting string) string {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic blocklists. It reads SWW_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 SWW_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 SWW_BLOCKLIST_ACTION is deny.
|
||||||
|
func (rq *request) refusedByBlocklists(listedBy []string) bool {
|
||||||
|
return len(listedBy) > 0 && rq.h.config.BlocklistAction == deny
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic byte_limits. It reads SWW_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,6 +98,21 @@ 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,6 +1,10 @@
|
|||||||
package discriminator
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic core_rule_set. It reads SWW_WAF_MODE, SWW_WAF_EXEMPT_PATHS
|
||||||
|
// and SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
@@ -9,18 +13,22 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkCoreRuleSet inspects the request with the Core Rule Set, unless
|
// coreRuleSet inspects the request with the Core Rule Set, unless
|
||||||
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
|
// SWW_WAF_MODE is off or SWW_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
|
||||||
// SWW_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
|
// SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature,
|
||||||
// and in block mode refuses the request, which is an offence its client's
|
// raises the waf_block alert, and in block mode refuses the request with 403,
|
||||||
// history counts, and so returns ActionWAFBlocked. It returns "" for a
|
// which is an offence its client's history counts. Its figures are the score
|
||||||
// request it does not refuse.
|
// and the threshold.
|
||||||
func (rq *request) checkCoreRuleSet() string {
|
type coreRuleSet struct{}
|
||||||
|
|
||||||
|
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 ""
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -40,18 +48,22 @@ func (rq *request) checkCoreRuleSet() string {
|
|||||||
|
|
||||||
threshold := cfg.WAFAnomalyThreshold
|
threshold := cfg.WAFAnomalyThreshold
|
||||||
if threshold == 0 || result.Score < threshold {
|
if threshold == 0 || result.Score < threshold {
|
||||||
return ""
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rq.addTag(config.ExploitSignatureTag)
|
||||||
rq.alertWAFBlock(result)
|
rq.alertWAFBlock(result)
|
||||||
|
|
||||||
if cfg.WAFMode == config.WAFModeDetect {
|
if cfg.WAFMode == config.WAFModeDetect {
|
||||||
return ""
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.wafBlocked = true
|
rq.wafBlocked = true
|
||||||
|
|
||||||
return requestlog.ActionWAFBlocked
|
return &verdict{
|
||||||
|
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
|
||||||
|
|||||||
@@ -61,6 +61,41 @@ func wantWAF(t *testing.T, line logLine, score *int, ruleIDs ...int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
mode string
|
||||||
|
status int
|
||||||
|
action string
|
||||||
|
}{
|
||||||
|
{block, block, http.StatusForbidden, requestlog.ActionWAFBlocked},
|
||||||
|
{detect, detect, http.StatusOK, requestlog.ActionForward},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
||||||
|
|
||||||
|
wantTags(t, s.request(client, sqlInjection, tc.status, tc.action),
|
||||||
|
"exploitsignature")
|
||||||
|
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
|
wafMode: detect, tagDeny: "exploitsignature",
|
||||||
|
})
|
||||||
|
|
||||||
|
line := s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
|
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
|
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -110,6 +145,16 @@ 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,21 +1,35 @@
|
|||||||
package discriminator
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"slices"
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// countryDenied reports whether the country lists refuse the request, by
|
// countries refuses a request when SWW_EXCLUSIVELY_ALLOWED_COUNTRIES is set and
|
||||||
// the client's country as it was looked up. A client without a country,
|
// does not hold the client's country as it was looked up. A client without a
|
||||||
// or whose country cannot be found, is refused only by
|
// country, or whose country cannot be found, is refused. Its figures are the
|
||||||
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
// country and the setting that refused it.
|
||||||
func (rq *request) countryDenied() bool {
|
type countries struct{}
|
||||||
denied := rq.h.config.DeniedCountries
|
|
||||||
|
func (countries) name() string { return "countries" }
|
||||||
|
|
||||||
|
func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
allowed := rq.h.config.ExclusivelyAllowedCountries
|
allowed := rq.h.config.ExclusivelyAllowedCountries
|
||||||
country := rq.line.Country
|
country := rq.line.Country
|
||||||
|
|
||||||
if slices.Contains(denied, country) {
|
if len(allowed) == 0 || slices.Contains(allowed, country) {
|
||||||
return true
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return len(allowed) > 0 && !slices.Contains(allowed, country)
|
return &verdict{
|
||||||
|
action: requestlog.ActionCountryDenied,
|
||||||
|
figures: map[string]any{
|
||||||
|
"country": country, "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -33,13 +33,13 @@ func TestCountryLists(t *testing.T) {
|
|||||||
env map[string]string
|
env map[string]string
|
||||||
refused []string
|
refused []string
|
||||||
}{
|
}{
|
||||||
{"denied", map[string]string{deniedCountries: "kp"}, []string{fromKP}},
|
|
||||||
{
|
{
|
||||||
"exclusively allowed", map[string]string{allowedCountries: "DE"},
|
"exclusively allowed", map[string]string{allowedCountries: "DE"},
|
||||||
[]string{fromKP, unplaced},
|
[]string{fromKP, unplaced},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"both", map[string]string{deniedCountries: "kp", allowedCountries: "de,fr"},
|
"with a country in SWW_TAG_DENY",
|
||||||
|
map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"},
|
||||||
[]string{fromKP, unplaced},
|
[]string{fromKP, unplaced},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -90,6 +90,23 @@ func TestCountryLists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
||||||
|
lookupTimeout: "1h", allowedCountries: "de",
|
||||||
|
})
|
||||||
|
|
||||||
|
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||||
|
wantHeuristic(t, line, "countries",
|
||||||
|
`{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
||||||
|
|
||||||
|
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||||
|
wantHeuristic(t, line, "countries",
|
||||||
|
`{"country": "", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -102,7 +119,7 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
|||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
deniedCountries: "kp",
|
allowedCountries: "de",
|
||||||
})
|
})
|
||||||
|
|
||||||
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
||||||
@@ -185,7 +202,7 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
|
|||||||
env map[string]string
|
env map[string]string
|
||||||
}{
|
}{
|
||||||
{"no setting needs the lookup", nil},
|
{"no setting needs the lookup", nil},
|
||||||
{"a country list is set", map[string]string{deniedCountries: "kp"}},
|
{"a tag setting needs it", map[string]string{tagDeny: denyKP}},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic crowdsec. It reads SWW_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 adds the tag crowdsec, 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.addTag("crowdsec")
|
||||||
|
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,6 +95,22 @@ 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))+`"}`)
|
||||||
|
wantTags(t, line, "crowdsec")
|
||||||
|
}
|
||||||
|
|
||||||
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
|
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic deny_nets. It reads SWW_DENY_NETS.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// denyNets refuses a client in SWW_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
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package discriminator
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -52,13 +53,6 @@ const ReportPath = "/_sww/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.
|
||||||
@@ -148,48 +142,20 @@ 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: it is over the header limits, a blocklist lists its client while
|
// its client: the heuristic crowdsec, header_limits, blocklists or rate_limits
|
||||||
// SWW_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is
|
// will refuse it, as a function in that heuristic's file says. In observe mode,
|
||||||
// over a whole rate limit. In observe mode, which refuses nothing, none is.
|
// which refuses nothing, none is. rproxy lets a request through when
|
||||||
// rproxy lets a request through when discriminator answers too late.
|
// discriminator 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
|
||||||
}
|
}
|
||||||
|
|
||||||
lists := rq.h.lists
|
_, decided := rq.crowdSecDecisionInForce(now)
|
||||||
_, decided := lists.CrowdSecDecision(rq.client, now)
|
|
||||||
|
|
||||||
return decided || rq.overHeaderLimits() ||
|
return decided || overHeaderLimits(rq.headerCounts()) ||
|
||||||
(rq.h.config.BlocklistAction == deny && len(lists.ListedBy(rq.client)) > 0) ||
|
rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) ||
|
||||||
(rq.countedByRateLimits() &&
|
rq.overAWholeRateLimit(now)
|
||||||
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now))
|
|
||||||
}
|
|
||||||
|
|
||||||
// overHeaderLimits reports whether discriminator 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
|
||||||
@@ -220,18 +186,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.countRefusal()
|
defer rq.runAfterAnswer(r.Context())
|
||||||
|
|
||||||
rq.allow()
|
rq.allow()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The last deferred runs first: countRefusal before addToHistory, so that a
|
// The last deferred runs first: runAfterAnswer before addToHistory, so that
|
||||||
// broken error burst is in the client's history.
|
// a broken refusal limit is in the client's history.
|
||||||
defer rq.addToHistory()
|
defer rq.addToHistory()
|
||||||
defer rq.countAnomalies()
|
defer rq.countAnomalies()
|
||||||
defer rq.countRefusal()
|
defer rq.runAfterAnswer(r.Context())
|
||||||
|
|
||||||
if refused != nil {
|
if refused != nil {
|
||||||
rq.deny(*refused)
|
rq.deny(*refused)
|
||||||
@@ -413,7 +379,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(held, report)
|
h.finishWith(r.Context(), held, report)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -476,11 +442,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 counts the request's bytes, and adds it to its
|
// For an allowed request it first runs the heuristics of onReport, and adds the
|
||||||
// client's history, the anomaly counters and the metrics, with the status and
|
// request to its client's history, the anomaly counters and the metrics, with
|
||||||
// bytes the report gives. A denied request's counting was finished at its
|
// the status and bytes the report gives. A denied request's counting was
|
||||||
// decision.
|
// finished at its decision. ctx is the report's own context.
|
||||||
func (h *handler) finishWith(held *decision, report Report) {
|
func (h *handler) finishWith(ctx context.Context, held *decision, report Report) {
|
||||||
rq := &request{
|
rq := &request{
|
||||||
decision: *held,
|
decision: *held,
|
||||||
h: h,
|
h: h,
|
||||||
@@ -497,7 +463,7 @@ func (h *handler) finishWith(held *decision, report Report) {
|
|||||||
if rq.allowed {
|
if rq.allowed {
|
||||||
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal
|
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal
|
||||||
|
|
||||||
rq.countBytes()
|
rq.runBanningHeuristics(ctx, onReport())
|
||||||
rq.countAnomalies()
|
rq.countAnomalies()
|
||||||
rq.addToHistory()
|
rq.addToHistory()
|
||||||
|
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, asked, release := startHeldGeoJS(t)
|
geojsURL, asked, release := startHeldGeoJS(t)
|
||||||
s := startDiscriminator(t, geojsURL, map[string]string{
|
s := startDiscriminator(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h", deniedCountries: "kp", bytesLimitPerMinute: byteLimit,
|
lookupTimeout: "1h", tagDeny: denyKP, bytesLimitPerMinute: byteLimit,
|
||||||
})
|
})
|
||||||
|
|
||||||
first := s.question(siteA, client, "/")
|
first := s.question(siteA, client, "/")
|
||||||
@@ -765,7 +765,7 @@ func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s := startDiscriminator(t, geojsURL, map[string]string{
|
s := startDiscriminator(t, geojsURL, map[string]string{
|
||||||
addLookupHeaders: tc.setting, lookupTimeout: "1h", deniedCountries: "kp",
|
addLookupHeaders: tc.setting, lookupTimeout: "1h", tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
got := s.ask(fromDE, "/")
|
got := s.ask(fromDE, "/")
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic dnsbl. It reads SWW_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, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
|
||||||
|
// the request when SWW_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")
|
||||||
|
|
||||||
|
for _, zone := range listedBy {
|
||||||
|
rq.addTag("dnsbl=" + zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionDenied,
|
||||||
|
figures: map[string]any{"zones": listedBy},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
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 discriminator 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
|
||||||
|
// SWW_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.
|
||||||
|
// tag_deny runs twice: before the rate limits, on the tags known by then, and
|
||||||
|
// last, on the tag rule_files and core_rule_set can add.
|
||||||
|
func afterLookup() []heuristic {
|
||||||
|
return []heuristic{
|
||||||
|
tagLists{},
|
||||||
|
countries{},
|
||||||
|
blocklists{},
|
||||||
|
crowdSec{},
|
||||||
|
dnsbl{},
|
||||||
|
abuseIPDB{},
|
||||||
|
tagDeny{},
|
||||||
|
rateLimits{},
|
||||||
|
trapPaths{},
|
||||||
|
ruleFiles{},
|
||||||
|
headerLimits{},
|
||||||
|
coreRuleSet{},
|
||||||
|
tagDeny{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// afterAnswer are the heuristics that run on a request once discriminator 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
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "2",
|
rateLimitPerMinute: "2",
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
@@ -19,7 +20,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// lookUp looks up the client's AS number and country, in the lookup
|
// lookUp looks up the client's AS number and country, in the lookup
|
||||||
// database or through GeoJS, and notes them for the log line, unless
|
// database or through GeoJS, and notes them for the log line and as the
|
||||||
|
// request's asn= and country= tags, unless
|
||||||
// SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or
|
// SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or
|
||||||
// link-local address, which no lookup can place. The lookup database
|
// link-local address, which no lookup can place. The lookup database
|
||||||
// answers at once. With GeoJS, while a setting needs the answer, such as a
|
// answers at once. With GeoJS, while a setting needs the answer, such as a
|
||||||
@@ -47,6 +49,34 @@ func (rq *request) lookUp(ctx context.Context, now time.Time) {
|
|||||||
rq.line.ASN = rq.lookupAnswer.ASN
|
rq.line.ASN = rq.lookupAnswer.ASN
|
||||||
rq.line.ASName = rq.lookupAnswer.ASName
|
rq.line.ASName = rq.lookupAnswer.ASName
|
||||||
rq.line.Country = rq.lookupAnswer.Country
|
rq.line.Country = rq.lookupAnswer.Country
|
||||||
|
|
||||||
|
if tag := asnTag(rq.line.ASN); tag != "" {
|
||||||
|
rq.addTag(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tag := countryTag(rq.line.Country); tag != "" {
|
||||||
|
rq.addTag(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// asnTag returns the tag of asn, an AS number as the lookup gives it, such as
|
||||||
|
// asn=64496 for AS64496, or "" for no AS number.
|
||||||
|
func asnTag(asn string) string {
|
||||||
|
if asn == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return "asn=" + strings.TrimPrefix(asn, "AS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// countryTag returns the tag of country, a code as the lookup gives it, such
|
||||||
|
// as country=de for DE, or "" for no country.
|
||||||
|
func countryTag(country string) string {
|
||||||
|
if country == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return "country=" + strings.ToLower(country)
|
||||||
}
|
}
|
||||||
|
|
||||||
// addLookup adds answer, an answer about a client from the lookup
|
// addLookup adds answer, an answer about a client from the lookup
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
|
|||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
requestFromDE := func() *http.Request {
|
requestFromDE := func() *http.Request {
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
metricsTopN: "2",
|
metricsTopN: "2",
|
||||||
deniedCountries: "kp",
|
allowedCountries: "de,fr",
|
||||||
}
|
}
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env)
|
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env)
|
||||||
@@ -324,11 +324,11 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
|||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
// GeoJS fails, so the client counts as coming from an unknown country,
|
// GeoJS fails, so the client has no country= tag, and SWW_TAG_DENY does
|
||||||
// which SWW_DENIED_COUNTRIES does not refuse.
|
// not refuse it.
|
||||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
req.Header.Set(forwardedFor, fromDE)
|
req.Header.Set(forwardedFor, fromDE)
|
||||||
wantStatus(t, do(t, req), http.StatusOK)
|
wantStatus(t, do(t, req), http.StatusOK)
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ import (
|
|||||||
// observe is the value of SWW_MODE for observe mode.
|
// observe is the value of SWW_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()
|
||||||
|
|
||||||
@@ -38,7 +50,7 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.setting != "" {
|
if tc.setting != "" {
|
||||||
@@ -59,7 +71,7 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
|||||||
for _, sent := range []struct{ from, refusal string }{
|
for _, sent := range []struct{ from, refusal string }{
|
||||||
{denied, requestlog.ActionDenied},
|
{denied, requestlog.ActionDenied},
|
||||||
{banned, requestlog.ActionBanned},
|
{banned, requestlog.ActionBanned},
|
||||||
{fromKP, requestlog.ActionCountryDenied},
|
{fromKP, requestlog.ActionDenied},
|
||||||
{fromDE, requestlog.ActionRateLimited},
|
{fromDE, requestlog.ActionRateLimited},
|
||||||
} {
|
} {
|
||||||
if !tc.observe {
|
if !tc.observe {
|
||||||
|
|||||||
@@ -155,11 +155,12 @@ func newHandler(params Params) *handler {
|
|||||||
h.geojs = lookup.New(lookup.Params{
|
h.geojs = lookup.New(lookup.Params{
|
||||||
URL: params.GeoJSURL,
|
URL: params.GeoJSURL,
|
||||||
Timeout: params.Config.LookupTimeout,
|
Timeout: params.Config.LookupTimeout,
|
||||||
// The country lists, the headers and the biased thresholds act on
|
// The country list, the headers, the tag settings that name an asn=
|
||||||
// the answer before the request goes on.
|
// or country= tag and the biased thresholds act on the answer before
|
||||||
Wait: len(params.Config.DeniedCountries) > 0 ||
|
// the request goes on.
|
||||||
len(params.Config.ExclusivelyAllowedCountries) > 0 ||
|
Wait: len(params.Config.ExclusivelyAllowedCountries) > 0 ||
|
||||||
params.Config.AddLookupHeaders || biasedThresholdsSet(params.Config),
|
params.Config.AddLookupHeaders || params.Config.TagsNeedLookup() ||
|
||||||
|
biasedThresholdsSet(params.Config),
|
||||||
Answered: h.addLookup,
|
Answered: h.addLookup,
|
||||||
Now: params.Now,
|
Now: params.Now,
|
||||||
ProcessLog: params.ProcessLog,
|
ProcessLog: params.ProcessLog,
|
||||||
@@ -183,6 +184,7 @@ func newReputation(
|
|||||||
lists := reputation.New(reputation.Params{
|
lists := reputation.New(reputation.Params{
|
||||||
BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh,
|
BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh,
|
||||||
ASNLimitPercentURL: cfg.ASNLimitPercentURL,
|
ASNLimitPercentURL: cfg.ASNLimitPercentURL,
|
||||||
|
TagLists: cfg.TagLists,
|
||||||
CrowdSecDecisionsURL: cfg.CrowdSecDecisionsURL, CrowdSecKey: cfg.CrowdSecKey,
|
CrowdSecDecisionsURL: cfg.CrowdSecDecisionsURL, CrowdSecKey: cfg.CrowdSecKey,
|
||||||
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
|
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ const (
|
|||||||
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||||
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
||||||
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||||
deniedCountries = "SWW_DENIED_COUNTRIES"
|
tagDeny = "SWW_TAG_DENY"
|
||||||
|
// denyKP is the value of SWW_TAG_DENY that refuses fromKP's country.
|
||||||
|
denyKP = "country=kp"
|
||||||
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
banResponse = "SWW_BAN_RESPONSE"
|
banResponse = "SWW_BAN_RESPONSE"
|
||||||
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic rate_limits. It reads SWW_RATE_LIMIT_EXEMPT_NETS,
|
||||||
|
// SWW_RATE_LIMIT_EXEMPT_PATHS and SWW_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 SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
|
||||||
|
// under SWW_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,6 +72,17 @@ 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()
|
||||||
|
|
||||||
@@ -125,7 +136,7 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
rateLimitExemptPaths: "/assets/,/favicon.ico",
|
rateLimitExemptPaths: "/assets/,/favicon.ico",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
// The answers are kept before the requests, so that none waits for
|
// The answers are kept before the requests, so that none waits for
|
||||||
@@ -156,7 +167,7 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
|
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
|
||||||
s.request(fromKP, "/assets/app.js",
|
s.request(fromKP, "/assets/app.js",
|
||||||
http.StatusForbidden, requestlog.ActionCountryDenied)
|
http.StatusForbidden, requestlog.ActionDenied)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
|
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic refusal_limit. It reads SWW_ERROR_BURST_THRESHOLD and
|
||||||
|
// SWW_BAN_RESPONSE.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// refusalLimit counts the request once it has been answered, if discriminator
|
||||||
|
// 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
|
||||||
|
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
|
||||||
|
// a limit, which bans the client. A client in SWW_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)}
|
||||||
|
}
|
||||||
+20
-1
@@ -106,6 +106,25 @@ 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()
|
||||||
|
|
||||||
@@ -172,7 +191,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
errorBurstThreshold: "2",
|
errorBurstThreshold: "2",
|
||||||
rulesDir: writeRules(t, testRules),
|
rulesDir: writeRules(t, testRules),
|
||||||
countryLimitPercent: countryDEHalf,
|
tagLimitPercent: countryTagDEHalf,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Half of the threshold would be one, which the second refusal is over.
|
// Half of the threshold would be one, which the second refusal is over.
|
||||||
@@ -1,91 +1,17 @@
|
|||||||
package discriminator
|
package discriminator
|
||||||
|
|
||||||
import (
|
// What the reputation heuristics share: blocklists, crowdsec, dnsbl and
|
||||||
"context"
|
// abuseipdb.
|
||||||
"time"
|
|
||||||
|
|
||||||
|
import (
|
||||||
"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 SWW_BLOCKLIST_ACTION and the SWW_REPUTATION_ACTION that
|
// deny is the SWW_BLOCKLIST_ACTION and the SWW_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 SWW_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 SWW_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
|
|
||||||
// SWW_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
|
|
||||||
// does for a zone. While SWW_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 SWW_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,11 +118,61 @@ 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, *discriminator.Server)
|
||||||
|
figures string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"blocklists",
|
||||||
|
map[string]string{blocklistURLs: dropURL, blocklistAction: actionDeny},
|
||||||
|
func(t *testing.T, server *discriminator.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 *discriminator.Server) {
|
||||||
|
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
|
||||||
|
},
|
||||||
|
`{"zones": ["` + dnsblZone + `"]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"abuseipdb",
|
||||||
|
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
|
||||||
|
func(_ *testing.T, server *discriminator.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()
|
||||||
|
|
||||||
s, server, queue := startWithLookups(t, map[string]string{
|
s, server, queue := startWithLookups(t, map[string]string{
|
||||||
blocklistURLs: dropURL, deniedCountries: "kp", allowNets: fromDE,
|
blocklistURLs: dropURL, allowedCountries: "de", allowNets: fromDE,
|
||||||
})
|
})
|
||||||
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
|
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
|
||||||
|
|
||||||
@@ -161,18 +211,18 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
|
|||||||
// percentText gives them, and limitHit its limit_hit.
|
// percentText gives them, and limitHit its limit_hit.
|
||||||
want, limitHit string
|
want, limitHit string
|
||||||
}{
|
}{
|
||||||
{limitHalf, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
|
{limitHalf, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
|
||||||
{limitQuarter, asnDEHalf, "25 from " + blocklistAction, minuteBytes},
|
{limitQuarter, asnTagDEHalf, "25 from " + blocklistAction, minuteBytes},
|
||||||
// The AS number's, the first of two alike.
|
// The AS number's, the first of two alike.
|
||||||
{limitQuarter, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
|
{limitQuarter, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
|
||||||
{actionLog, asnDE + ":100", none, ""},
|
{actionLog, "asn=64511:25", none, ""},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) {
|
t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL,
|
bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL,
|
||||||
blocklistAction: tc.action, asnLimitPercent: tc.asnPercent,
|
blocklistAction: tc.action, tagLimitPercent: tc.asnPercent,
|
||||||
})
|
})
|
||||||
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
||||||
|
|
||||||
@@ -196,7 +246,7 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
fromURL = "25 from " + asnLimitPercentURL
|
fromURL = "25 from " + asnLimitPercentURL
|
||||||
fromSetting = "25 from " + asnLimitPercent
|
fromSetting = "25 from " + tagLimitPercent
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -210,16 +260,16 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
|
|||||||
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
|
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
|
||||||
{
|
{
|
||||||
"the file's, lower than the setting's",
|
"the file's, lower than the setting's",
|
||||||
map[string]string{asnLimitPercent: asnDEHalf}, asnDEQuarter, fromURL, fromURL,
|
map[string]string{tagLimitPercent: asnTagDEHalf}, asnDEQuarter, fromURL, fromURL,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the setting's, lower than the file's",
|
"the setting's, lower than the file's",
|
||||||
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEHalf,
|
map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEHalf,
|
||||||
fromSetting, fromSetting,
|
fromSetting, fromSetting,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the setting's, the first of two alike",
|
"the setting's, the first of two alike",
|
||||||
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEQuarter,
|
map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEQuarter,
|
||||||
fromSetting, fromSetting,
|
fromSetting, fromSetting,
|
||||||
},
|
},
|
||||||
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
|
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
|
||||||
@@ -432,6 +482,46 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env map[string]string
|
||||||
|
load func(*discriminator.Server)
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"dnsbl",
|
||||||
|
map[string]string{
|
||||||
|
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionLog,
|
||||||
|
},
|
||||||
|
func(server *discriminator.Server) {
|
||||||
|
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
||||||
|
},
|
||||||
|
[]string{"asn=64496", "country=de", "dnsbl=" + dnsblZone},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
abuseipdb,
|
||||||
|
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionLog},
|
||||||
|
func(server *discriminator.Server) {
|
||||||
|
loadScores(server, map[string]int64{fromDE: 80})
|
||||||
|
},
|
||||||
|
[]string{abuseipdb, "asn=64496", "country=de"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, tc.env)
|
||||||
|
tc.load(server)
|
||||||
|
|
||||||
|
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
tc.want...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
|
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,17 @@ func (h *handler) startRequest(
|
|||||||
return rq
|
return rq
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addTag adds tag, in lower case, to the request's tags, which stay sorted and
|
||||||
|
// hold each tag once.
|
||||||
|
func (rq *request) addTag(tag string) {
|
||||||
|
tag = strings.ToLower(tag)
|
||||||
|
|
||||||
|
at, found := slices.BinarySearch(rq.line.Tags, tag)
|
||||||
|
if !found {
|
||||||
|
rq.line.Tags = slices.Insert(rq.line.Tags, at, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// requestHeaders returns the headers of r that names lists, by name in
|
// requestHeaders returns the headers of r that names lists, by name in
|
||||||
// lower case, each with its values joined by ", ". Authorization, Cookie
|
// lower case, each with its values joined by ", ". Authorization, Cookie
|
||||||
// and Set-Cookie are never among them, whatever names says.
|
// and Set-Cookie are never among them, whatever names says.
|
||||||
@@ -160,16 +171,20 @@ 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 checks of checkClient decide: a request they refuse
|
// the request through. The heuristics of checkClient decide: a request they
|
||||||
// is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core Rule Set,
|
// refuse is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core
|
||||||
// or 431 for the header limits. In observe mode nothing is refused, and the log
|
// Rule Set, or 431 for the header limits. In observe mode nothing is refused,
|
||||||
// line names what enforce mode would have done. ctx is the request's own context.
|
// and the log line names what enforce mode would have done. ctx is the
|
||||||
|
// request's own context.
|
||||||
func (rq *request) check(ctx context.Context) *refusal {
|
func (rq *request) check(ctx context.Context) *refusal {
|
||||||
action := rq.checkClient(ctx)
|
v := 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
|
||||||
|
|
||||||
@@ -183,35 +198,21 @@ func (rq *request) check(ctx context.Context) *refusal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkClient runs the checks on the request's client, and returns the
|
// checkClient runs the heuristics of beforeLookup on the request, then looks
|
||||||
// action of the first that refuses the request, or "" when none does. A
|
// up the client's AS number and country, then runs those of afterLookup. It
|
||||||
// client in SWW_ALLOW_NETS skips them, and is not looked up. For any
|
// returns the first verdict, or nil when none gives one. A client in
|
||||||
// other client, SWW_DENY_NETS comes first, then a ban on its netblock,
|
// SWW_ALLOW_NETS skips them all, and is not looked up. ctx is the request's
|
||||||
// so that a client either refuses is not looked up, then the lookup of
|
// own context.
|
||||||
// its AS number and country, then the country lists, then the blocklists,
|
func (rq *request) checkClient(ctx context.Context) *verdict {
|
||||||
// then the CrowdSec decision list, which bans the client it lists, then
|
if isInside(rq.client, rq.h.config.AllowNets) {
|
||||||
// the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of
|
return nil
|
||||||
// them refuses is not counted for the rate limits. Then come the
|
|
||||||
// rate limits, unless the client is in SWW_RATE_LIMIT_EXEMPT_NETS or the
|
|
||||||
// request's path is exempt under SWW_RATE_LIMIT_EXEMPT_PATHS, so that
|
|
||||||
// every other request is counted, each of them by the client's limit
|
|
||||||
// percentages, then SWW_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()
|
||||||
|
|
||||||
if isInside(rq.client, cfg.DenyNets) {
|
v := rq.runHeuristics(ctx, beforeLookup(), now)
|
||||||
return requestlog.ActionDenied
|
if v != nil {
|
||||||
}
|
return v
|
||||||
|
|
||||||
if rq.banned(now) {
|
|
||||||
return requestlog.ActionBanned
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.lookUp(ctx, now)
|
rq.lookUp(ctx, now)
|
||||||
@@ -220,59 +221,7 @@ func (rq *request) checkClient(ctx context.Context) string {
|
|||||||
// 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.
|
||||||
now = rq.h.now()
|
return rq.runHeuristics(ctx, afterLookup(), 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 SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
|
|
||||||
// under SWW_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,6 +1,7 @@
|
|||||||
package discriminator_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -272,6 +273,48 @@ 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))
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic rule_files. It reads the rules of the rule files, which
|
||||||
|
// SWW_RULES_DIR and SWW_RULES_ENABLED name.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"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, adds the tag
|
||||||
|
// exploitsignature when it matches any, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.addTag(config.ExploitSignatureTag)
|
||||||
|
|
||||||
|
// 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,6 +93,35 @@ func TestEachRuleAction(t *testing.T) {
|
|||||||
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
|
||||||
|
})
|
||||||
|
|
||||||
|
wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward),
|
||||||
|
"exploitsignature")
|
||||||
|
wantTags(t, s.request(client, "/blocked", http.StatusForbidden,
|
||||||
|
requestlog.ActionRuleBlocked), "exploitsignature")
|
||||||
|
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
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
|
|
||||||
// SWW_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 ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,7 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
|
|||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
allowNets: "198.51.100.0/24",
|
allowNets: "198.51.100.0/24",
|
||||||
denyNets: fromKP,
|
denyNets: fromKP,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
|
|||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
denyNets: "203.0.113.0/24",
|
denyNets: "203.0.113.0/24",
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
||||||
@@ -104,6 +104,15 @@ 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()
|
||||||
|
|
||||||
@@ -133,7 +142,7 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitExemptNets: listedAddr + "," + fromKP,
|
rateLimitExemptNets: listedAddr + "," + fromKP,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -145,7 +154,7 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
||||||
{fromKP, http.StatusForbidden, requestlog.ActionCountryDenied},
|
{fromKP, http.StatusForbidden, requestlog.ActionDenied},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic tag_deny. It reads SWW_TAG_DENY.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagDeny refuses a request that carries a tag in SWW_TAG_DENY. Its figure is
|
||||||
|
// the first such tag, in sorted order.
|
||||||
|
type tagDeny struct{}
|
||||||
|
|
||||||
|
func (tagDeny) name() string { return "tag_deny" }
|
||||||
|
|
||||||
|
func (tagDeny) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
for _, tag := range rq.line.Tags {
|
||||||
|
if slices.Contains(rq.h.config.TagDeny, tag) {
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionDenied,
|
||||||
|
figures: map[string]any{"tag": tag},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package discriminator_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=KP"})
|
||||||
|
|
||||||
|
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
|
wantHeuristic(t, line, "tag_deny", `{"tag": "country=kp"}`)
|
||||||
|
wantTags(t, line, "asn=64511", "country=kp")
|
||||||
|
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagDenyRefusesAnASNTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"})
|
||||||
|
|
||||||
|
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
|
||||||
|
"tag_deny", `{"tag": "asn=64511"}`)
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagDenyRefusesAListTagBeforeTheRateLimitsCount(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
tagLists: "torexit=" + torList, tagDeny: "torexit", rateLimitPerMinute: "1",
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{torList: {fromDE}})
|
||||||
|
|
||||||
|
// Each is refused for its tag: none is counted to break the limit of one.
|
||||||
|
for range 3 {
|
||||||
|
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionDenied),
|
||||||
|
"tag_deny", `{"tag": "torexit"}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature",
|
||||||
|
})
|
||||||
|
|
||||||
|
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
|
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
||||||
|
wantTags(t, line, "exploitsignature")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
rateLimitPerMinute: fourAMinute,
|
||||||
|
tagLists: "vpn=" + vpnList + ",tor=" + torList,
|
||||||
|
tagLimitPercent: "vpn:50,tor:25",
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{vpnList: {fromDE}, torList: {fromDE}})
|
||||||
|
|
||||||
|
// A quarter of 4 requests a minute: the second breaks the limit.
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
|
"25 from "+tagLimitPercent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic tag_lists. It reads the address lists of SWW_TAG_LISTS through
|
||||||
|
// the reputation lists.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagLists adds to the request one tag for each list of SWW_TAG_LISTS that
|
||||||
|
// holds the client, the tag being the list's name. It refuses nothing, and runs
|
||||||
|
// first so that every refusal's log line carries these tags.
|
||||||
|
type tagLists struct{}
|
||||||
|
|
||||||
|
func (tagLists) name() string { return "tag_lists" }
|
||||||
|
|
||||||
|
func (tagLists) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
for _, name := range rq.h.lists.TagsOf(rq.client) {
|
||||||
|
rq.addTag(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package discriminator_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagLists is SWW_TAG_LISTS, and vpnList and torList lists it names.
|
||||||
|
const (
|
||||||
|
tagLists = "SWW_TAG_LISTS"
|
||||||
|
vpnList = "https://lists.example/vpn.txt"
|
||||||
|
torList = "https://lists.example/torexit.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wantTags checks the log line's tags.
|
||||||
|
func wantTags(t *testing.T, line logLine, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got, _ := line.fields["tags"].([]any)
|
||||||
|
|
||||||
|
tags := make([]string, 0, len(got))
|
||||||
|
for _, tag := range got {
|
||||||
|
text, _ := tag.(string)
|
||||||
|
tags = append(tags, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(tags, want) {
|
||||||
|
t.Errorf("log line has tags %v, want %v", tags, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupAndTagListsGiveTheRequestItsTagsSorted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
tagLists: "vpn=mullvad=" + vpnList + ",TorExit=" + torList,
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{
|
||||||
|
vpnList: {fromDE}, torList: {fromDE + "/32", fromKP},
|
||||||
|
})
|
||||||
|
|
||||||
|
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
"asn=64496", "country=de", "torexit", "vpn=mullvad")
|
||||||
|
wantTags(t, s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
||||||
|
"asn=64511", "country=kp", "torexit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestWithoutATagLeavesTheFieldOut(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// With no lookup, a client is not placed and no setting gives it a tag.
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{lookupSource: "off"})
|
||||||
|
|
||||||
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
if _, present := line.fields["tags"]; present {
|
||||||
|
t.Errorf("log line has tags %v, want none", line.fields["tags"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagListsTagEvenARequestTheCountryListRefuses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
tagLists: "vpn=" + vpnList, allowedCountries: "de",
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{vpnList: {fromKP}})
|
||||||
|
|
||||||
|
wantTags(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
||||||
|
"asn=64511", "country=kp", "vpn")
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic trap_paths. It reads SWW_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 SWW_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,6 +83,16 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type busiest struct {
|
|||||||
requests *prometheus.CounterVec
|
requests *prometheus.CounterVec
|
||||||
requestBytes *prometheus.CounterVec
|
requestBytes *prometheus.CounterVec
|
||||||
responseBytes *prometheus.CounterVec
|
responseBytes *prometheus.CounterVec
|
||||||
// refused are, by country, the requests the country lists refused; nil
|
// refused are, by country, the requests the country list refused; nil
|
||||||
// by AS number.
|
// by AS number.
|
||||||
refused *prometheus.CounterVec
|
refused *prometheus.CounterVec
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ type busiest struct {
|
|||||||
func newCountries(topN int) *busiest {
|
func newCountries(topN int) *busiest {
|
||||||
countries := newBusiest(topN, "country", "the client's country")
|
countries := newBusiest(topN, "country", "the client's country")
|
||||||
countries.refused = counterVec("sww_country_list_refusals_total",
|
countries.refused = counterVec("sww_country_list_refusals_total",
|
||||||
"Requests the country lists refused, by the client's country.",
|
"Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
|
||||||
[]string{"country"})
|
[]string{"country"})
|
||||||
|
|
||||||
return countries
|
return countries
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Package reputation fetches the lists the settings name by URL: the
|
// Package reputation fetches the lists the settings name by URL: the
|
||||||
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
|
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
|
||||||
// SWW_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
|
// SWW_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_TAG_LISTS, and
|
||||||
// engine SWW_CROWDSEC_LAPI_URL names. It keeps the last good copy of
|
// the decision list of the CrowdSec engine SWW_CROWDSEC_LAPI_URL names. It
|
||||||
|
// keeps the last good copy of
|
||||||
// each, whole, comment lines included, which is used while a fetch fails,
|
// each, whole, comment lines included, which is used while a fetch fails,
|
||||||
// and when each was last tried. It also asks the DNSBL zones of
|
// and when each was last tried. It also asks the DNSBL zones of
|
||||||
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
||||||
@@ -71,6 +72,8 @@ type Params struct {
|
|||||||
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
||||||
BlocklistURLs []string
|
BlocklistURLs []string
|
||||||
ASNLimitPercentURL string
|
ASNLimitPercentURL string
|
||||||
|
// TagLists are the address lists that add a tag (SWW_TAG_LISTS).
|
||||||
|
TagLists []config.TagList
|
||||||
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
||||||
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
||||||
// (SWW_CROWDSEC_LAPI_KEY).
|
// (SWW_CROWDSEC_LAPI_KEY).
|
||||||
@@ -152,14 +155,18 @@ func New(params Params) *Lists {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// URLs returns the URL of every list: the blocklists' in the order
|
// URLs returns the URL of every list: the blocklists' in the order
|
||||||
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then
|
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then the
|
||||||
// the CrowdSec decision list's.
|
// tag lists', then the CrowdSec decision list's.
|
||||||
func (l *Lists) URLs() []string {
|
func (l *Lists) URLs() []string {
|
||||||
urls := slices.Clone(l.params.BlocklistURLs)
|
urls := slices.Clone(l.params.BlocklistURLs)
|
||||||
if l.params.ASNLimitPercentURL != "" {
|
if l.params.ASNLimitPercentURL != "" {
|
||||||
urls = append(urls, l.params.ASNLimitPercentURL)
|
urls = append(urls, l.params.ASNLimitPercentURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, list := range l.params.TagLists {
|
||||||
|
urls = append(urls, list.URL)
|
||||||
|
}
|
||||||
|
|
||||||
if l.params.CrowdSecDecisionsURL != "" {
|
if l.params.CrowdSecDecisionsURL != "" {
|
||||||
urls = append(urls, l.params.CrowdSecDecisionsURL)
|
urls = append(urls, l.params.CrowdSecDecisionsURL)
|
||||||
}
|
}
|
||||||
@@ -184,6 +191,22 @@ func (l *Lists) ListedBy(addr netip.Addr) []string {
|
|||||||
return listedBy
|
return listedBy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TagsOf returns the names of the tag lists whose copy holds addr.
|
||||||
|
func (l *Lists) TagsOf(addr netip.Addr) []string {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
var names []string
|
||||||
|
|
||||||
|
for _, list := range l.params.TagLists {
|
||||||
|
if l.lists[list.URL].entries.contain(addr) {
|
||||||
|
names = append(names, list.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
// ASNLimitPercent returns the percentage the copy of the file of
|
// ASNLimitPercent returns the percentage the copy of the file of
|
||||||
// AS:percent lines gives asn, and whether it lists asn.
|
// AS:percent lines gives asn, and whether it lists asn.
|
||||||
func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
|
func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
|
||||||
@@ -551,7 +574,7 @@ func parseNetblock(text string) (netip.Prefix, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parsePercents reads the lines of the file of AS:percent lines, each an
|
// parsePercents reads the lines of the file of AS:percent lines, each an
|
||||||
// AS number, : and a percentage, as SWW_ASN_LIMIT_PERCENT takes them. An
|
// AS number, : and a percentage, such as AS64496:50. An
|
||||||
// AS number listed more than once gets the lowest of its percentages.
|
// AS number listed more than once gets the lowest of its percentages.
|
||||||
func parsePercents(lines []string) (entries, error) {
|
func parsePercents(lines []string) (entries, error) {
|
||||||
found := entries{percents: map[string]int64{}}
|
found := entries{percents: map[string]int64{}}
|
||||||
|
|||||||
@@ -38,9 +38,10 @@ const (
|
|||||||
// ActionDenied is a request refused because its client is in
|
// ActionDenied is a request refused because its client is in
|
||||||
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
|
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
|
||||||
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
||||||
// SWW_REPUTATION_ACTION is deny.
|
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
|
||||||
ActionDenied = "denied"
|
ActionDenied = "denied"
|
||||||
// ActionCountryDenied is a request refused for its client's country.
|
// ActionCountryDenied is a request refused because its client's country
|
||||||
|
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||||
ActionCountryDenied = "country_denied"
|
ActionCountryDenied = "country_denied"
|
||||||
// ActionAdmin is a request discriminator answered at one of its own
|
// ActionAdmin is a request discriminator answered at one of its own
|
||||||
// endpoints, or a question about a path under /_sww/.
|
// endpoints, or a question about a path under /_sww/.
|
||||||
@@ -131,6 +132,16 @@ type Line struct {
|
|||||||
// ActionRuleBlocked, ActionWAFBlocked, or, in discriminator, ActionTooLarge
|
// ActionRuleBlocked, ActionWAFBlocked, or, in discriminator, 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 discriminator 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
|
||||||
@@ -162,6 +173,8 @@ type Line struct {
|
|||||||
// whose verdict lists it, their keys masked, then abuseipdb when its
|
// whose verdict lists it, their keys masked, then abuseipdb when its
|
||||||
// score is a hit.
|
// score is a hit.
|
||||||
Reputation []string `json:"reputation,omitempty"`
|
Reputation []string `json:"reputation,omitempty"`
|
||||||
|
// Tags are the request's tags, sorted.
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
// Offence is the offence the request was held as, OffenceLimit.
|
// Offence is the offence the request was held as, OffenceLimit.
|
||||||
Offence string `json:"offence,omitempty"`
|
Offence string `json:"offence,omitempty"`
|
||||||
// BanExpires is when the ban the request made, or was refused under,
|
// BanExpires is when the ban the request made, or was refused under,
|
||||||
|
|||||||
@@ -618,7 +618,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
|
|||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
lookupSource: "file",
|
lookupSource: "file",
|
||||||
lookupDBPath: path,
|
lookupDBPath: path,
|
||||||
"SWW_DENIED_COUNTRIES": "kp",
|
"SWW_TAG_DENY": "country=kp",
|
||||||
// The requests sent until a replacement takes effect, and those
|
// The requests sent until a replacement takes effect, and those
|
||||||
// for the metrics, must not break a rate limit, whose ban would
|
// for the metrics, must not break a rate limit, whose ban would
|
||||||
// refuse them too.
|
// refuse them too.
|
||||||
@@ -1280,7 +1280,7 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
|
|||||||
"SWW_RATE_LIMIT_PER_HOUR": "1500",
|
"SWW_RATE_LIMIT_PER_HOUR": "1500",
|
||||||
rateLimitPerDay: "6000",
|
rateLimitPerDay: "6000",
|
||||||
"SWW_RATE_LIMIT_EXEMPT_PATHS": "",
|
"SWW_RATE_LIMIT_EXEMPT_PATHS": "",
|
||||||
"SWW_DENIED_COUNTRIES": "",
|
"SWW_TAG_DENY": "",
|
||||||
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
||||||
"SWW_BAN_RESPONSE": "403",
|
"SWW_BAN_RESPONSE": "403",
|
||||||
"SWW_LIMIT_BAN_DURATION": "1h",
|
"SWW_LIMIT_BAN_DURATION": "1h",
|
||||||
|
|||||||
Reference in new issue
Block a user