Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8378f4b52c |
@@ -167,6 +167,10 @@ RUN groupadd --system --gid 65532 smallwebwaf \
|
|||||||
# smallwebwaf user at each start.
|
# smallwebwaf user at each start.
|
||||||
RUN mkdir /var/lib/smallwebwaf
|
RUN mkdir /var/lib/smallwebwaf
|
||||||
|
|
||||||
|
# The default rule file, in SWWAF_RULES_DIR by default, where an app's
|
||||||
|
# Dockerfile can copy rule files of its own beside it.
|
||||||
|
COPY share/rules.d/00-default.rules /etc/smallwebwaf/rules.d/00-default.rules
|
||||||
|
|
||||||
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
|
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
|
||||||
# looks too.
|
# looks too.
|
||||||
COPY --chmod=755 share/smallwebwaf.run /etc/service/smallwebwaf/run
|
COPY --chmod=755 share/smallwebwaf.run /etc/service/smallwebwaf/run
|
||||||
|
|||||||
@@ -19,13 +19,16 @@ JSON state files with your edits taken in while it runs and the paths the rate
|
|||||||
limits do not count, which come next in the build order, `observe` mode and the
|
limits do not count, which come next in the build order, `observe` mode and the
|
||||||
rest of the request log's fields, which come a little later, and the metrics
|
rest of the request log's fields, which come a little later, and the metrics
|
||||||
endpoint and the header size and the idle time as settings, which come last in
|
endpoint and the header size and the idle time as settings, which come last in
|
||||||
it. `smallwebwaf` passes each request to the app and the app's answer back,
|
it. So are the rule files, the first part of the stage after it, with the bans
|
||||||
unchanged, within its timeouts and size limits, works out each client's address,
|
for a clear sign of attack. `smallwebwaf` passes each request to the app and the
|
||||||
bans a client that sends too many requests, not counting those for the paths you
|
app's answer back, unchanged, within its timeouts and size limits, works out
|
||||||
choose, refuses a client that comes from a country you refuse or from a network
|
each client's address, bans a client that sends too many requests, not counting
|
||||||
you refuse, lets the networks you choose through, keeps its bans, each client's
|
those for the paths you choose, refuses a client that comes from a country you
|
||||||
counters and history, and GeoJS's answers in JSON files across restarts, takes
|
refuse or from a network you refuse, lets the networks you choose through,
|
||||||
in your edits of those files while it runs, writes a JSON log line for every
|
checks each request against the rule files and bans a client whose request is a
|
||||||
|
clear sign of attack, keeps its bans, each client's counters and history, and
|
||||||
|
GeoJS's answers in JSON files across restarts, takes in your edits of those
|
||||||
|
files and of the rule files while it runs, writes a JSON log line for every
|
||||||
request, serves Prometheus metrics to a scraper that holds the metrics token,
|
request, serves Prometheus metrics to a scraper that holds the metrics token,
|
||||||
and in `observe` mode passes on the requests it would refuse, logging what it
|
and in `observe` mode passes on the requests it would refuse, logging what it
|
||||||
would have done with them. It comes as the image the app's own image is built
|
would have done with them. It comes as the image the app's own image is built
|
||||||
@@ -54,7 +57,8 @@ works.
|
|||||||
To work on the code, `make build` builds the binary alone, with Go installed,
|
To work on the code, `make build` builds the binary alone, with Go installed,
|
||||||
and `make run` builds and runs it, listening on port 8080 in front of an app at
|
and `make run` builds and runs it, listening on port 8080 in front of an app at
|
||||||
`SWWAF_UPSTREAM_URL`, by default `http://127.0.0.1:8081`, with its state files
|
`SWWAF_UPSTREAM_URL`, by default `http://127.0.0.1:8081`, with its state files
|
||||||
in `bin/state` unless `SWWAF_STATE_DIR` is set.
|
in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
|
||||||
|
`share/rules.d` unless `SWWAF_RULES_DIR` is set.
|
||||||
|
|
||||||
## What it does so far
|
## What it does so far
|
||||||
|
|
||||||
@@ -86,13 +90,13 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set.
|
|||||||
`SWWAF_BAN_RESPONSE`, `403` by default, before anything reaches the app, and
|
`SWWAF_BAN_RESPONSE`, `403` by default, before anything reaches the app, and
|
||||||
bans the client. A request whose path starts with one of
|
bans the client. A request whose path starts with one of
|
||||||
`SWWAF_RATE_LIMIT_EXEMPT_PATHS`, as that setting below describes, is neither
|
`SWWAF_RATE_LIMIT_EXEMPT_PATHS`, as that setting below describes, is neither
|
||||||
counted nor refused by the rate limits; the static lists, bans and the country
|
counted nor refused by the rate limits; the static lists, bans, the country
|
||||||
lists still apply to it. A client is one IPv4 address, or one IPv6 /64, since
|
lists and the rule files still apply to it. A client is one IPv4 address, or
|
||||||
one abuser usually holds a whole /64. Each window is counted in two fixed
|
one IPv6 /64, since one abuser usually holds a whole /64. Each window is
|
||||||
buckets, the earlier one weighted by how much of it the window still covers.
|
counted in two fixed buckets, the earlier one weighted by how much of it the
|
||||||
At most 20,000 clients are kept, the least recently seen dropped first, with
|
window still covers. At most 20,000 clients are kept, the least recently seen
|
||||||
their history, and a restart gives no client a fresh allowance (see "State
|
dropped first, with their history, and a restart gives no client a fresh
|
||||||
files" below).
|
allowance (see "State files" below).
|
||||||
- Bans a client that breaks a rate limit, as "Bans" in [`SPEC.md`](SPEC.md)
|
- Bans a client that breaks a rate limit, as "Bans" in [`SPEC.md`](SPEC.md)
|
||||||
describes: the first ban lasts an hour, and a limit broken again within a day
|
describes: the first ban lasts an hour, and a limit broken again within a day
|
||||||
of a ban ending bans for three times as long as that ban, so 1, 3, 9, 27 and
|
of a ban ending bans for three times as long as that ban, so 1, 3, 9, 27 and
|
||||||
@@ -106,11 +110,26 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set.
|
|||||||
window and the requests counted in it, the request that broke it, the client's
|
window and the requests counted in it, the request that broke it, the client's
|
||||||
country when it was looked up, the netblock's requests since it was first
|
country when it was looked up, the netblock's requests since it was first
|
||||||
seen, how many of them the ban has refused, and how many bans the netblock had
|
seen, how many of them the ban has refused, and how many bans the netblock had
|
||||||
before. At most `SWWAF_MAX_BANS` bans are kept, past, active and permanent;
|
before, for a broken limit, for a clear sign of attack and without a cause. At
|
||||||
past that, the earliest ban of the netblock that has gone longest without a
|
most `SWWAF_MAX_BANS` bans are kept, past, active and permanent; past that,
|
||||||
request is dropped first. `bans.json` shows the bans and their notes, a
|
the earliest ban of the netblock that has gone longest without a request is
|
||||||
restart lifts none, and you add or lift a ban by editing it (see "State files"
|
dropped first. `bans.json` shows the bans and their notes, a restart lifts
|
||||||
below).
|
none, and you add or lift a ban by editing it (see "State files" below).
|
||||||
|
- Checks each request against the rules of the rule files (see "Rule files"
|
||||||
|
below) after the rate limits, and before its body is read. A `log` rule that
|
||||||
|
matches is noted in the log line; a `block` rule refuses the request with
|
||||||
|
`403`, and bans no one; a `ban` rule refuses it with `SWWAF_BAN_RESPONSE` and
|
||||||
|
bans the client's netblock for a clear sign of attack. Matching stops at the
|
||||||
|
first rule that refuses. A client in `SWWAF_ALLOW_NETS` is not checked.
|
||||||
|
- Bans a client for a clear sign of attack, as "Bans" in [`SPEC.md`](SPEC.md)
|
||||||
|
describes: the first such ban lasts `SWWAF_ATTACK_BAN_DURATION`, seven days by
|
||||||
|
default, and any request from the netblock while it lasts makes it permanent.
|
||||||
|
Once it has run out, the netblock is served like any other, but its next clear
|
||||||
|
sign of attack bans it permanently at once. Such a ban covers the same
|
||||||
|
netblock as a ban for a broken rate limit, does not set the client's counters
|
||||||
|
back to zero, and does not make the netblock's next ban for a broken limit
|
||||||
|
longer. Its notes give the id and the target of the rule that matched in place
|
||||||
|
of the limit.
|
||||||
- Refuses a request from a country you refuse with `SWWAF_BAN_RESPONSE`, as soon
|
- Refuses a request from a country you refuse with `SWWAF_BAN_RESPONSE`, as soon
|
||||||
as the client's country is known and before its body is read; such a request
|
as the client's country is known and before its body is read; such a request
|
||||||
is not counted for the rate limits. While one of the country lists below is
|
is not counted for the rate limits. While one of the country lists below is
|
||||||
@@ -121,25 +140,26 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set.
|
|||||||
in `SWWAF_ALLOW_NETS`, and `SWWAF_DENIED_COUNTRIES` does not refuse it.
|
in `SWWAF_ALLOW_NETS`, and `SWWAF_DENIED_COUNTRIES` does not refuse it.
|
||||||
- Checks the client's own address against the static lists, the three netblock
|
- Checks the client's own address against the static lists, the three netblock
|
||||||
settings below, before anything else, its country included. A client in
|
settings below, before anything else, its country included. A client in
|
||||||
`SWWAF_ALLOW_NETS` skips bans, the country lists and the rate limits, and is
|
`SWWAF_ALLOW_NETS` skips bans, the country lists, the rate limits and the rule
|
||||||
not looked up; the timeouts and size limits still apply. A client in
|
files, and is not looked up; the timeouts and size limits still apply. A
|
||||||
`SWWAF_DENY_NETS` is refused with `SWWAF_BAN_RESPONSE` before its body is
|
client in `SWWAF_DENY_NETS` is refused with `SWWAF_BAN_RESPONSE` before its
|
||||||
read, and the request is not counted for the rate limits; an address in
|
body is read, and the request is not counted for the rate limits; an address
|
||||||
`SWWAF_ALLOW_NETS` too is let through. A client in
|
in `SWWAF_ALLOW_NETS` too is let through. A client in
|
||||||
`SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor refused by the rate
|
`SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor refused by the rate
|
||||||
limits; the country lists and bans still apply to it.
|
limits; the country lists, the rule files and bans still apply to it.
|
||||||
- In `observe` mode, with `SWWAF_MODE=observe`, refuses none of the requests
|
- In `observe` mode, with `SWWAF_MODE=observe`, refuses none of the requests
|
||||||
that `SWWAF_DENY_NETS`, a ban, the country lists or a rate limit would refuse:
|
that `SWWAF_DENY_NETS`, a ban, the country lists, a rate limit or a rule would
|
||||||
it passes them to the app, and their log lines name what `enforce` mode would
|
refuse: it passes them to the app, and their log lines name what `enforce`
|
||||||
have done (see `would_action` in "Request log" below). The checks run, and
|
mode would have done (see `would_action` in "Request log" below). The checks
|
||||||
requests are counted, as in `enforce` mode, but a broken rate limit makes no
|
run, and requests are counted, as in `enforce` mode, with three differences:
|
||||||
ban and does not set the client's counters back to zero, so each request over
|
neither a broken rate limit nor a `ban` rule makes a ban; a broken rate limit
|
||||||
the limit is logged as one that would be refused. The bans in `bans.json` are
|
does not set the client's counters back to zero, so each request over the
|
||||||
kept, and refuse requests again when `smallwebwaf` next runs in `enforce`
|
limit is logged as one that would be refused; and a request under a ban does
|
||||||
mode, as long as they last. The timeouts and size limits still apply, since
|
not make it permanent. The bans in `bans.json` are kept, and refuse requests
|
||||||
they protect `smallwebwaf` and the app themselves, and a request for the
|
again when `smallwebwaf` next runs in `enforce` mode, as long as they last.
|
||||||
metrics without the token is still answered `401`. It is for trying a
|
The timeouts and size limits still apply, since they protect `smallwebwaf` and
|
||||||
configuration before enforcing it.
|
the app themselves, and a request for the metrics without the token is still
|
||||||
|
answered `401`. It is for trying a configuration before enforcing it.
|
||||||
- Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any
|
- Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any
|
||||||
check and without asking the app, for the image's health check.
|
check and without asking the app, for the image's health check.
|
||||||
- Answers `GET /_smallwebwaf/metrics` with its metrics (see "Metrics" below) for
|
- Answers `GET /_smallwebwaf/metrics` with its metrics (see "Metrics" below) for
|
||||||
@@ -194,8 +214,8 @@ it, and the effective settings are logged at start.
|
|||||||
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body.
|
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body.
|
||||||
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body.
|
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body.
|
||||||
- `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip bans, the
|
- `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip bans, the
|
||||||
country lists and the rate limits, such as your monitoring or your own
|
country lists, the rate limits and the rule files, such as your monitoring or
|
||||||
networks.
|
your own networks.
|
||||||
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients the
|
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients the
|
||||||
rate limits do not apply to, such as a machine that talks to the app all day.
|
rate limits do not apply to, such as a machine that talks to the app all day.
|
||||||
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are always refused.
|
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are always refused.
|
||||||
@@ -225,16 +245,20 @@ it, and the effective settings are logged at start.
|
|||||||
country cannot be found is refused too, so that new clients are not let in
|
country cannot be found is refused too, so that new clients are not let in
|
||||||
whenever GeoJS stops answering.
|
whenever GeoJS stops answering.
|
||||||
- `SWWAF_BAN_RESPONSE` (default `403`): how a refused client is answered, one
|
- `SWWAF_BAN_RESPONSE` (default `403`): how a refused client is answered, one
|
||||||
that is banned, breaks a rate limit, is in `SWWAF_DENY_NETS` or comes from a
|
that is banned, breaks a rate limit, matches a `ban` rule, is in
|
||||||
refused country: `403`, `429`, or `close` to close the connection without an
|
`SWWAF_DENY_NETS` or comes from a refused country: `403`, `429`, or `close` to
|
||||||
answer. Behind traefik, `close` does not leave the client unanswered: traefik
|
close the connection without an answer. Behind traefik, `close` does not leave
|
||||||
answers `502`, as it does whenever its backend drops a connection.
|
the client unanswered: traefik answers `502`, as it does whenever its backend
|
||||||
|
drops a connection. A `block` rule always answers `403`.
|
||||||
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken rate
|
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken rate
|
||||||
limit.
|
limit.
|
||||||
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): a rate limit broken again
|
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): a rate limit broken again
|
||||||
within this time after a ban ended bans for three times as long as that ban.
|
within this time after a ban ended, other than one for a clear sign of attack,
|
||||||
- `SWWAF_MAX_BAN_DURATION` (default `7d`): a ban that would be longer is
|
bans for three times as long as that ban.
|
||||||
permanent instead.
|
- `SWWAF_MAX_BAN_DURATION` (default `7d`): a ban for a broken rate limit that
|
||||||
|
would be longer is permanent instead.
|
||||||
|
- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
||||||
|
attack.
|
||||||
- `SWWAF_MAX_BANS` (default `5000`): the most bans kept, past, active and
|
- `SWWAF_MAX_BANS` (default `5000`): the most bans kept, past, active and
|
||||||
permanent.
|
permanent.
|
||||||
- `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): the length of the netblock around
|
- `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): the length of the netblock around
|
||||||
@@ -260,6 +284,10 @@ it, and the effective settings are logged at start.
|
|||||||
`********` in its place.
|
`********` in its place.
|
||||||
- `SWWAF_METRICS_TOP_N` (default `50`): how many countries get series of their
|
- `SWWAF_METRICS_TOP_N` (default `50`): how many countries get series of their
|
||||||
own in the metrics by country; the others are counted as `other`.
|
own in the metrics by country; the others are counted as `other`.
|
||||||
|
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the
|
||||||
|
rule files. A directory that does not exist stops the start.
|
||||||
|
- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file, and checks
|
||||||
|
no request against one.
|
||||||
|
|
||||||
Durations are in Go's syntax, with `d` for days (`90s`, `15m`, `7d`). Sizes are
|
Durations are in Go's syntax, with `d` for days (`90s`, `15m`, `7d`). Sizes are
|
||||||
bytes, with an optional `K`, `M` or `G`, which are powers of 1024 (`1K` is 1024
|
bytes, with an optional `K`, `M` or `G`, which are powers of 1024 (`1K` is 1024
|
||||||
@@ -322,18 +350,19 @@ A field that does not apply to a request is left out of its line, apart from
|
|||||||
- `request_bytes` and `response_bytes` count body bytes.
|
- `request_bytes` and `response_bytes` count body bytes.
|
||||||
- `action` is `forward` for a request passed to the app, `denied` for one
|
- `action` is `forward` for a request passed to the app, `denied` for one
|
||||||
refused because its client is in `SWWAF_DENY_NETS`, `banned` for one refused
|
refused because its client is in `SWWAF_DENY_NETS`, `banned` for one refused
|
||||||
because a ban covers its client, `country_denied` for one refused for its
|
because a ban covers its client or because it matched a `ban` rule, which bans
|
||||||
client's country, `rate_limited` for one that broke a rate limit and banned
|
its client, `country_denied` for one refused for its client's country,
|
||||||
its client, `too_large` for a request or response over its size limit,
|
`rate_limited` for one that broke a rate limit and banned its client,
|
||||||
`timed_out` for one that ran out of time, `upstream_error` when the app could
|
`rule_blocked` for one a `block` rule refused, `too_large` for a request or
|
||||||
not be reached or its answer broke off, and `admin` for one `smallwebwaf`
|
response over its size limit, `timed_out` for one that ran out of time,
|
||||||
answered at its own endpoint.
|
`upstream_error` when the app could not be reached or its answer broke off,
|
||||||
|
and `admin` for one `smallwebwaf` answered at its own endpoint.
|
||||||
- `would_action` is there in `observe` mode for a request that
|
- `would_action` is there in `observe` mode for a request that
|
||||||
`SWWAF_DENY_NETS`, a ban, the country lists or a rate limit would have refused
|
`SWWAF_DENY_NETS`, a ban, the country lists, a rate limit or a rule would have
|
||||||
in `enforce` mode, and names the action that refusal would have had: `denied`,
|
refused in `enforce` mode, and names the action that refusal would have had:
|
||||||
`banned`, `country_denied` or `rate_limited`. `action` then names what was
|
`denied`, `banned`, `country_denied`, `rate_limited` or `rule_blocked`.
|
||||||
done: `forward` for a request passed to the app, and another action, such as
|
`action` then names what was done: `forward` for a request passed to the app,
|
||||||
`too_large`, for one a size or time limit refused.
|
and another action, such as `too_large`, for one a size or time limit refused.
|
||||||
- `counts` gives the client's requests in the minute, the hour and the day as
|
- `counts` gives the client's requests in the minute, the hour and the day as
|
||||||
the rate limits count them, this request included: in each window, those in
|
the rate limits count them, this request included: in each window, those in
|
||||||
the bucket under way and a share of those in the bucket before, so a count can
|
the bucket under way and a share of those in the bucket before, so a count can
|
||||||
@@ -344,6 +373,8 @@ A field that does not apply to a request is left out of its line, apart from
|
|||||||
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` exempts, and one that `SWWAF_DENY_NETS`, a ban
|
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` exempts, and one that `SWWAF_DENY_NETS`, a ban
|
||||||
or the country lists refuse, or would refuse in `observe` mode. The byte
|
or the country lists refuse, or would refuse in `observe` mode. The byte
|
||||||
totals come with the byte limits.
|
totals come with the byte limits.
|
||||||
|
- `rule_ids` is there for a request that matched rules of the rule files, and
|
||||||
|
lists their ids in the order they matched, up to the one that refused it.
|
||||||
- `limit_hit` is there for a request that broke a rate limit, and names the
|
- `limit_hit` is there for a request that broke a rate limit, and names the
|
||||||
window whose limit it went over: `minute`, `hour` or `day`, the shortest if it
|
window whose limit it went over: `minute`, `hour` or `day`, the shortest if it
|
||||||
went over several. `offence` is then `limit`.
|
went over several. `offence` is then `limit`.
|
||||||
@@ -381,7 +412,8 @@ all, which it answers itself, mostly with `400`.
|
|||||||
entries by client address, with times in UTC.
|
entries by client address, with times in UTC.
|
||||||
|
|
||||||
- `bans.json`: every ban with its notes, indented to be read; a permanent ban's
|
- `bans.json`: every ban with its notes, indented to be read; a permanent ban's
|
||||||
`expires` is `null`.
|
`expires` is `null`, and a ban `smallwebwaf` made has the `cause` `limit` for
|
||||||
|
a broken rate limit or `attack` for a clear sign of attack.
|
||||||
- `clients.json`: each client's two buckets in the minute, the hour and the day,
|
- `clients.json`: each client's two buckets in the minute, the hour and the day,
|
||||||
and its history: when it was first and last seen, its country as last looked
|
and its history: when it was first and last seen, its country as last looked
|
||||||
up and when, its requests, how many were forwarded and how many refused (one
|
up and when, its requests, how many were forwarded and how many refused (one
|
||||||
@@ -392,12 +424,13 @@ entries by client address, with times in UTC.
|
|||||||
- `lookups.json`: GeoJS's answers, one to a line, with when GeoJS gave each and
|
- `lookups.json`: GeoJS's answers, one to a line, with when GeoJS gave each and
|
||||||
when it was last used.
|
when it was last used.
|
||||||
|
|
||||||
`bans.json` is written `SWWAF_STATE_WRITE_DELAY` after a ban is made, with every
|
`bans.json` is written `SWWAF_STATE_WRITE_DELAY` after a ban is made or made
|
||||||
ban made in between, and every file every `SWWAF_STATE_COUNTER_INTERVAL` and
|
permanent, with every such change in between, and every file every
|
||||||
when `smallwebwaf` stops. Each write goes to a temporary file in the same
|
`SWWAF_STATE_COUNTER_INTERVAL` and when `smallwebwaf` stops. Each write goes to
|
||||||
directory, which then replaces the file, so a crash leaves the old file or the
|
a temporary file in the same directory, which then replaces the file, so a crash
|
||||||
new one, whole. A write that fails is logged, and tried again at the next write.
|
leaves the old file or the new one, whole. A write that fails is logged, and
|
||||||
A hard kill loses what changed since the last write.
|
tried again at the next write. A hard kill loses what changed since the last
|
||||||
|
write.
|
||||||
|
|
||||||
At start the files are read back: each client keeps its counts, so a restart
|
At start the files are read back: each client keeps its counts, so a restart
|
||||||
gives it no fresh allowance, and each ban keeps refusing every client in its
|
gives it no fresh allowance, and each ban keeps refusing every client in its
|
||||||
@@ -412,7 +445,8 @@ without a field it needs, named with the entry's place in the file: a ban's
|
|||||||
`netblock`, `start` or `expires`, which is `null` for a permanent ban; a
|
`netblock`, `start` or `expires`, which is `null` for a permanent ban; a
|
||||||
client's `client`, or the `start` of a window in which it has requests; an
|
client's `client`, or the `start` of a window in which it has requests; an
|
||||||
answer's `client`, `country`, which is `""` for a client GeoJS cannot place, or
|
answer's `client`, `country`, which is `""` for a client GeoJS cannot place, or
|
||||||
`answered`. The AS number and AS name come with their lookup.
|
`answered`. So does a ban whose `cause` is neither `limit` nor `attack`. The AS
|
||||||
|
number and AS name come with their lookup.
|
||||||
|
|
||||||
While it runs, `smallwebwaf` watches `SWWAF_STATE_DIR` and takes in your edit of
|
While it runs, `smallwebwaf` watches `SWWAF_STATE_DIR` and takes in your edit of
|
||||||
a state file as soon as you save it: what the file then holds replaces what
|
a state file as soon as you save it: what the file then holds replaces what
|
||||||
@@ -421,17 +455,19 @@ yours by comparing the file with what it last read or wrote, and before it
|
|||||||
writes a file it takes in any edit made since, so your edit is not overwritten;
|
writes a file it takes in any edit made since, so your edit is not overwritten;
|
||||||
a change `smallwebwaf` made after you opened the file, such as a new ban, is
|
a change `smallwebwaf` made after you opened the file, such as a new ban, is
|
||||||
lost when you save over it. An edit that would stop the start, because it does
|
lost when you save over it. An edit that would stop the start, because it does
|
||||||
not parse, has another `version` or leaves out a field an entry needs, does not
|
not parse, has another `version`, leaves out a field an entry needs or gives a
|
||||||
stop the running `smallwebwaf`: it keeps what it holds, and at the file's next
|
ban another `cause`, does not stop the running `smallwebwaf`: it keeps what it
|
||||||
write renames your file to `<name>.bad`, such as `bans.json.bad`, writes the
|
holds, and at the file's next write renames your file to `<name>.bad`, such as
|
||||||
file again from memory, and logs the file and where the error is. It waits for
|
`bans.json.bad`, writes the file again from memory, and logs the file and where
|
||||||
that write because an editor's file can be read before the editor has finished
|
the error is. It waits for that write because an editor's file can be read
|
||||||
writing it. Mend the `.bad` file and move it back. A file you remove is written
|
before the editor has finished writing it. Mend the `.bad` file and move it
|
||||||
again at its next write.
|
back. A file you remove is written again at its next write.
|
||||||
|
|
||||||
To ban a netblock, add an entry to `bans.json` with its `netblock`, its `start`
|
To ban a netblock, add an entry to `bans.json` with its `netblock`, its `start`
|
||||||
and its `expires`, `null` for a ban that never ends; its `notes` may be left
|
and its `expires`, `null` for a ban that never ends; its `cause` and its `notes`
|
||||||
out. This `bans.json` bans `203.0.113.0/24` for good:
|
may be left out. A ban whose `cause` is `attack` becomes permanent at the first
|
||||||
|
request it refuses; one without a cause does not. This `bans.json` bans
|
||||||
|
`203.0.113.0/24` for good:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -449,6 +485,77 @@ out. This `bans.json` bans `203.0.113.0/24` for good:
|
|||||||
To lift a ban, delete its entry. `smallwebwaf` then forgets the ban, so it does
|
To lift a ban, delete its entry. `smallwebwaf` then forgets the ban, so it does
|
||||||
not make the netblock's next ban longer.
|
not make the netblock's next ban longer.
|
||||||
|
|
||||||
|
## Rule files
|
||||||
|
|
||||||
|
`smallwebwaf` reads every `*.rules` file in `SWWAF_RULES_DIR`,
|
||||||
|
`/etc/smallwebwaf/rules.d` by default, in the order of their names, and checks
|
||||||
|
each request against their rules in that order, as "Rule files" in
|
||||||
|
[`SPEC.md`](SPEC.md) describes. A file whose name starts with `.`, such as an
|
||||||
|
editor's lock file `.#50-app.rules`, is not a rule file, as a shell's `*.rules`
|
||||||
|
would not match it. A rule is a line of four fields separated by spaces or tabs:
|
||||||
|
an id, a target, an action and a regex, which runs to the end of the line. Blank
|
||||||
|
lines and lines that start with `#` are ignored.
|
||||||
|
|
||||||
|
```
|
||||||
|
# id target action regex
|
||||||
|
env-file path ban (?i)^/\.env(\.[a-z]+)?$
|
||||||
|
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
|
||||||
|
```
|
||||||
|
|
||||||
|
- The id is letters, digits, `-` and `_`, and no two rules share one. The
|
||||||
|
request log, the metrics and a ban's notes name the rule by it.
|
||||||
|
- The target is what the regex is matched against: `path` or `query`, as the
|
||||||
|
client sent it, before any decoding; `uri`, the path and the query together,
|
||||||
|
both as sent and once percent-decoded, so that an encoded probe does not slip
|
||||||
|
past; `method`; `host`; `user_agent`; `referer`; or `header:<Name>`, any one
|
||||||
|
request header but `Host` and `Transfer-Encoding`, which Go's HTTP server
|
||||||
|
takes out of every request; the request's host is the target `host`. A header
|
||||||
|
sent more than once is matched with its values joined by `, `, and one not
|
||||||
|
sent as empty text. No body is read.
|
||||||
|
- The action is `log`, `block` or `ban` (see "What it does so far" above). Keep
|
||||||
|
`ban` for requests no real visitor sends, and anchor a path at the site root
|
||||||
|
with `^/`: a file of the same name deeper in a site can be ordinary content,
|
||||||
|
such as a file in a repository on a code forge.
|
||||||
|
- The regex is in Go's syntax, RE2, which has no backreferences or lookaround,
|
||||||
|
and takes time linear in the text it reads. It matches anywhere in the target
|
||||||
|
unless anchored with `^` and `$`; `(?i)` at its front makes it ignore case.
|
||||||
|
|
||||||
|
A line that is not a rule, a rule for the `Host` or the `Transfer-Encoding`
|
||||||
|
header, a regex that does not compile or an id used twice stops the start with a
|
||||||
|
message naming the file and the line, and so does a `SWWAF_RULES_DIR` that does
|
||||||
|
not exist. An empty directory is no error, and the log says that it holds no
|
||||||
|
rules. While it runs, `smallwebwaf` watches the directory, and reads the rule
|
||||||
|
files again once the directory has had no change for 2 seconds after one is
|
||||||
|
edited, added or removed, so that a file saved in place, appended to or copied
|
||||||
|
in with `scp` is read only once whole, unless its writing stops for longer. It
|
||||||
|
also reads them 2 seconds after it starts watching, so that an edit saved while
|
||||||
|
it started is not missed. If they then hold one of those errors, the rules stay
|
||||||
|
as they were, the earlier version of the edited file included, the log names the
|
||||||
|
file and the line, and the files are read again after the next change.
|
||||||
|
|
||||||
|
The image ships one rule file, `share/rules.d/00-default.rules` here: rules that
|
||||||
|
ban probes no real visitor sends, for secrets, version control directories,
|
||||||
|
backups, logs and web shells at the site root, and the user agents of common
|
||||||
|
scanners; one that blocks `../` twice in a row in the path or the query; and one
|
||||||
|
that only notes a request without a user agent. An app's Dockerfile adds rules
|
||||||
|
of its own in a file beside it, named to sort after it, such as this
|
||||||
|
`50-gitea.rules` for an app that serves no WordPress:
|
||||||
|
|
||||||
|
```
|
||||||
|
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
||||||
|
```
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules
|
||||||
|
```
|
||||||
|
|
||||||
|
A directory mounted over `/etc/smallwebwaf/rules.d` replaces the default file,
|
||||||
|
and single files mounted into it add to it. Docker does not show a single
|
||||||
|
mounted file being replaced, which is how many editors save, so rules to be
|
||||||
|
edited while `smallwebwaf` runs belong in a mounted directory, with a copy of
|
||||||
|
`00-default.rules` if its rules are to stay. To run without rules, mount an
|
||||||
|
empty directory or set `SWWAF_RULES_ENABLED=false`.
|
||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
|
|
||||||
`GET /_smallwebwaf/metrics` answers with the metrics in the Prometheus text
|
`GET /_smallwebwaf/metrics` answers with the metrics in the Prometheus text
|
||||||
@@ -466,8 +573,11 @@ other request. No metric carries a client's address.
|
|||||||
- `smallwebwaf_rate_limit_hits_total` by `window`,
|
- `smallwebwaf_rate_limit_hits_total` by `window`,
|
||||||
`smallwebwaf_size_and_time_limit_hits_total` by `limit`, the setting whose
|
`smallwebwaf_size_and_time_limit_hits_total` by `limit`, the setting whose
|
||||||
limit was passed, `smallwebwaf_offences_total` by `kind`, and
|
limit was passed, `smallwebwaf_offences_total` by `kind`, and
|
||||||
`smallwebwaf_bans_made_total` by `cause`; `smallwebwaf_active_bans` and
|
`smallwebwaf_bans_made_total` by `cause`, `limit` or `attack`;
|
||||||
`smallwebwaf_permanent_bans`.
|
`smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`.
|
||||||
|
- `smallwebwaf_rule_matches_total`: the requests that matched each rule, by
|
||||||
|
`rule_id` and `action`, the rule's own; and `smallwebwaf_rules_loaded`: the
|
||||||
|
rules read from the rule files.
|
||||||
- `smallwebwaf_country_requests_total`,
|
- `smallwebwaf_country_requests_total`,
|
||||||
`smallwebwaf_country_request_bytes_total`,
|
`smallwebwaf_country_request_bytes_total`,
|
||||||
`smallwebwaf_country_response_bytes_total`, and
|
`smallwebwaf_country_response_bytes_total`, and
|
||||||
@@ -494,7 +604,7 @@ other request. No metric carries a client's address.
|
|||||||
|
|
||||||
The requests Go's HTTP server ends before `smallwebwaf` sees them (see "Request
|
The requests Go's HTTP server ends before `smallwebwaf` sees them (see "Request
|
||||||
log") are not counted. The metrics of the features still to come, such as the
|
log") are not counted. The metrics of the features still to come, such as the
|
||||||
rule files, come with them.
|
Core Rule Set, come with them.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
@@ -768,25 +878,29 @@ addresses are never sent to GeoJS.
|
|||||||
## How the code is laid out
|
## How the code is laid out
|
||||||
|
|
||||||
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
|
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
|
||||||
- `internal/smallwebwaf`: the process: it reads the settings and the state
|
- `internal/smallwebwaf`: the process: it reads the settings, the rule files and
|
||||||
files, listens, serves requests until `SIGTERM` or `SIGINT`, and stops,
|
the state files, listens, serves requests until `SIGTERM` or `SIGINT`, and
|
||||||
writing the state files. Run as `smallwebwaf healthcheck`, it is the image's
|
stops, writing the state files. Run as `smallwebwaf healthcheck`, it is the
|
||||||
health check instead.
|
image's health check instead.
|
||||||
- `internal/config`: reads the settings, the one place they are read.
|
- `internal/config`: reads the settings, the one place they are read.
|
||||||
- `internal/proxy`: what happens to each request: it works out the client, runs
|
- `internal/proxy`: what happens to each request: it works out the client, runs
|
||||||
the checks, passes the request to the app and the answer back with the
|
the checks, passes the request to the app and the answer back with the
|
||||||
standard library's `httputil.ReverseProxy` within the timeouts and size
|
standard library's `httputil.ReverseProxy` within the timeouts and size
|
||||||
limits, and writes the request's log line. Its `check` method is where a
|
limits, and writes the request's log line. Its `check` method is where a
|
||||||
request is refused before anything reaches the app: for `SWWAF_DENY_NETS`, for
|
request is refused before anything reaches the app: for `SWWAF_DENY_NETS`, for
|
||||||
a ban, for the country lists, for a rate limit, which bans the client, and for
|
a ban, for the country lists, for a rate limit, which bans the client, for a
|
||||||
an announced body over the size limit; in `observe` mode, only for the size
|
`block` or `ban` rule, the latter banning the client, and for an announced
|
||||||
limit, with what it would have refused for noted in the log line. A request
|
body over the size limit; in `observe` mode, only for the size limit, with
|
||||||
under `/_smallwebwaf/` that `check` lets through is answered by `answerAdmin`
|
what it would have refused for noted in the log line. A request under
|
||||||
|
`/_smallwebwaf/` that `check` lets through is answered by `answerAdmin`
|
||||||
instead of reaching the app.
|
instead of reaching the app.
|
||||||
- `internal/metrics`: the metrics, counted as the other parts tell it what
|
- `internal/metrics`: the metrics, counted as the other parts tell it what
|
||||||
happened, and served in the Prometheus text format.
|
happened, and served in the Prometheus text format.
|
||||||
- `internal/bans`: the ban ledger: each netblock's bans with their notes, how
|
- `internal/bans`: the ban ledger: each netblock's bans with their notes, how
|
||||||
long a new ban lasts, and which ban is dropped when `SWWAF_MAX_BANS` are held.
|
long a new ban lasts, when a ban for a clear sign of attack becomes permanent,
|
||||||
|
and which ban is dropped when `SWWAF_MAX_BANS` are held.
|
||||||
|
- `internal/rules`: reads the rule files at start and again as they change, and
|
||||||
|
tells which of their rules a request matches.
|
||||||
- `internal/lookup`: looks up each client's country through GeoJS, and keeps the
|
- `internal/lookup`: looks up each client's country through GeoJS, and keeps the
|
||||||
answers.
|
answers.
|
||||||
- `internal/ratelimit`: the table of clients: counts each client's requests,
|
- `internal/ratelimit`: the table of clients: counts each client's requests,
|
||||||
@@ -797,7 +911,8 @@ addresses are never sent to GeoJS.
|
|||||||
process's own messages.
|
process's own messages.
|
||||||
- `Dockerfile`: the lint and test phases, then the image, whose last stage
|
- `Dockerfile`: the lint and test phases, then the image, whose last stage
|
||||||
installs Ubuntu's packages, nixpkgs, `runsvinit` and `smallwebwaf`, with
|
installs Ubuntu's packages, nixpkgs, `runsvinit` and `smallwebwaf`, with
|
||||||
`share/smallwebwaf.run` as runit's `run` script for `smallwebwaf`.
|
`share/smallwebwaf.run` as runit's `run` script for `smallwebwaf` and
|
||||||
|
`share/rules.d/00-default.rules` as its default rule file.
|
||||||
- `deploy/example-app`: an app built on the image, which `script/example-app`
|
- `deploy/example-app`: an app built on the image, which `script/example-app`
|
||||||
checks.
|
checks.
|
||||||
|
|
||||||
@@ -805,8 +920,8 @@ Besides the Go standard library, `github.com/hashicorp/golang-lru/v2` keeps the
|
|||||||
table of clients to 20,000, the GeoJS answers to 100,000 and the banned
|
table of clients to 20,000, the GeoJS answers to 100,000 and the banned
|
||||||
netblocks to `SWWAF_MAX_BANS`, dropping the least recently seen, and
|
netblocks to `SWWAF_MAX_BANS`, dropping the least recently seen, and
|
||||||
`github.com/prometheus/client_golang` keeps the metrics and serves them, and
|
`github.com/prometheus/client_golang` keeps the metrics and serves them, and
|
||||||
`github.com/fsnotify/fsnotify` tells `smallwebwaf` when a state file is saved.
|
`github.com/fsnotify/fsnotify` tells `smallwebwaf` when a state file or a rule
|
||||||
The country codes are the list in `internal/config/config.go`.
|
file is saved. The country codes are the list in `internal/config/config.go`.
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
|
|
||||||
@@ -836,16 +951,17 @@ so that they run in minimal containers.
|
|||||||
- `script/build`: builds `bin/smallwebwaf` on the host, with Go installed, for
|
- `script/build`: builds `bin/smallwebwaf` on the host, with Go installed, for
|
||||||
working on the code by hand; `make build` runs it.
|
working on the code by hand; `make build` runs it.
|
||||||
- `script/run`: builds `bin/smallwebwaf` with `script/build` and runs it, with
|
- `script/run`: builds `bin/smallwebwaf` with `script/build` and runs it, with
|
||||||
its state files in `bin/state` unless `SWWAF_STATE_DIR` is set; `make run`
|
its state files in `bin/state` unless `SWWAF_STATE_DIR` is set, and the rule
|
||||||
runs it.
|
files of `share/rules.d` unless `SWWAF_RULES_DIR` is set; `make run` runs it.
|
||||||
- `script/example-app`: builds the image and, on it, the example app in
|
- `script/example-app`: builds the image and, on it, the example app in
|
||||||
`deploy/example-app`, runs it with a volume for the state files, and checks
|
`deploy/example-app`, runs it with a volume for the state files, and checks
|
||||||
that the health check passes, that a request reaches the app through
|
that the health check passes, that a request reaches the app through
|
||||||
`smallwebwaf`, that a second request in a minute bans the client, that
|
`smallwebwaf`, that a second request in a minute bans the client, that a probe
|
||||||
`sv stop` and `docker stop` stop it in order, and that a new container on the
|
for `/.env` bans another client, whose next request makes the ban permanent,
|
||||||
same volume still refuses the banned client; then removes the containers, the
|
that `sv stop` and `docker stop` stop it in order, and that a new container on
|
||||||
volume and both images. It needs network access, for nixpkgs' binary cache,
|
the same volume still refuses the banned client; then removes the containers,
|
||||||
and `script/check` does not run it; `make example-app` does.
|
the volume and both images. It needs network access, for nixpkgs' binary
|
||||||
|
cache, and `script/check` does not run it; `make example-app` does.
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
|
|||||||
+181
-81
@@ -1,7 +1,8 @@
|
|||||||
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
||||||
// netblocks of clients that break a rate limit, with their notes, as the
|
// netblocks of clients that break a rate limit or show a clear sign of
|
||||||
// "Bans" section of SPEC.md describes. The bans are kept in memory, and
|
// attack, with their notes, as the "Bans" section of SPEC.md describes.
|
||||||
// written to bans.json and read from it by the state package.
|
// The bans are kept in memory, and written to bans.json and read from it
|
||||||
|
// by the state package.
|
||||||
package bans
|
package bans
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -14,6 +15,15 @@ import (
|
|||||||
"github.com/hashicorp/golang-lru/v2/simplelru"
|
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The causes of the bans smallwebwaf makes. A ban an admin adds to
|
||||||
|
// bans.json may have no cause.
|
||||||
|
const (
|
||||||
|
// CauseLimit is a ban for a broken limit.
|
||||||
|
CauseLimit = "limit"
|
||||||
|
// CauseAttack is a ban for a clear sign of attack.
|
||||||
|
CauseAttack = "attack"
|
||||||
|
)
|
||||||
|
|
||||||
// repeatFactor is how many times as long as the netblock's last ban a ban
|
// repeatFactor is how many times as long as the netblock's last ban a ban
|
||||||
// for a limit broken again within the repeat window lasts.
|
// for a limit broken again within the repeat window lasts.
|
||||||
const repeatFactor = 3
|
const repeatFactor = 3
|
||||||
@@ -21,31 +31,36 @@ const repeatFactor = 3
|
|||||||
// maxTextBytes is how much of each text in a ban's notes is kept.
|
// maxTextBytes is how much of each text in a ban's notes is kept.
|
||||||
const maxTextBytes = 256
|
const maxTextBytes = 256
|
||||||
|
|
||||||
// Rules are how long a ban for a broken limit lasts, and how many bans
|
// Rules are how long a ban lasts, and how many bans are held.
|
||||||
// are held.
|
|
||||||
type Rules struct {
|
type Rules struct {
|
||||||
// LimitBanDuration is how long a first ban lasts.
|
// LimitBanDuration is how long a first ban for a broken limit lasts.
|
||||||
LimitBanDuration time.Duration
|
LimitBanDuration time.Duration
|
||||||
// LimitBanRepeatWindow is how soon after the end of the netblock's
|
// LimitBanRepeatWindow is how soon after the end of the netblock's
|
||||||
// ban that ended last a broken limit counts as a repeat, which bans
|
// ban that ended last, other than one for a clear sign of attack, a
|
||||||
// for repeatFactor times as long as that ban.
|
// broken limit counts as a repeat, which bans for repeatFactor times as
|
||||||
|
// long as that ban.
|
||||||
LimitBanRepeatWindow time.Duration
|
LimitBanRepeatWindow time.Duration
|
||||||
// MaxBanDuration is the longest ban; a ban that would be longer is
|
// MaxBanDuration is the longest ban for a broken limit; one that would
|
||||||
// permanent instead.
|
// be longer is permanent instead.
|
||||||
MaxBanDuration time.Duration
|
MaxBanDuration time.Duration
|
||||||
|
// AttackBanDuration is how long a first ban for a clear sign of attack
|
||||||
|
// lasts.
|
||||||
|
AttackBanDuration time.Duration
|
||||||
// MaxBans is the most bans held, at least one. Past it, the earliest
|
// MaxBans is the most bans held, at least one. Past it, the earliest
|
||||||
// ban of the netblock that has gone longest without a request is
|
// ban of the netblock that has gone longest without a request is
|
||||||
// dropped.
|
// dropped.
|
||||||
MaxBans int
|
MaxBans int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ban is a ban on a netblock for a broken limit, the only kind of ban
|
// Ban is a ban on a netblock.
|
||||||
// smallwebwaf makes so far.
|
|
||||||
type Ban struct {
|
type Ban struct {
|
||||||
Netblock netip.Prefix
|
Netblock netip.Prefix
|
||||||
Start time.Time
|
Start time.Time
|
||||||
// Expires is when the ban ends, zero for a permanent ban.
|
// Expires is when the ban ends, zero for a permanent ban.
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
|
// Cause is CauseLimit or CauseAttack, or "" for a ban an admin added
|
||||||
|
// without one.
|
||||||
|
Cause string
|
||||||
Notes Notes
|
Notes Notes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,23 +81,39 @@ func (b Ban) ActiveAt(now time.Time) bool {
|
|||||||
type Notes struct {
|
type Notes struct {
|
||||||
// Country is the client's country, when it was looked up.
|
// Country is the client's country, when it was looked up.
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
// Limit, Window and Count are the limit that was broken, its window,
|
// Limit, Window and Count are, for a ban for a broken limit, the limit
|
||||||
// "minute", "hour" or "day", and the count reached: the client's
|
// that was broken, its window, "minute", "hour" or "day", and the
|
||||||
// requests in the window, the one that broke the limit included.
|
// count reached: the client's requests in the window, the one that
|
||||||
// These are the requests that counted toward the ban, and the window
|
// broke the limit included. These are the requests that counted
|
||||||
// is the time over which they came.
|
// toward the ban, and the window is the time over which they came.
|
||||||
Limit int64 `json:"limit"`
|
Limit int64 `json:"limit,omitempty"`
|
||||||
Window string `json:"window"`
|
Window string `json:"window,omitempty"`
|
||||||
Count float64 `json:"count"`
|
Count float64 `json:"count,omitempty"`
|
||||||
// Request is the request that broke the limit.
|
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
||||||
|
// of the rule file rule that matched, and its target.
|
||||||
|
RuleID string `json:"rule_id,omitempty"`
|
||||||
|
Target string `json:"target,omitempty"`
|
||||||
|
// Request is the request that broke the limit, or that was the clear
|
||||||
|
// sign of attack.
|
||||||
Request Request `json:"request"`
|
Request Request `json:"request"`
|
||||||
// Requests is how many requests the netblock has sent since it was
|
// Requests is how many requests the netblock has sent since it was
|
||||||
// first seen, and Refused how many of them the ban has refused so
|
// first seen, and Refused how many of them the ban has refused so
|
||||||
// far. Both go up with each request the ban refuses.
|
// far. Both go up with each request the ban refuses.
|
||||||
Requests int64 `json:"requests"`
|
Requests int64 `json:"requests"`
|
||||||
Refused int64 `json:"refused"`
|
Refused int64 `json:"refused"`
|
||||||
// EarlierBans is how many bans the netblock had before this one.
|
// EarlierBans is how many bans the netblock had before this one, by
|
||||||
EarlierBans int `json:"earlier_bans"`
|
// cause.
|
||||||
|
EarlierBans EarlierBans `json:"earlier_bans"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EarlierBans counts a netblock's bans before a ban, by cause.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||||
|
type EarlierBans struct {
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Attack int `json:"attack"`
|
||||||
|
// WithoutCause counts the bans an admin added without a cause.
|
||||||
|
WithoutCause int `json:"without_cause"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request is a request in a ban's notes. Each text is cut to 256 bytes.
|
// Request is a request in a ban's notes. Each text is cut to 256 bytes.
|
||||||
@@ -112,8 +143,8 @@ type Ledger struct {
|
|||||||
netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
|
netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
|
||||||
// held is how many bans netblocks holds, at most rules.MaxBans.
|
// held is how many bans netblocks holds, at most rules.MaxBans.
|
||||||
held int
|
held int
|
||||||
// made is how many bans BanForLimit has made since the start.
|
// made is how many bans the ledger has made since the start, by cause.
|
||||||
made int
|
made map[string]int
|
||||||
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
||||||
// netblocks that have been banned. Check looks for a ban at each of
|
// netblocks that have been banned. Check looks for a ban at each of
|
||||||
// them, so that a ban read from bans.json refuses every client in its
|
// them, so that a ban read from bans.json refuses every client in its
|
||||||
@@ -135,18 +166,21 @@ func New(rules Rules) *Ledger {
|
|||||||
rules: rules,
|
rules: rules,
|
||||||
changed: make(chan struct{}, 1),
|
changed: make(chan struct{}, 1),
|
||||||
netblocks: netblocks,
|
netblocks: netblocks,
|
||||||
|
made: map[string]int{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Changed receives a value after a ban is made, so that bans.json can be
|
// Changed receives a value after a ban is made or made permanent, so that
|
||||||
// written. Several bans made before it is read leave one value.
|
// bans.json can be written. Several changes before it is read leave one
|
||||||
|
// value.
|
||||||
func (l *Ledger) Changed() <-chan struct{} {
|
func (l *Ledger) Changed() <-chan struct{} {
|
||||||
return l.changed
|
return l.changed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check is called for a request from client, at now. It reports whether
|
// Check is called for a request from client, at now. It reports whether
|
||||||
// a ban on a netblock client is in is active, and returns that ban, with
|
// a ban on a netblock client is in is active, and returns that ban, with
|
||||||
// the request counted among those it refused.
|
// the request counted among those it refused. A ban for a clear sign of
|
||||||
|
// attack is made permanent by the request: the netblock is malicious.
|
||||||
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
|
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
@@ -159,6 +193,12 @@ func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
|
|||||||
ban.Notes.Requests++
|
ban.Notes.Requests++
|
||||||
ban.Notes.Refused++
|
ban.Notes.Refused++
|
||||||
|
|
||||||
|
if ban.Cause == CauseAttack && !ban.Permanent() {
|
||||||
|
ban.Expires = time.Time{}
|
||||||
|
|
||||||
|
l.markChanged()
|
||||||
|
}
|
||||||
|
|
||||||
return *ban, true
|
return *ban, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,54 +232,22 @@ func activeBan(bans []Ban, now time.Time) *Ban {
|
|||||||
|
|
||||||
// BanForLimit bans netblock at now for a broken limit, with notes, and
|
// BanForLimit bans netblock at now for a broken limit, with notes, and
|
||||||
// returns the ban. A first ban lasts LimitBanDuration. A ban made within
|
// returns the ban. A first ban lasts LimitBanDuration. A ban made within
|
||||||
// LimitBanRepeatWindow after the netblock's ban that ended last lasts
|
// LimitBanRepeatWindow after the netblock's ban that ended last, other
|
||||||
// repeatFactor times as long as that one. A ban that would be longer
|
// than one for a clear sign of attack, lasts repeatFactor times as long as
|
||||||
// than MaxBanDuration is permanent instead. If a ban on netblock is still
|
// that one. A ban that would be longer than MaxBanDuration is permanent
|
||||||
// active, as when two of its requests break a limit at once, that ban is
|
// instead. If a ban on netblock is still active, as when two of its
|
||||||
// returned and no other is made. The ledger fills in the notes' Refused
|
// requests break a limit at once, that ban is returned and no other is
|
||||||
// and EarlierBans itself.
|
// made. The ledger fills in the notes' Refused and EarlierBans itself.
|
||||||
func (l *Ledger) BanForLimit(netblock netip.Prefix, now time.Time, notes Notes) Ban {
|
func (l *Ledger) BanForLimit(netblock netip.Prefix, now time.Time, notes Notes) Ban {
|
||||||
l.mu.Lock()
|
return l.ban(netblock, now, CauseLimit, notes)
|
||||||
defer l.mu.Unlock()
|
|
||||||
|
|
||||||
var last *Ban
|
|
||||||
|
|
||||||
bans, found := l.netblocks.Get(netblock)
|
|
||||||
if found {
|
|
||||||
active := activeBan(*bans, now)
|
|
||||||
if active != nil {
|
|
||||||
return *active
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No ban is active, so each has an end. A ban an admin adds to
|
// BanForAttack bans netblock at now for a clear sign of attack, with
|
||||||
// bans.json can start after another and end before it, so the
|
// notes, and returns the ban, as BanForLimit does. A first ban lasts
|
||||||
// ban that ended last is looked for among them all.
|
// AttackBanDuration; once the netblock has had one, the next is
|
||||||
ended := slices.MaxFunc(*bans, func(a, b Ban) int {
|
// permanent.
|
||||||
return a.Expires.Compare(b.Expires)
|
func (l *Ledger) BanForAttack(netblock netip.Prefix, now time.Time, notes Notes) Ban {
|
||||||
})
|
return l.ban(netblock, now, CauseAttack, notes)
|
||||||
last = &ended
|
|
||||||
|
|
||||||
// The netblock's first ban held counts the bans it had before that
|
|
||||||
// one, since dropped to make room, and each ban held adds one.
|
|
||||||
notes.EarlierBans = (*bans)[0].Notes.EarlierBans + len(*bans)
|
|
||||||
}
|
|
||||||
|
|
||||||
notes.Request = notes.Request.cut()
|
|
||||||
ban := Ban{
|
|
||||||
Netblock: netblock,
|
|
||||||
Start: now,
|
|
||||||
Expires: l.expiry(last, now),
|
|
||||||
Notes: notes,
|
|
||||||
}
|
|
||||||
l.add(ban)
|
|
||||||
l.made++
|
|
||||||
|
|
||||||
select {
|
|
||||||
case l.changed <- struct{}{}:
|
|
||||||
default: // a value is waiting already
|
|
||||||
}
|
|
||||||
|
|
||||||
return ban
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bans returns the bans held on netblock, oldest first. It is not a
|
// Bans returns the bans held on netblock, oldest first. It is not a
|
||||||
@@ -256,13 +264,13 @@ func (l *Ledger) Bans(netblock netip.Prefix) []Ban {
|
|||||||
return slices.Clone(*bans)
|
return slices.Clone(*bans)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Made returns how many bans the ledger has made since the start; bans
|
// Made returns how many bans for cause the ledger has made since the
|
||||||
// read from bans.json are not among them.
|
// start; bans read from bans.json are not among them.
|
||||||
func (l *Ledger) Made() int {
|
func (l *Ledger) Made(cause string) int {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
return l.made
|
return l.made[cause]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count returns how many of the bans held are active at now, and how many
|
// Count returns how many of the bans held are active at now, and how many
|
||||||
@@ -332,6 +340,73 @@ func (l *Ledger) Load(bans []Ban) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ban bans netblock at now for cause, with notes, as BanForLimit and
|
||||||
|
// BanForAttack describe, and returns the ban.
|
||||||
|
func (l *Ledger) ban(
|
||||||
|
netblock netip.Prefix, now time.Time, cause string, notes Notes,
|
||||||
|
) Ban {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
// held are the netblock's bans, none of them active.
|
||||||
|
var held []Ban
|
||||||
|
|
||||||
|
bans, found := l.netblocks.Get(netblock)
|
||||||
|
if found {
|
||||||
|
active := activeBan(*bans, now)
|
||||||
|
if active != nil {
|
||||||
|
return *active
|
||||||
|
}
|
||||||
|
|
||||||
|
held = *bans
|
||||||
|
notes.EarlierBans = earlierBans(held)
|
||||||
|
}
|
||||||
|
|
||||||
|
notes.Request = notes.Request.cut()
|
||||||
|
ban := Ban{Netblock: netblock, Start: now, Cause: cause, Notes: notes}
|
||||||
|
|
||||||
|
if cause == CauseAttack {
|
||||||
|
ban.Expires = l.attackExpiry(held, now)
|
||||||
|
} else {
|
||||||
|
ban.Expires = l.limitExpiry(held, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.add(ban)
|
||||||
|
l.made[cause]++
|
||||||
|
l.markChanged()
|
||||||
|
|
||||||
|
return ban
|
||||||
|
}
|
||||||
|
|
||||||
|
// earlierBans returns how many bans a netblock with the bans held, oldest
|
||||||
|
// first, has had, by cause: the first ban held counts the bans the
|
||||||
|
// netblock had before that one, since dropped to make room, and each ban
|
||||||
|
// held adds one.
|
||||||
|
func earlierBans(held []Ban) EarlierBans {
|
||||||
|
earlier := held[0].Notes.EarlierBans
|
||||||
|
|
||||||
|
for _, ban := range held {
|
||||||
|
switch ban.Cause {
|
||||||
|
case CauseLimit:
|
||||||
|
earlier.Limit++
|
||||||
|
case CauseAttack:
|
||||||
|
earlier.Attack++
|
||||||
|
default:
|
||||||
|
earlier.WithoutCause++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return earlier
|
||||||
|
}
|
||||||
|
|
||||||
|
// markChanged has Changed receive a value, unless one is waiting already.
|
||||||
|
func (l *Ledger) markChanged() {
|
||||||
|
select {
|
||||||
|
case l.changed <- struct{}{}:
|
||||||
|
default: // a value is waiting already
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// active returns the ban active at now on a netblock client is in, or
|
// active returns the ban active at now on a netblock client is in, or
|
||||||
// nil.
|
// nil.
|
||||||
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
||||||
@@ -383,12 +458,23 @@ func (l *Ledger) add(ban Ban) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// expiry returns when a ban for a broken limit made at now ends, or zero
|
// limitExpiry returns when a ban for a broken limit made at now ends, or
|
||||||
// when it is permanent. last is the netblock's ban that ended last, or nil
|
// zero when it is permanent. held are the netblock's bans, none of them
|
||||||
// when it has none.
|
// active, of which the one that ended last, other than a ban for a clear
|
||||||
func (l *Ledger) expiry(last *Ban, now time.Time) time.Time {
|
// sign of attack, can make the new ban longer. A ban an admin adds to
|
||||||
|
// bans.json can start after another and end before it, so that one is
|
||||||
|
// looked for among them all.
|
||||||
|
func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
|
||||||
length := l.rules.LimitBanDuration
|
length := l.rules.LimitBanDuration
|
||||||
|
|
||||||
|
var last *Ban
|
||||||
|
|
||||||
|
for i, ban := range held {
|
||||||
|
if ban.Cause != CauseAttack && (last == nil || ban.Expires.After(last.Expires)) {
|
||||||
|
last = &held[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow {
|
if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow {
|
||||||
lastLength := last.Expires.Sub(last.Start)
|
lastLength := last.Expires.Sub(last.Start)
|
||||||
// This is repeatFactor * lastLength > MaxBanDuration, written so
|
// This is repeatFactor * lastLength > MaxBanDuration, written so
|
||||||
@@ -407,6 +493,20 @@ func (l *Ledger) expiry(last *Ban, now time.Time) time.Time {
|
|||||||
return now.Add(length)
|
return now.Add(length)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attackExpiry returns when a ban for a clear sign of attack made at now
|
||||||
|
// ends. held are the netblock's bans, none of them active: if one of them
|
||||||
|
// is for a clear sign of attack too, the new ban is permanent, and its
|
||||||
|
// end zero; otherwise it ends AttackBanDuration later.
|
||||||
|
func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time {
|
||||||
|
for _, ban := range held {
|
||||||
|
if ban.Cause == CauseAttack {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return now.Add(l.rules.AttackBanDuration)
|
||||||
|
}
|
||||||
|
|
||||||
// dropOne drops the earliest ban of the netblock that has gone longest
|
// dropOne drops the earliest ban of the netblock that has gone longest
|
||||||
// without a request, and the netblock with it if that was its only ban.
|
// without a request, and the netblock with it if that was its only ban.
|
||||||
func (l *Ledger) dropOne() {
|
func (l *Ledger) dropOne() {
|
||||||
|
|||||||
+103
-7
@@ -24,8 +24,9 @@ func TestRepeatsTripleUntilPermanent(t *testing.T) {
|
|||||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||||
|
|
||||||
length := time.Duration(hours) * time.Hour
|
length := time.Duration(hours) * time.Hour
|
||||||
if !ban.Expires.Equal(now.Add(length)) || ban.Notes.EarlierBans != i {
|
if !ban.Expires.Equal(now.Add(length)) ||
|
||||||
t.Fatalf("ban %d lasts %s with %d earlier bans, want %d hours and %d",
|
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: i}) {
|
||||||
|
t.Fatalf("ban %d lasts %s with earlier bans %+v, want %d hours and %d for a limit",
|
||||||
i+1, ban.Expires.Sub(now), ban.Notes.EarlierBans, hours, i)
|
i+1, ban.Expires.Sub(now), ban.Notes.EarlierBans, hours, i)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +67,9 @@ func TestRepeatWindowRunsOut(t *testing.T) {
|
|||||||
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
second := ledger.BanForLimit(netblock, first.Expires.Add(tc.gap), bans.Notes{})
|
second := ledger.BanForLimit(netblock, first.Expires.Add(tc.gap), bans.Notes{})
|
||||||
|
|
||||||
if second.Expires.Sub(second.Start) != tc.want || second.Notes.EarlierBans != 1 {
|
if second.Expires.Sub(second.Start) != tc.want ||
|
||||||
t.Errorf("second ban lasts %s with %d earlier bans, want %s and 1",
|
second.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||||
|
t.Errorf("second ban lasts %s with earlier bans %+v, want %s and 1 for a limit",
|
||||||
second.Expires.Sub(second.Start), second.Notes.EarlierBans, tc.want)
|
second.Expires.Sub(second.Start), second.Notes.EarlierBans, tc.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -235,9 +237,102 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
|
|||||||
second := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
second := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
||||||
|
|
||||||
held := ledger.Bans(netblock)
|
held := ledger.Bans(netblock)
|
||||||
if len(held) != 1 || held[0] != second || held[0].Notes.EarlierBans != 1 {
|
if len(held) != 1 || held[0] != second ||
|
||||||
t.Errorf("the ledger holds %+v, want only the second ban, with 1 earlier ban",
|
held[0].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||||
held)
|
t.Errorf("the ledger holds %+v, want only the second ban, "+
|
||||||
|
"with 1 earlier ban for a limit", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
notes := bans.Notes{RuleID: "env-file", Target: "path"}
|
||||||
|
|
||||||
|
ban := ledger.BanForAttack(netblock, midnight(), notes)
|
||||||
|
if !ban.Expires.Equal(midnight().Add(7*day)) || ban.Cause != bans.CauseAttack ||
|
||||||
|
ban.Notes.RuleID != "env-file" || ledger.Made(bans.CauseAttack) != 1 ||
|
||||||
|
ledger.Made(bans.CauseLimit) != 0 {
|
||||||
|
t.Fatalf("the ban is %+v, with %d made for an attack and %d for a limit, "+
|
||||||
|
"want one for an attack, of seven days", ban,
|
||||||
|
ledger.Made(bans.CauseAttack), ledger.Made(bans.CauseLimit))
|
||||||
|
}
|
||||||
|
|
||||||
|
wantChanged(t, ledger, true)
|
||||||
|
|
||||||
|
// In observe mode the ban refuses nothing, and stays as it is.
|
||||||
|
got, _ := ledger.Find(netblock.Addr(), midnight().Add(time.Hour))
|
||||||
|
if got.Permanent() {
|
||||||
|
t.Fatal("a request found under the ban made it permanent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request it refuses makes it permanent, and bans.json due.
|
||||||
|
got, _ = ledger.Check(netblock.Addr(), midnight().Add(time.Hour))
|
||||||
|
if !got.Permanent() || !ledger.Bans(netblock)[0].Permanent() {
|
||||||
|
t.Fatalf("after a request during the ban, it is %+v, want it permanent", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantChanged(t, ledger, true)
|
||||||
|
|
||||||
|
_, banned := ledger.Check(netblock.Addr(), midnight().Add(100*365*day))
|
||||||
|
if !banned {
|
||||||
|
t.Error("the permanent ban ended")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
|
// A ban for a broken limit before does not count.
|
||||||
|
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
|
second := ledger.BanForAttack(netblock, first.Expires, bans.Notes{})
|
||||||
|
|
||||||
|
if second.Expires.Sub(second.Start) != 7*day {
|
||||||
|
t.Fatalf("the first ban for an attack lasts %s, want 7 days",
|
||||||
|
second.Expires.Sub(second.Start))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once that has run out without a request, the netblock is served, and
|
||||||
|
// its next clear sign of attack bans it for good.
|
||||||
|
_, banned := ledger.Check(netblock.Addr(), second.Expires)
|
||||||
|
if banned {
|
||||||
|
t.Fatal("the ban did not end")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Its notes show the earlier ban for an attack that makes it permanent,
|
||||||
|
// beside the one for a limit.
|
||||||
|
third := ledger.BanForAttack(netblock, second.Expires.Add(30*day), bans.Notes{})
|
||||||
|
if !third.Permanent() ||
|
||||||
|
third.Notes.EarlierBans != (bans.EarlierBans{Limit: 1, Attack: 1}) {
|
||||||
|
t.Errorf("the next ban for an attack is %+v, want a permanent one, "+
|
||||||
|
"with 1 earlier ban for a limit and 1 for an attack", third)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
|
// Three times the seven days would be permanent; a limit broken as the
|
||||||
|
// ban for an attack ends bans for an hour, as a first broken limit does.
|
||||||
|
attack := ledger.BanForAttack(netblock, midnight(), bans.Notes{})
|
||||||
|
limit := ledger.BanForLimit(netblock, attack.Expires, bans.Notes{})
|
||||||
|
|
||||||
|
if limit.Expires.Sub(limit.Start) != time.Hour || limit.Cause != bans.CauseLimit {
|
||||||
|
t.Errorf("the ban for a limit is %+v, want one of an hour", limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a request during the ban for a limit leaves it as it is.
|
||||||
|
got, _ := ledger.Check(netblock.Addr(), limit.Start)
|
||||||
|
if got.Permanent() {
|
||||||
|
t.Error("a request during a ban for a limit made it permanent")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +364,7 @@ func defaultRules() bans.Rules {
|
|||||||
LimitBanDuration: time.Hour,
|
LimitBanDuration: time.Hour,
|
||||||
LimitBanRepeatWindow: day,
|
LimitBanRepeatWindow: day,
|
||||||
MaxBanDuration: 7 * day,
|
MaxBanDuration: 7 * day,
|
||||||
|
AttackBanDuration: 7 * day,
|
||||||
MaxBans: 5000,
|
MaxBans: 5000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,8 +82,9 @@ func TestLoadedBansCarryOn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
again := after.BanForLimit(netblock, ban.Expires, bans.Notes{})
|
again := after.BanForLimit(netblock, ban.Expires, bans.Notes{})
|
||||||
if again.Expires.Sub(again.Start) != 3*time.Hour || again.Notes.EarlierBans != 1 {
|
if again.Expires.Sub(again.Start) != 3*time.Hour ||
|
||||||
t.Errorf("the next ban lasts %s with %d earlier bans, want 3h and 1",
|
again.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||||
|
t.Errorf("the next ban lasts %s with earlier bans %+v, want 3h and 1 for a limit",
|
||||||
again.Expires.Sub(again.Start), again.Notes.EarlierBans)
|
again.Expires.Sub(again.Start), again.Notes.EarlierBans)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,7 +179,8 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
|||||||
Netblock: netblock,
|
Netblock: netblock,
|
||||||
Start: midnight(),
|
Start: midnight(),
|
||||||
Expires: midnight().Add(9 * time.Hour),
|
Expires: midnight().Add(9 * time.Hour),
|
||||||
Notes: bans.Notes{EarlierBans: 2},
|
Cause: bans.CauseLimit,
|
||||||
|
Notes: bans.Notes{EarlierBans: bans.EarlierBans{Limit: 2}},
|
||||||
}
|
}
|
||||||
admins := bans.Ban{
|
admins := bans.Ban{
|
||||||
Netblock: netblock,
|
Netblock: netblock,
|
||||||
@@ -191,10 +193,13 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
|||||||
|
|
||||||
// Once both have ended, a limit broken within the repeat window bans
|
// Once both have ended, a limit broken within the repeat window bans
|
||||||
// for three times the 9 hours, and the notes count the two bans
|
// for three times the 9 hours, and the notes count the two bans
|
||||||
// before the 9-hour one, it, and the admin's.
|
// before the 9-hour one and it, for a limit, and the admin's, without
|
||||||
|
// a cause.
|
||||||
ban := ledger.BanForLimit(netblock, nineHours.Expires.Add(time.Hour), bans.Notes{})
|
ban := ledger.BanForLimit(netblock, nineHours.Expires.Add(time.Hour), bans.Notes{})
|
||||||
if ban.Expires.Sub(ban.Start) != 27*time.Hour || ban.Notes.EarlierBans != 4 {
|
if ban.Expires.Sub(ban.Start) != 27*time.Hour ||
|
||||||
t.Errorf("the next ban lasts %s with %d earlier bans, want 27h and 4",
|
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 3, WithoutCause: 1}) {
|
||||||
|
t.Errorf("the next ban lasts %s with earlier bans %+v, "+
|
||||||
|
"want 27h, 3 for a limit and 1 without a cause",
|
||||||
ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans)
|
ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ type Config struct {
|
|||||||
InstanceName string
|
InstanceName string
|
||||||
// Observe is true in observe mode, when SWWAF_MODE is observe rather
|
// Observe is true in observe mode, when SWWAF_MODE is observe rather
|
||||||
// than enforce: a request that SWWAF_DENY_NETS, a ban, the country
|
// than enforce: a request that SWWAF_DENY_NETS, a ban, the country
|
||||||
// lists or a rate limit would refuse is passed to the app instead, and
|
// lists, a rate limit or a rule would refuse is passed to the app
|
||||||
// no ban is made.
|
// instead, and no ban is made.
|
||||||
Observe bool
|
Observe bool
|
||||||
// TrustedProxies are the netblocks whose X-Forwarded-For is
|
// TrustedProxies are the netblocks whose X-Forwarded-For is
|
||||||
// believed (SWWAF_TRUSTED_PROXIES).
|
// believed (SWWAF_TRUSTED_PROXIES).
|
||||||
@@ -94,7 +94,8 @@ type Config struct {
|
|||||||
// BanResponse is the status a refused client is answered with, 403
|
// BanResponse is the status a refused client is answered with, 403
|
||||||
// or 429, or 0 to close the connection without an answer
|
// or 429, or 0 to close the connection without an answer
|
||||||
// (SWWAF_BAN_RESPONSE). It answers a banned client, a request that
|
// (SWWAF_BAN_RESPONSE). It answers a banned client, a request that
|
||||||
// breaks a rate limit, SWWAF_DENY_NETS and the country lists.
|
// breaks a rate limit or matches a ban rule, SWWAF_DENY_NETS and the
|
||||||
|
// country lists.
|
||||||
BanResponse int
|
BanResponse int
|
||||||
// LimitBanDuration is the ban for a first broken rate limit
|
// LimitBanDuration is the ban for a first broken rate limit
|
||||||
// (SWWAF_LIMIT_BAN_DURATION). A limit broken again within
|
// (SWWAF_LIMIT_BAN_DURATION). A limit broken again within
|
||||||
@@ -105,6 +106,9 @@ type Config struct {
|
|||||||
LimitBanDuration time.Duration
|
LimitBanDuration time.Duration
|
||||||
LimitBanRepeatWindow time.Duration
|
LimitBanRepeatWindow time.Duration
|
||||||
MaxBanDuration time.Duration
|
MaxBanDuration time.Duration
|
||||||
|
// AttackBanDuration is the ban for a first clear sign of attack
|
||||||
|
// (SWWAF_ATTACK_BAN_DURATION). It cannot be off.
|
||||||
|
AttackBanDuration time.Duration
|
||||||
// MaxBans is the most bans held (SWWAF_MAX_BANS).
|
// MaxBans is the most bans held (SWWAF_MAX_BANS).
|
||||||
MaxBans int
|
MaxBans int
|
||||||
// BanScopeV4Prefix is the length of the netblock around an IPv4
|
// BanScopeV4Prefix is the length of the netblock around an IPv4
|
||||||
@@ -127,6 +131,10 @@ type Config struct {
|
|||||||
// metrics (SWWAF_METRICS_TOP_N).
|
// metrics (SWWAF_METRICS_TOP_N).
|
||||||
MetricsToken string
|
MetricsToken string
|
||||||
MetricsTopN int
|
MetricsTopN int
|
||||||
|
// RulesDir is the directory of the rule files (SWWAF_RULES_DIR), read
|
||||||
|
// unless RulesEnabled is false (SWWAF_RULES_ENABLED).
|
||||||
|
RulesDir string
|
||||||
|
RulesEnabled bool
|
||||||
|
|
||||||
// settings are the values read, as given or by default, for the
|
// settings are the values read, as given or by default, for the
|
||||||
// log line at start.
|
// log line at start.
|
||||||
@@ -187,6 +195,7 @@ var (
|
|||||||
errNotMode = errors.New("is not enforce or observe")
|
errNotMode = errors.New("is not enforce or observe")
|
||||||
errNotPathPrefix = errors.New(
|
errNotPathPrefix = errors.New(
|
||||||
"is not a path prefix starting with /, such as /assets/")
|
"is not a path prefix starting with /, such as /assets/")
|
||||||
|
errNotBoolean = errors.New("is not true or false")
|
||||||
)
|
)
|
||||||
|
|
||||||
// FromEnvironment reads the settings with lookupEnv, normally
|
// FromEnvironment reads the settings with lookupEnv, normally
|
||||||
@@ -224,6 +233,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
|||||||
LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"),
|
LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"),
|
||||||
LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"),
|
LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"),
|
||||||
MaxBanDuration: env.durationNotOff("SWWAF_MAX_BAN_DURATION", "7d"),
|
MaxBanDuration: env.durationNotOff("SWWAF_MAX_BAN_DURATION", "7d"),
|
||||||
|
AttackBanDuration: env.durationNotOff("SWWAF_ATTACK_BAN_DURATION", "7d"),
|
||||||
MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"),
|
MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"),
|
||||||
BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"),
|
BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"),
|
||||||
StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/smallwebwaf"),
|
StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/smallwebwaf"),
|
||||||
@@ -233,6 +243,8 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
|||||||
"accept,accept-language,accept-encoding,content-type,origin,range"),
|
"accept,accept-language,accept-encoding,content-type,origin,range"),
|
||||||
MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
|
MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
|
||||||
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
|
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
|
||||||
|
RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"),
|
||||||
|
RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, country := range cfg.ExclusivelyAllowedCountries {
|
for _, country := range cfg.ExclusivelyAllowedCountries {
|
||||||
@@ -316,6 +328,16 @@ func (e *environment) observe(name, defaultValue string) bool {
|
|||||||
return mode == "observe"
|
return mode == "observe"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// boolean reads a setting that is true or false.
|
||||||
|
func (e *environment) boolean(name, defaultValue string) bool {
|
||||||
|
value := e.value(name, defaultValue)
|
||||||
|
if value != "true" && value != "false" {
|
||||||
|
e.check(name, fmt.Errorf("%q %w", value, errNotBoolean))
|
||||||
|
}
|
||||||
|
|
||||||
|
return value == "true"
|
||||||
|
}
|
||||||
|
|
||||||
// netblocks reads a setting that is a list of netblocks.
|
// netblocks reads a setting that is a list of netblocks.
|
||||||
func (e *environment) netblocks(name, defaultValue string) []netip.Prefix {
|
func (e *environment) netblocks(name, defaultValue string) []netip.Prefix {
|
||||||
netblocks, err := parseNetblocks(e.value(name, defaultValue))
|
netblocks, err := parseNetblocks(e.value(name, defaultValue))
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const (
|
|||||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
||||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
||||||
|
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
||||||
maxBans = "SWWAF_MAX_BANS"
|
maxBans = "SWWAF_MAX_BANS"
|
||||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||||
stateDir = "SWWAF_STATE_DIR"
|
stateDir = "SWWAF_STATE_DIR"
|
||||||
@@ -51,6 +52,8 @@ const (
|
|||||||
metricsTopN = "SWWAF_METRICS_TOP_N"
|
metricsTopN = "SWWAF_METRICS_TOP_N"
|
||||||
instanceName = "SWWAF_INSTANCE_NAME"
|
instanceName = "SWWAF_INSTANCE_NAME"
|
||||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||||
|
rulesDir = "SWWAF_RULES_DIR"
|
||||||
|
rulesEnabled = "SWWAF_RULES_ENABLED"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
||||||
@@ -108,6 +111,7 @@ func TestDefaults(t *testing.T) {
|
|||||||
LimitBanDuration: time.Hour,
|
LimitBanDuration: time.Hour,
|
||||||
LimitBanRepeatWindow: 24 * time.Hour,
|
LimitBanRepeatWindow: 24 * time.Hour,
|
||||||
MaxBanDuration: 7 * 24 * time.Hour,
|
MaxBanDuration: 7 * 24 * time.Hour,
|
||||||
|
AttackBanDuration: 7 * 24 * time.Hour,
|
||||||
MaxBans: 5000,
|
MaxBans: 5000,
|
||||||
BanScopeV4Prefix: 32,
|
BanScopeV4Prefix: 32,
|
||||||
StateDir: "/var/lib/smallwebwaf",
|
StateDir: "/var/lib/smallwebwaf",
|
||||||
@@ -115,6 +119,8 @@ func TestDefaults(t *testing.T) {
|
|||||||
StateCounterInterval: 15 * time.Minute,
|
StateCounterInterval: 15 * time.Minute,
|
||||||
MetricsToken: "",
|
MetricsToken: "",
|
||||||
MetricsTopN: 50,
|
MetricsTopN: 50,
|
||||||
|
RulesDir: "/etc/smallwebwaf/rules.d",
|
||||||
|
RulesEnabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
|
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
|
||||||
@@ -168,13 +174,13 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
rateLimitPerMinute: "60",
|
rateLimitPerMinute: "60",
|
||||||
rateLimitPerHour: "600",
|
rateLimitPerHour: "600",
|
||||||
rateLimitPerDay: "6000",
|
rateLimitPerDay: "6000",
|
||||||
rateLimitExemptPaths: "/assets/, /favicon.ico",
|
|
||||||
deniedCountries: "cn, RU,kp,Xk",
|
deniedCountries: "cn, RU,kp,Xk",
|
||||||
allowedCountries: "de",
|
allowedCountries: "de",
|
||||||
banResponse: "429",
|
banResponse: "429",
|
||||||
limitBanDuration: "15m",
|
limitBanDuration: "15m",
|
||||||
limitBanRepeatWindow: "2d",
|
limitBanRepeatWindow: "2d",
|
||||||
maxBanDuration: "30d",
|
maxBanDuration: "30d",
|
||||||
|
attackBanDuration: "1d",
|
||||||
maxBans: "100",
|
maxBans: "100",
|
||||||
banScopeV4Prefix: "24",
|
banScopeV4Prefix: "24",
|
||||||
stateDir: "/srv/waf-state",
|
stateDir: "/srv/waf-state",
|
||||||
@@ -182,6 +188,8 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
stateCounterInterval: "1h",
|
stateCounterInterval: "1h",
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
metricsTopN: "10",
|
metricsTopN: "10",
|
||||||
|
rulesDir: "/srv/waf-rules",
|
||||||
|
rulesEnabled: "false",
|
||||||
})
|
})
|
||||||
|
|
||||||
wantSettings(t, cfg, config.Config{
|
wantSettings(t, cfg, config.Config{
|
||||||
@@ -202,6 +210,7 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
LimitBanDuration: 15 * time.Minute,
|
LimitBanDuration: 15 * time.Minute,
|
||||||
LimitBanRepeatWindow: 48 * time.Hour,
|
LimitBanRepeatWindow: 48 * time.Hour,
|
||||||
MaxBanDuration: 30 * 24 * time.Hour,
|
MaxBanDuration: 30 * 24 * time.Hour,
|
||||||
|
AttackBanDuration: 24 * time.Hour,
|
||||||
MaxBans: 100,
|
MaxBans: 100,
|
||||||
BanScopeV4Prefix: 24,
|
BanScopeV4Prefix: 24,
|
||||||
StateDir: "/srv/waf-state",
|
StateDir: "/srv/waf-state",
|
||||||
@@ -209,6 +218,8 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
StateCounterInterval: time.Hour,
|
StateCounterInterval: time.Hour,
|
||||||
MetricsToken: token,
|
MetricsToken: token,
|
||||||
MetricsTopN: 10,
|
MetricsTopN: 10,
|
||||||
|
RulesDir: "/srv/waf-rules",
|
||||||
|
RulesEnabled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
if cfg.UpstreamURL.String() != "https://app.internal:8443/" {
|
if cfg.UpstreamURL.String() != "https://app.internal:8443/" {
|
||||||
@@ -221,6 +232,12 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
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, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK")
|
||||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitExemptPathsAsSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := fromEnvironment(t, environment{rateLimitExemptPaths: "/assets/, /favicon.ico"})
|
||||||
|
|
||||||
if !slices.Equal(cfg.RateLimitExemptPaths, []string{"/assets/", "/favicon.ico"}) {
|
if !slices.Equal(cfg.RateLimitExemptPaths, []string{"/assets/", "/favicon.ico"}) {
|
||||||
t.Errorf("%s gave %v, want /assets/ and /favicon.ico",
|
t.Errorf("%s gave %v, want /assets/ and /favicon.ico",
|
||||||
@@ -356,7 +373,7 @@ func TestTrustedProxiesSetButEmptyTrustNothing(t *testing.T) {
|
|||||||
func TestInvalidValueStopsTheStart(t *testing.T) {
|
func TestInvalidValueStopsTheStart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, tc := range []struct{ name, value string }{
|
wantStartStopped(t, []struct{ name, value string }{
|
||||||
{listenAddr, "8080"}, {listenAddr, ":http"}, {listenAddr, ":65536"},
|
{listenAddr, "8080"}, {listenAddr, ":http"}, {listenAddr, ":65536"},
|
||||||
{upstreamURL, "127.0.0.1:8081"},
|
{upstreamURL, "127.0.0.1:8081"},
|
||||||
{upstreamURL, "ftp://127.0.0.1:8081"},
|
{upstreamURL, "ftp://127.0.0.1:8081"},
|
||||||
@@ -406,21 +423,37 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
|||||||
{allowedCountries, "uk"},
|
{allowedCountries, "uk"},
|
||||||
{allowedCountries, "zz"},
|
{allowedCountries, "zz"},
|
||||||
{allowedCountries, "de,germany"},
|
{allowedCountries, "de,germany"},
|
||||||
{banResponse, "404"}, {banResponse, "drop"}, {banResponse, ""},
|
|
||||||
{limitBanDuration, off}, {limitBanDuration, "0s"}, {limitBanDuration, "1"},
|
|
||||||
{limitBanRepeatWindow, off}, {limitBanRepeatWindow, "-1h"},
|
|
||||||
{maxBanDuration, off}, {maxBanDuration, "1w"},
|
|
||||||
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
|
|
||||||
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
|
|
||||||
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"},
|
|
||||||
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
|
||||||
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
|
||||||
{metricsTopN, off}, {metricsTopN, "0"}, {metricsTopN, "-1"},
|
{metricsTopN, off}, {metricsTopN, "0"}, {metricsTopN, "-1"},
|
||||||
{logRequestHeaders, "accept,,origin"}, {logRequestHeaders, "accept;origin"},
|
{logRequestHeaders, "accept,,origin"}, {logRequestHeaders, "accept;origin"},
|
||||||
{logRequestHeaders, "accept language"}, {logRequestHeaders, "x-foo:"},
|
{logRequestHeaders, "accept language"}, {logRequestHeaders, "x-foo:"},
|
||||||
{logRequestHeaders, "host"}, {logRequestHeaders, "accept,Host"},
|
{logRequestHeaders, "host"}, {logRequestHeaders, "accept,Host"},
|
||||||
{logRequestHeaders, "transfer-encoding"}, {logRequestHeaders, "TRANSFER-ENCODING"},
|
{logRequestHeaders, "transfer-encoding"}, {logRequestHeaders, "TRANSFER-ENCODING"},
|
||||||
} {
|
{rulesEnabled, "yes"}, {rulesEnabled, "True"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
wantStartStopped(t, []struct{ name, value string }{
|
||||||
|
{banResponse, "404"}, {banResponse, "drop"}, {banResponse, ""},
|
||||||
|
{limitBanDuration, off}, {limitBanDuration, "0s"}, {limitBanDuration, "1"},
|
||||||
|
{limitBanRepeatWindow, off}, {limitBanRepeatWindow, "-1h"},
|
||||||
|
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
|
||||||
|
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
|
||||||
|
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
|
||||||
|
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"},
|
||||||
|
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
||||||
|
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantStartStopped checks that each setting, set to its value, stops the
|
||||||
|
// start with an error that names the setting.
|
||||||
|
func wantStartStopped(t *testing.T, invalid []struct{ name, value string }) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, tc := range invalid {
|
||||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -536,6 +569,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
limitBanDuration: "1h",
|
limitBanDuration: "1h",
|
||||||
limitBanRepeatWindow: "24h",
|
limitBanRepeatWindow: "24h",
|
||||||
maxBanDuration: "7d",
|
maxBanDuration: "7d",
|
||||||
|
attackBanDuration: "7d",
|
||||||
maxBans: "5000",
|
maxBans: "5000",
|
||||||
banScopeV4Prefix: "32",
|
banScopeV4Prefix: "32",
|
||||||
stateDir: "/var/lib/smallwebwaf",
|
stateDir: "/var/lib/smallwebwaf",
|
||||||
@@ -545,6 +579,8 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
metricsTopN: "50",
|
metricsTopN: "50",
|
||||||
instanceName: hostname,
|
instanceName: hostname,
|
||||||
logRequestHeaders: defaultLogRequestHeaders,
|
logRequestHeaders: defaultLogRequestHeaders,
|
||||||
|
rulesDir: "/etc/smallwebwaf/rules.d",
|
||||||
|
rulesEnabled: "true",
|
||||||
}
|
}
|
||||||
if !maps.Equal(line.Settings, want) {
|
if !maps.Equal(line.Settings, want) {
|
||||||
t.Errorf("logged settings\n%v\nwant\n%v", line.Settings, want)
|
t.Errorf("logged settings\n%v\nwant\n%v", line.Settings, want)
|
||||||
@@ -574,8 +610,8 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) {
|
|||||||
wantBanSettings(t, got, want)
|
wantBanSettings(t, got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantBanSettings checks the settings for bans, the state files and the
|
// wantBanSettings checks the settings for bans, the state files, the
|
||||||
// metrics.
|
// metrics and the rule files.
|
||||||
func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
|
func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -583,11 +619,17 @@ func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
|
|||||||
got.LimitBanDuration != want.LimitBanDuration ||
|
got.LimitBanDuration != want.LimitBanDuration ||
|
||||||
got.LimitBanRepeatWindow != want.LimitBanRepeatWindow ||
|
got.LimitBanRepeatWindow != want.LimitBanRepeatWindow ||
|
||||||
got.MaxBanDuration != want.MaxBanDuration ||
|
got.MaxBanDuration != want.MaxBanDuration ||
|
||||||
|
got.AttackBanDuration != want.AttackBanDuration ||
|
||||||
got.MaxBans != want.MaxBans ||
|
got.MaxBans != want.MaxBans ||
|
||||||
got.BanScopeV4Prefix != want.BanScopeV4Prefix {
|
got.BanScopeV4Prefix != want.BanScopeV4Prefix {
|
||||||
t.Errorf("ban settings\n%+v\nwant\n%+v", got, want)
|
t.Errorf("ban settings\n%+v\nwant\n%+v", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if got.RulesDir != want.RulesDir || got.RulesEnabled != want.RulesEnabled {
|
||||||
|
t.Errorf("rule files in %q, on: %t, want %q, %t",
|
||||||
|
got.RulesDir, got.RulesEnabled, want.RulesDir, want.RulesEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
if got.StateDir != want.StateDir ||
|
if got.StateDir != want.StateDir ||
|
||||||
got.StateWriteDelay != want.StateWriteDelay ||
|
got.StateWriteDelay != want.StateWriteDelay ||
|
||||||
got.StateCounterInterval != want.StateCounterInterval {
|
got.StateCounterInterval != want.StateCounterInterval {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metrics are smallwebwaf's metrics. They are safe for concurrent use.
|
// Metrics are smallwebwaf's metrics. They are safe for concurrent use.
|
||||||
@@ -30,6 +31,8 @@ type Metrics struct {
|
|||||||
rateLimitHits *prometheus.CounterVec
|
rateLimitHits *prometheus.CounterVec
|
||||||
sizeAndTimeLimitHits *prometheus.CounterVec
|
sizeAndTimeLimitHits *prometheus.CounterVec
|
||||||
offences *prometheus.CounterVec
|
offences *prometheus.CounterVec
|
||||||
|
// ruleMatches are made by AddRules.
|
||||||
|
ruleMatches *prometheus.CounterVec
|
||||||
countries *countries
|
countries *countries
|
||||||
|
|
||||||
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
|
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
|
||||||
@@ -135,19 +138,22 @@ func New(topN int) *Metrics {
|
|||||||
|
|
||||||
// AddBansAndClients adds the metrics read from the ledger and the table
|
// AddBansAndClients adds the metrics read from the ledger and the table
|
||||||
// of clients as the metrics are asked for: the bans made since the start,
|
// of clients as the metrics are asked for: the bans made since the start,
|
||||||
// the bans active and permanent at now, and the clients in the table.
|
// by cause, the bans active and permanent at now, and the clients in the
|
||||||
|
// table.
|
||||||
func (m *Metrics) AddBansAndClients(
|
func (m *Metrics) AddBansAndClients(
|
||||||
ledger *bans.Ledger, limiter *ratelimit.Limiter, now func() time.Time,
|
ledger *bans.Ledger, limiter *ratelimit.Limiter, now func() time.Time,
|
||||||
) {
|
) {
|
||||||
m.registry.MustRegister(
|
for _, cause := range []string{bans.CauseLimit, bans.CauseAttack} {
|
||||||
// Every ban smallwebwaf makes so far is for a broken limit.
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
|
||||||
Name: "smallwebwaf_bans_made_total",
|
Name: "smallwebwaf_bans_made_total",
|
||||||
Help: "Bans made, by cause.",
|
Help: "Bans made, by cause.",
|
||||||
ConstLabels: prometheus.Labels{"cause": "limit"},
|
ConstLabels: prometheus.Labels{"cause": cause},
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(ledger.Made())
|
return float64(ledger.Made(cause))
|
||||||
}),
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_active_bans",
|
Name: "smallwebwaf_active_bans",
|
||||||
Help: "Bans active now, the permanent ones included.",
|
Help: "Bans active now, the permanent ones included.",
|
||||||
@@ -173,6 +179,24 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddRules adds the metrics of the rule files: the requests that matched
|
||||||
|
// each rule, which RuleMatched counts, and the rules loaded from
|
||||||
|
// ruleFiles, read as the metrics are asked for. It is called once, before
|
||||||
|
// RuleMatched.
|
||||||
|
func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
||||||
|
m.ruleMatches = counterVec("smallwebwaf_rule_matches_total",
|
||||||
|
"Requests that matched a rule of the rule files, by its id and action.",
|
||||||
|
[]string{"rule_id", "action"})
|
||||||
|
|
||||||
|
m.registry.MustRegister(m.ruleMatches,
|
||||||
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
|
Name: "smallwebwaf_rules_loaded",
|
||||||
|
Help: "Rules loaded from the rule files.",
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(ruleFiles.Len())
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// ServeHTTP answers with the metrics in the Prometheus text format.
|
// ServeHTTP answers with the metrics in the Prometheus text format.
|
||||||
func (m *Metrics) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (m *Metrics) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
m.handler.ServeHTTP(w, r)
|
m.handler.ServeHTTP(w, r)
|
||||||
@@ -219,6 +243,12 @@ func (m *Metrics) RequestEnded(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RuleMatched counts a request that matched the rule id, whose action is
|
||||||
|
// action.
|
||||||
|
func (m *Metrics) RuleMatched(id, action string) {
|
||||||
|
m.ruleMatches.WithLabelValues(id, action).Inc()
|
||||||
|
}
|
||||||
|
|
||||||
// StateFileWritten counts a write of the state file name, of size bytes,
|
// StateFileWritten counts a write of the state file name, of size bytes,
|
||||||
// that ended with err.
|
// that ended with err.
|
||||||
func (m *Metrics) StateFileWritten(name string, size int, err error) {
|
func (m *Metrics) StateFileWritten(name string, size int, err error) {
|
||||||
|
|||||||
+35
-8
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
|
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
|
||||||
@@ -58,21 +59,47 @@ func (rq *request) limitBroken(now time.Time) bool {
|
|||||||
Limit: hit.Limit,
|
Limit: hit.Limit,
|
||||||
Window: hit.Window,
|
Window: hit.Window,
|
||||||
Count: hit.Requests,
|
Count: hit.Requests,
|
||||||
Request: bans.Request{
|
Request: rq.noted(now),
|
||||||
|
Requests: rq.netblockRequests(netblock),
|
||||||
|
})
|
||||||
|
rq.h.limiter.Reset(group)
|
||||||
|
rq.line.BanExpires = banExpires(ban)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// banForAttack bans the client's netblock at now for a clear sign of
|
||||||
|
// attack, the match of rule, a ban rule.
|
||||||
|
func (rq *request) banForAttack(now time.Time, rule rules.Rule) {
|
||||||
|
netblock := rq.netblock()
|
||||||
|
ban := rq.h.ledger.BanForAttack(netblock, now, bans.Notes{
|
||||||
|
Country: rq.line.Country,
|
||||||
|
RuleID: rule.ID,
|
||||||
|
Target: rule.Target,
|
||||||
|
Request: rq.noted(now),
|
||||||
|
Requests: rq.netblockRequests(netblock),
|
||||||
|
})
|
||||||
|
rq.line.BanExpires = banExpires(ban)
|
||||||
|
}
|
||||||
|
|
||||||
|
// noted is the request, refused at now with SWWAF_BAN_RESPONSE, as the
|
||||||
|
// notes of the ban it makes keep it.
|
||||||
|
func (rq *request) noted(now time.Time) bans.Request {
|
||||||
|
return bans.Request{
|
||||||
Time: now,
|
Time: now,
|
||||||
Method: rq.in.Method,
|
Method: rq.in.Method,
|
||||||
Host: rq.in.Host,
|
Host: rq.in.Host,
|
||||||
Path: rq.in.URL.RequestURI(),
|
Path: rq.in.URL.RequestURI(),
|
||||||
Status: rq.h.config.BanResponse,
|
Status: rq.h.config.BanResponse,
|
||||||
UserAgent: rq.in.UserAgent(),
|
UserAgent: rq.in.UserAgent(),
|
||||||
},
|
}
|
||||||
// The histories count this request only once it has ended.
|
}
|
||||||
Requests: rq.h.limiter.Requests(netblock) + 1,
|
|
||||||
})
|
|
||||||
rq.h.limiter.Reset(group)
|
|
||||||
rq.line.BanExpires = banExpires(ban)
|
|
||||||
|
|
||||||
return true
|
// netblockRequests is how many requests netblock has sent since it was
|
||||||
|
// first seen, this one included: the histories count it only once it has
|
||||||
|
// ended.
|
||||||
|
func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
|
||||||
|
return rq.h.limiter.Requests(netblock) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// netblock is the netblock a ban on the client covers: its IPv4 address,
|
// netblock is the netblock a ban on the client covers: its IPv4 address,
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ func TestBanNotes(t *testing.T) {
|
|||||||
Netblock: netblock,
|
Netblock: netblock,
|
||||||
Start: start,
|
Start: start,
|
||||||
Expires: start.Add(time.Hour),
|
Expires: start.Add(time.Hour),
|
||||||
|
Cause: bans.CauseLimit,
|
||||||
Notes: bans.Notes{
|
Notes: bans.Notes{
|
||||||
Country: "DE",
|
Country: "DE",
|
||||||
Limit: 1,
|
Limit: 1,
|
||||||
@@ -295,7 +296,7 @@ func TestBanNotes(t *testing.T) {
|
|||||||
// refused under the ban.
|
// refused under the ban.
|
||||||
Requests: 4,
|
Requests: 4,
|
||||||
Refused: 2,
|
Refused: 2,
|
||||||
EarlierBans: 0,
|
EarlierBans: bans.EarlierBans{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,8 +313,8 @@ func TestBanNotes(t *testing.T) {
|
|||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
got = ledger.Bans(netblock)
|
got = ledger.Bans(netblock)
|
||||||
if len(got) != 2 || got[1].Notes.EarlierBans != 1 {
|
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||||
t.Errorf("bans %+v, want two, the second with one earlier ban", got)
|
t.Errorf("bans %+v, want two, the second with one earlier ban for a limit", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// How smallwebwaf keeps connections to the app open between requests.
|
// How smallwebwaf keeps connections to the app open between requests.
|
||||||
@@ -51,6 +52,9 @@ type Params struct {
|
|||||||
// limits, bans are made and run out, and GeoJS's answers are kept,
|
// limits, bans are made and run out, and GeoJS's answers are kept,
|
||||||
// normally time.Now in UTC, the time the state files give.
|
// normally time.Now in UTC, the time the state files give.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
|
// Rules are the rule files' rules, which each request is checked
|
||||||
|
// against.
|
||||||
|
Rules *rules.Files
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the server smallwebwaf runs, with the parts of the proxy
|
// Server is the server smallwebwaf runs, with the parts of the proxy
|
||||||
@@ -90,6 +94,7 @@ func New(params Params) *Server {
|
|||||||
LimitBanDuration: params.Config.LimitBanDuration,
|
LimitBanDuration: params.Config.LimitBanDuration,
|
||||||
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
|
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
|
||||||
MaxBanDuration: params.Config.MaxBanDuration,
|
MaxBanDuration: params.Config.MaxBanDuration,
|
||||||
|
AttackBanDuration: params.Config.AttackBanDuration,
|
||||||
MaxBans: params.Config.MaxBans,
|
MaxBans: params.Config.MaxBans,
|
||||||
}),
|
}),
|
||||||
geojs: lookup.New(lookup.Params{
|
geojs: lookup.New(lookup.Params{
|
||||||
@@ -98,8 +103,10 @@ func New(params Params) *Server {
|
|||||||
ProcessLog: params.ProcessLog,
|
ProcessLog: params.ProcessLog,
|
||||||
Metrics: m,
|
Metrics: m,
|
||||||
}),
|
}),
|
||||||
|
rules: params.Rules,
|
||||||
}
|
}
|
||||||
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
||||||
|
m.AddRules(params.Rules)
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
Server: &http.Server{
|
Server: &http.Server{
|
||||||
@@ -134,6 +141,7 @@ type handler struct {
|
|||||||
limiter *ratelimit.Limiter
|
limiter *ratelimit.Limiter
|
||||||
ledger *bans.Ledger
|
ledger *bans.Ledger
|
||||||
geojs *lookup.GeoJS
|
geojs *lookup.GeoJS
|
||||||
|
rules *rules.Files
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTransport returns what carries requests to the app. It never goes
|
// newTransport returns what carries requests to the app. It never goes
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -74,6 +75,8 @@ const (
|
|||||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||||
instanceName = "SWWAF_INSTANCE_NAME"
|
instanceName = "SWWAF_INSTANCE_NAME"
|
||||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||||
|
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
||||||
|
rulesDir = "SWWAF_RULES_DIR"
|
||||||
)
|
)
|
||||||
|
|
||||||
// output collects what smallwebwaf writes on stdout.
|
// output collects what smallwebwaf writes on stdout.
|
||||||
@@ -212,14 +215,15 @@ func startProxyWithGeoJS(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
||||||
// bans made by the time now tells, and returns the server as well.
|
// bans made by the time now tells, and returns the server as well. Unless
|
||||||
|
// env sets SWWAF_RULES_DIR, it is an empty directory, of no rules.
|
||||||
func startProxyWithClock(
|
func startProxyWithClock(
|
||||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||||
env map[string]string,
|
env map[string]string,
|
||||||
) (string, *output, *proxy.Server) {
|
) (string, *output, *proxy.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{"SWWAF_UPSTREAM_URL": appURL}
|
settings := map[string]string{"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir()}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(func(name string) (string, bool) {
|
cfg, err := config.FromEnvironment(func(name string) (string, bool) {
|
||||||
@@ -232,12 +236,22 @@ func startProxyWithClock(
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := &output{}
|
out := &output{}
|
||||||
|
processLog := requestlog.NewProcessLogger(out)
|
||||||
|
|
||||||
|
ruleFiles, err := rules.Load(rules.Params{
|
||||||
|
Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rule files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
server := proxy.New(proxy.Params{
|
server := proxy.New(proxy.Params{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
RequestLog: out,
|
RequestLog: out,
|
||||||
ProcessLog: requestlog.NewProcessLogger(out),
|
ProcessLog: processLog,
|
||||||
GeoJSURL: geojsURL,
|
GeoJSURL: geojsURL,
|
||||||
Now: now,
|
Now: now,
|
||||||
|
Rules: ruleFiles,
|
||||||
})
|
})
|
||||||
|
|
||||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||||
|
|||||||
+15
-12
@@ -159,20 +159,23 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
|
|||||||
// check is the one place where a request can be refused once its client
|
// check is the one place where a request can be refused once its client
|
||||||
// is known, before its body is read or anything reaches the app. It
|
// is known, before its body is read or anything reaches the app. It
|
||||||
// returns nil to let the request through. The checks of checkClient come
|
// returns nil to let the request through. The checks of checkClient come
|
||||||
// first, answered with SWWAF_BAN_RESPONSE, and then the size limit, so
|
// first, answered with SWWAF_BAN_RESPONSE, or 403 for a block rule, and
|
||||||
// that a request the rate limits count is counted even when it is
|
// then the size limit, so that a request the rate limits count is counted
|
||||||
// refused for its size. In observe mode a request checkClient refuses
|
// even when it is refused for its size. In observe mode a request
|
||||||
// goes on to the size limit like any other. ctx is the request's own
|
// checkClient refuses goes on to the size limit like any other. ctx is
|
||||||
// context.
|
// 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)
|
action := rq.checkClient(ctx)
|
||||||
if action != "" {
|
|
||||||
if !rq.h.config.Observe {
|
|
||||||
return rq.banResponse(action)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case action == "":
|
||||||
|
case rq.h.config.Observe:
|
||||||
// The log line names what enforce mode would have done.
|
// The log line names what enforce mode would have done.
|
||||||
rq.line.WouldAction = action
|
rq.line.WouldAction = action
|
||||||
|
case action == requestlog.ActionRuleBlocked:
|
||||||
|
return &refusal{status: http.StatusForbidden, action: action}
|
||||||
|
default:
|
||||||
|
return rq.banResponse(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
maxBytes := rq.h.config.RequestMaxBytes
|
maxBytes := rq.h.config.RequestMaxBytes
|
||||||
@@ -195,8 +198,8 @@ func (rq *request) check(ctx context.Context) *refusal {
|
|||||||
// request any of them refuses is not counted for the rate limits. Then
|
// request any of them refuses is not counted for the rate limits. Then
|
||||||
// come the rate limits, unless the client is in
|
// come the rate limits, unless the client is in
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_NETS or the request's path is exempt under
|
// SWWAF_RATE_LIMIT_EXEMPT_NETS or the request's path is exempt under
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that every other request is counted.
|
// SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that every other request is counted,
|
||||||
// ctx is the request's own context.
|
// and last the rule files. ctx is the request's own context.
|
||||||
func (rq *request) checkClient(ctx context.Context) string {
|
func (rq *request) checkClient(ctx context.Context) string {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
if isInside(rq.client, cfg.AllowNets) {
|
if isInside(rq.client, cfg.AllowNets) {
|
||||||
@@ -223,7 +226,7 @@ func (rq *request) checkClient(ctx context.Context) string {
|
|||||||
return requestlog.ActionRateLimited
|
return requestlog.ActionRateLimited
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return rq.checkRules(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pathExempt reports whether the rate limits leave out a request for u
|
// pathExempt reports whether the rate limits leave out a request for u
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package proxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testRules are the rules most tests here load: a block rule for
|
||||||
|
// /blocked and a ban rule for /.env.
|
||||||
|
const testRules = `
|
||||||
|
blocked path block ^/blocked$
|
||||||
|
probe path ban ^/\.env$
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestEachRuleAction(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server := startWithClock(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
|
||||||
|
banResponse: "429",
|
||||||
|
})
|
||||||
|
start := clk.Now()
|
||||||
|
|
||||||
|
// A log rule notes its match, and lets the request through.
|
||||||
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantRuleIDs(t, line, "noted")
|
||||||
|
|
||||||
|
// A block rule refuses with 403, whatever SWWAF_BAN_RESPONSE is, and
|
||||||
|
// bans no one.
|
||||||
|
line = s.request(client, "/blocked", http.StatusForbidden,
|
||||||
|
requestlog.ActionRuleBlocked)
|
||||||
|
wantRuleIDs(t, line, "noted", "blocked")
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
// A ban rule refuses with SWWAF_BAN_RESPONSE, and bans the client for
|
||||||
|
// seven days, the default.
|
||||||
|
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||||
|
wantRuleIDs(t, line, "noted", "probe")
|
||||||
|
|
||||||
|
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
|
||||||
|
t.Errorf("log line has ban_expires %q, want seven days on", line.BanExpires)
|
||||||
|
}
|
||||||
|
|
||||||
|
netblock := netip.MustParsePrefix(client + "/32")
|
||||||
|
want := bans.Ban{
|
||||||
|
Netblock: netblock,
|
||||||
|
Start: start,
|
||||||
|
Expires: start.Add(7 * 24 * time.Hour),
|
||||||
|
Cause: bans.CauseAttack,
|
||||||
|
Notes: bans.Notes{
|
||||||
|
RuleID: "probe",
|
||||||
|
Target: "path",
|
||||||
|
Request: bans.Request{
|
||||||
|
Time: start,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Host: appHost,
|
||||||
|
Path: "/.env",
|
||||||
|
Status: http.StatusTooManyRequests,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
},
|
||||||
|
// The four requests up to and including the probe.
|
||||||
|
Requests: 4,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := server.Ledger.Bans(netblock)
|
||||||
|
if len(got) != 1 || got[0] != want {
|
||||||
|
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next request is refused under the ban, without being checked
|
||||||
|
// against the rules, and makes the ban permanent.
|
||||||
|
clk.advance(time.Hour)
|
||||||
|
|
||||||
|
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||||
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
|
if line.BanExpires != permanent {
|
||||||
|
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
||||||
|
}
|
||||||
|
|
||||||
|
clk.advance(365 * 24 * time.Hour)
|
||||||
|
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, testRules),
|
||||||
|
attackBanDuration: "1h",
|
||||||
|
})
|
||||||
|
|
||||||
|
// The first probe bans for SWWAF_ATTACK_BAN_DURATION.
|
||||||
|
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
|
||||||
|
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once that ban has run out without a request, the client is served,
|
||||||
|
// and its next probe bans it for good.
|
||||||
|
clk.advance(time.Hour)
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
if line.BanExpires != permanent {
|
||||||
|
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
||||||
|
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||||
|
)
|
||||||
|
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, testRules),
|
||||||
|
allowNets: allowed,
|
||||||
|
rateLimitExemptNets: exempt,
|
||||||
|
rateLimitPerMinute: "1",
|
||||||
|
})
|
||||||
|
|
||||||
|
// A client in SWWAF_ALLOW_NETS is not checked.
|
||||||
|
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
|
// A probe over the rate limit breaks the limit before any rule sees
|
||||||
|
// it.
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
|
limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||||
|
if len(limitBan) != 1 || limitBan[0].Cause != bans.CauseLimit {
|
||||||
|
t.Errorf("bans %+v, want one for a broken limit", limitBan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client the rate limits do not apply to is still checked.
|
||||||
|
s.get(exempt, http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, testRules),
|
||||||
|
mode: observe,
|
||||||
|
})
|
||||||
|
|
||||||
|
line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
|
||||||
|
wantRuleIDs(t, line, "blocked")
|
||||||
|
|
||||||
|
line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||||
|
wantRuleIDs(t, line, "probe")
|
||||||
|
|
||||||
|
if line.BanExpires != "" {
|
||||||
|
t.Errorf("log line has ban_expires %q, want none", line.BanExpires)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No ban was made.
|
||||||
|
line = s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantWouldAction(t, line, "")
|
||||||
|
|
||||||
|
if got := server.Ledger.Snapshot(); len(got) != 0 {
|
||||||
|
t.Errorf("bans %+v, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const scraper = "192.0.2.200"
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, testRules),
|
||||||
|
metricsToken: token,
|
||||||
|
})
|
||||||
|
|
||||||
|
s.request(client, "/blocked", http.StatusForbidden, requestlog.ActionRuleBlocked)
|
||||||
|
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
|
metrics := s.scrape(scraper)
|
||||||
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_rule_matches_total{action="block",rule_id="blocked"}`, 1)
|
||||||
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_rule_matches_total{action="ban",rule_id="probe"}`, 1)
|
||||||
|
wantMetric(t, metrics, "smallwebwaf_rules_loaded", 2)
|
||||||
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_requests_total{action="rule_blocked",status_class="4xx"}`, 1)
|
||||||
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack"}`, 1)
|
||||||
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit"}`, 0)
|
||||||
|
wantMetric(t, metrics, "smallwebwaf_permanent_bans", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRules writes content as a rule file into a new directory, and
|
||||||
|
// returns the directory.
|
||||||
|
func writeRules(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
err := os.WriteFile(filepath.Join(dir, "test.rules"), []byte(content), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write the rule file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantRuleIDs checks the request log line's rule_ids.
|
||||||
|
func wantRuleIDs(t *testing.T, line logLine, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if !slices.Equal(line.RuleIDs, want) {
|
||||||
|
t.Errorf("log line has rule_ids %v, want %v", line.RuleIDs, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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. In enforce mode
|
||||||
|
// a ban rule bans the client's netblock for a clear sign of attack.
|
||||||
|
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:
|
||||||
|
return requestlog.ActionRuleBlocked
|
||||||
|
case rules.ActionBan:
|
||||||
|
if !rq.h.config.Observe {
|
||||||
|
rq.banForAttack(now, last)
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestlog.ActionBanned
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,8 +28,12 @@ const (
|
|||||||
// ActionRateLimited is a request refused because it took its client
|
// ActionRateLimited is a request refused because it took its client
|
||||||
// over a rate limit, which bans the client.
|
// over a rate limit, which bans the client.
|
||||||
ActionRateLimited = "rate_limited"
|
ActionRateLimited = "rate_limited"
|
||||||
// ActionBanned is a request refused because a ban covers its client.
|
// ActionBanned is a request refused because a ban covers its client,
|
||||||
|
// or because it matched a ban rule, which bans the client.
|
||||||
ActionBanned = "banned"
|
ActionBanned = "banned"
|
||||||
|
// ActionRuleBlocked is a request refused because it matched a block
|
||||||
|
// rule.
|
||||||
|
ActionRuleBlocked = "rule_blocked"
|
||||||
// ActionDenied is a request refused because its client is in
|
// ActionDenied is a request refused because its client is in
|
||||||
// SWWAF_DENY_NETS.
|
// SWWAF_DENY_NETS.
|
||||||
ActionDenied = "denied"
|
ActionDenied = "denied"
|
||||||
@@ -107,11 +111,14 @@ type Line struct {
|
|||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
// WouldAction is, in observe mode, the action enforce mode would have
|
// WouldAction is, in observe mode, the action enforce mode would have
|
||||||
// taken with a request it would have refused: ActionDenied,
|
// taken with a request it would have refused: ActionDenied,
|
||||||
// ActionBanned, ActionCountryDenied or ActionRateLimited.
|
// ActionBanned, ActionCountryDenied, ActionRateLimited or
|
||||||
|
// ActionRuleBlocked.
|
||||||
WouldAction string `json:"would_action,omitempty"`
|
WouldAction string `json:"would_action,omitempty"`
|
||||||
// Counts are the client's requests as the rate limits counted them
|
// Counts are the client's requests as the rate limits counted them
|
||||||
// with this one, for a request they counted.
|
// with this one, for a request they counted.
|
||||||
Counts ratelimit.Counts `json:"counts,omitzero"`
|
Counts ratelimit.Counts `json:"counts,omitzero"`
|
||||||
|
// RuleIDs are the ids of the rule file rules the request matched.
|
||||||
|
RuleIDs []string `json:"rule_ids,omitempty"`
|
||||||
// LimitHit is the window whose rate limit the request went over:
|
// LimitHit is the window whose rate limit the request went over:
|
||||||
// minute, hour or day.
|
// minute, hour or day.
|
||||||
LimitHit string `json:"limit_hit,omitempty"`
|
LimitHit string `json:"limit_hit,omitempty"`
|
||||||
|
|||||||
@@ -0,0 +1,459 @@
|
|||||||
|
// Package rules reads the rule files: the plain text files in
|
||||||
|
// SWWAF_RULES_DIR, one rule to a line, that each request is checked
|
||||||
|
// against, as the "Rule files" section of SPEC.md describes. They are read
|
||||||
|
// at start, and again once the directory has had no change for a short
|
||||||
|
// time after one is edited, added or removed.
|
||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The actions a rule takes when it matches.
|
||||||
|
const (
|
||||||
|
// ActionLog notes the match in the request log, and does nothing else.
|
||||||
|
ActionLog = "log"
|
||||||
|
// ActionBlock refuses the request with 403.
|
||||||
|
ActionBlock = "block"
|
||||||
|
// ActionBan refuses the request and bans the client's netblock: the
|
||||||
|
// request is a clear sign of attack.
|
||||||
|
ActionBan = "ban"
|
||||||
|
)
|
||||||
|
|
||||||
|
// extension ends the name of every rule file.
|
||||||
|
const extension = ".rules"
|
||||||
|
|
||||||
|
// quietTime is how long SWWAF_RULES_DIR must go without a change before
|
||||||
|
// the rule files are read again, so that a file still being written, such
|
||||||
|
// as one saved in place, appended to or copied in with scp, is read only
|
||||||
|
// once whole.
|
||||||
|
const quietTime = 2 * time.Second
|
||||||
|
|
||||||
|
// headerTarget starts the target that is one request header,
|
||||||
|
// header:<Name>.
|
||||||
|
const headerTarget = "header:"
|
||||||
|
|
||||||
|
// escapeLength is the length of a percent escape, such as %2e.
|
||||||
|
const escapeLength = 3
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ruleLine is a rule: four fields separated by spaces or tabs, of
|
||||||
|
// which the fourth, the regex, runs to the end of the line.
|
||||||
|
ruleLine = regexp.MustCompile(`^([^ \t]+)[ \t]+([^ \t]+)[ \t]+([^ \t]+)[ \t]+(.+)$`)
|
||||||
|
// idChars are the characters of a rule's id.
|
||||||
|
idChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errNotRule = errors.New(
|
||||||
|
"is not a rule: an id, a target, an action and a regex, " +
|
||||||
|
"separated by spaces or tabs")
|
||||||
|
errNotID = errors.New("is not an id of letters, digits, - and _")
|
||||||
|
errNotTarget = errors.New(
|
||||||
|
"is not path, query, uri, method, host, user_agent, referer or header:<Name>")
|
||||||
|
errHeaderTakenOut = errors.New(
|
||||||
|
"names a header that Go's HTTP server takes out of every request, " +
|
||||||
|
"so a rule never sees it")
|
||||||
|
errNotAction = errors.New("is not log, block or ban")
|
||||||
|
errNotRegex = errors.New("does not compile")
|
||||||
|
errUsedTwice = errors.New("is already the id of the rule at")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rule is one rule of a rule file.
|
||||||
|
type Rule struct {
|
||||||
|
// ID names the rule in the request log, the metrics and ban notes.
|
||||||
|
ID string
|
||||||
|
// Target is what the regex is matched against, such as path or
|
||||||
|
// header:Accept.
|
||||||
|
Target string
|
||||||
|
// Action is ActionLog, ActionBlock or ActionBan.
|
||||||
|
Action string
|
||||||
|
|
||||||
|
regex *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params are what Load needs.
|
||||||
|
type Params struct {
|
||||||
|
// Dir is the directory of the rule files (SWWAF_RULES_DIR).
|
||||||
|
Dir string
|
||||||
|
// Enabled is SWWAF_RULES_ENABLED: while it is false, no file is read
|
||||||
|
// and no rule loaded.
|
||||||
|
Enabled bool
|
||||||
|
// ProcessLog receives how many rules were read, and the error in a
|
||||||
|
// rule file edited while smallwebwaf runs.
|
||||||
|
ProcessLog *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files are the rule files of a running smallwebwaf, and the rules read
|
||||||
|
// from them. They are safe for concurrent use.
|
||||||
|
type Files struct {
|
||||||
|
params Params
|
||||||
|
// rules are the rules loaded, in the order of their files' names, and
|
||||||
|
// then of their lines.
|
||||||
|
rules atomic.Pointer[[]Rule]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the rules of every *.rules file in Dir, in the order of the
|
||||||
|
// files' names, unless Enabled is false. A Dir that cannot be read is an
|
||||||
|
// error, and so is a line that is not a rule, a rule for the Host or the
|
||||||
|
// Transfer-Encoding header, which Go's HTTP server takes out of every
|
||||||
|
// request, a regex that does not compile and an id used twice, each named
|
||||||
|
// with its file and line.
|
||||||
|
func Load(params Params) (*Files, error) {
|
||||||
|
f := &Files{params: params}
|
||||||
|
f.rules.Store(&[]Rule{})
|
||||||
|
|
||||||
|
if !params.Enabled {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, err := read(params.Dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.rules.Store(&rules)
|
||||||
|
f.logRead(len(rules))
|
||||||
|
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match checks r against the rules, in order, and returns those it
|
||||||
|
// matches, up to the first whose action refuses it, block or ban, which
|
||||||
|
// is then the last one returned.
|
||||||
|
func (f *Files) Match(r *http.Request) []Rule {
|
||||||
|
var matched []Rule
|
||||||
|
|
||||||
|
for _, rule := range *f.rules.Load() {
|
||||||
|
if !rule.matches(r) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
matched = append(matched, rule)
|
||||||
|
if rule.Action != ActionLog {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns how many rules are loaded.
|
||||||
|
func (f *Files) Len() int {
|
||||||
|
return len(*f.rules.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch watches Dir until ctx is done, and reads the rule files again
|
||||||
|
// once Dir has had no change for quietTime, after one is edited, added or
|
||||||
|
// removed, and after Watch starts watching. If they then hold an error,
|
||||||
|
// the rules stay as they were, the error is logged with its file and
|
||||||
|
// line, and the files are read again after the next change. If Dir cannot
|
||||||
|
// be watched, that is logged, and the rules stay as they were loaded.
|
||||||
|
// While Enabled is false, Watch returns at once.
|
||||||
|
func (f *Files) Watch(ctx context.Context) {
|
||||||
|
if !f.params.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err == nil {
|
||||||
|
defer func() {
|
||||||
|
_ = watcher.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
err = watcher.Add(f.params.Dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
f.params.ProcessLog.Error("cannot watch the rule files for edits",
|
||||||
|
"error", err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f.params.ProcessLog.Info("watching the rule files for edits",
|
||||||
|
"directory", f.params.Dir)
|
||||||
|
|
||||||
|
f.readAfterChanges(ctx, watcher.Events, watcher.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAfterChanges reads the rule files again once quietTime has passed
|
||||||
|
// without a change from events, until ctx is done, and logs the errors
|
||||||
|
// from errs. The wait starts at once, as if for a change, so that an edit
|
||||||
|
// saved after Load read the files, and before Dir was watched, is taken
|
||||||
|
// in too.
|
||||||
|
func (f *Files) readAfterChanges(
|
||||||
|
ctx context.Context, events <-chan fsnotify.Event, errs <-chan error,
|
||||||
|
) {
|
||||||
|
quiet := time.NewTimer(quietTime)
|
||||||
|
defer quiet.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-events:
|
||||||
|
quiet.Reset(quietTime)
|
||||||
|
case <-quiet.C:
|
||||||
|
f.readAgain()
|
||||||
|
case err := <-errs:
|
||||||
|
f.params.ProcessLog.Warn("watching the rule files failed",
|
||||||
|
"error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAgain reads the rule files again, in place of the rules loaded, or
|
||||||
|
// logs the error that keeps the rules as they were.
|
||||||
|
func (f *Files) readAgain() {
|
||||||
|
rules, err := read(f.params.Dir)
|
||||||
|
if err != nil {
|
||||||
|
f.params.ProcessLog.Error(
|
||||||
|
"a rule file has an error, and the rules stay as they were",
|
||||||
|
"error", err.Error())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f.rules.Store(&rules)
|
||||||
|
f.logRead(len(rules))
|
||||||
|
}
|
||||||
|
|
||||||
|
// logRead logs that the rule files were read, and how many rules they
|
||||||
|
// hold, which can be none.
|
||||||
|
func (f *Files) logRead(count int) {
|
||||||
|
f.params.ProcessLog.Info("read the rule files",
|
||||||
|
"directory", f.params.Dir, "rules", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// read returns the rules of every rule file in dir, in the order of the
|
||||||
|
// files' names, and then of their lines. A file whose name starts with a
|
||||||
|
// dot, such as an editor's lock file .#50-app.rules, is not a rule file,
|
||||||
|
// as a shell's *.rules would not match it.
|
||||||
|
func read(dir string) ([]Rule, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SWWAF_RULES_DIR cannot be read: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules []Rule
|
||||||
|
|
||||||
|
// places are where each id is, as "<file>, line <n>".
|
||||||
|
places := map[string]string{}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if entry.IsDir() || strings.HasPrefix(name, ".") || filepath.Ext(name) != extension {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, err = readFile(filepath.Join(dir, name), rules, places)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFile appends the rules of the rule file at path to rules. places
|
||||||
|
// are where each id read so far is, and gain those of the file.
|
||||||
|
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWWAF_RULES_DIR
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
number := 0
|
||||||
|
|
||||||
|
for line := range strings.Lines(string(data)) {
|
||||||
|
number++
|
||||||
|
place := fmt.Sprintf("%s, line %d", path, number)
|
||||||
|
|
||||||
|
text := strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
|
||||||
|
|
||||||
|
rule, isRule, err := parse(text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", place, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isRule {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
first, used := places[rule.ID]
|
||||||
|
if used {
|
||||||
|
return nil, fmt.Errorf("%s: the id %q %w %s", place, rule.ID, errUsedTwice, first)
|
||||||
|
}
|
||||||
|
|
||||||
|
places[rule.ID] = place
|
||||||
|
rules = append(rules, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse reads a line of a rule file. It returns false for a blank line
|
||||||
|
// and for a comment, a line that starts with #.
|
||||||
|
func parse(line string) (Rule, bool, error) {
|
||||||
|
line = strings.TrimLeft(line, " \t")
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
return Rule{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := ruleLine.FindStringSubmatch(line)
|
||||||
|
if fields == nil {
|
||||||
|
return Rule{}, false, errNotRule
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := Rule{ID: fields[1], Target: fields[2], Action: fields[3]}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !idChars.MatchString(rule.ID):
|
||||||
|
return Rule{}, false, fmt.Errorf("the id %q %w", rule.ID, errNotID)
|
||||||
|
case !isTarget(rule.Target):
|
||||||
|
return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotTarget)
|
||||||
|
case strings.EqualFold(rule.Target, headerTarget+"Host"):
|
||||||
|
return Rule{}, false, fmt.Errorf(
|
||||||
|
"the target %q %w; the request's host is the target host",
|
||||||
|
rule.Target, errHeaderTakenOut)
|
||||||
|
case strings.EqualFold(rule.Target, headerTarget+"Transfer-Encoding"):
|
||||||
|
return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errHeaderTakenOut)
|
||||||
|
case !slices.Contains([]string{ActionLog, ActionBlock, ActionBan}, rule.Action):
|
||||||
|
return Rule{}, false, fmt.Errorf("the action %q %w", rule.Action, errNotAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
regex, err := regexp.Compile(fields[4])
|
||||||
|
if err != nil {
|
||||||
|
return Rule{}, false, fmt.Errorf("the regex %w: %w", errNotRegex, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.regex = regex
|
||||||
|
|
||||||
|
return rule, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTarget reports whether target is one a rule may have.
|
||||||
|
func isTarget(target string) bool {
|
||||||
|
switch target {
|
||||||
|
case "path", "query", "uri", "method", "host", "user_agent", "referer":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
name, isHeader := strings.CutPrefix(target, headerTarget)
|
||||||
|
|
||||||
|
return isHeader && name != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// matches reports whether the rule's regex matches its target in r. For
|
||||||
|
// uri it is matched against the path and query as received, and against
|
||||||
|
// them once percent-decoded, so that an encoded probe cannot slip past.
|
||||||
|
func (rule Rule) matches(r *http.Request) bool {
|
||||||
|
if rule.Target == "uri" {
|
||||||
|
uri := pathAndQuery(r)
|
||||||
|
|
||||||
|
return rule.regex.MatchString(uri) || rule.regex.MatchString(decodeOnce(uri))
|
||||||
|
}
|
||||||
|
|
||||||
|
return rule.regex.MatchString(value(rule.Target, r))
|
||||||
|
}
|
||||||
|
|
||||||
|
// value returns what a rule with target, other than uri, is matched
|
||||||
|
// against in r: the path and the query as the client sent them, before
|
||||||
|
// any decoding or re-encoding, split at the first ?, and a header's values
|
||||||
|
// joined by ", ", as HTTP joins those of a header sent more than once.
|
||||||
|
func value(target string, r *http.Request) string {
|
||||||
|
switch target {
|
||||||
|
case "path":
|
||||||
|
path, _, _ := strings.Cut(pathAndQuery(r), "?")
|
||||||
|
|
||||||
|
return path
|
||||||
|
case "query":
|
||||||
|
_, query, _ := strings.Cut(pathAndQuery(r), "?")
|
||||||
|
|
||||||
|
return query
|
||||||
|
case "method":
|
||||||
|
return r.Method
|
||||||
|
case "host":
|
||||||
|
return r.Host
|
||||||
|
case "user_agent":
|
||||||
|
return header(r, "User-Agent")
|
||||||
|
case "referer":
|
||||||
|
return header(r, "Referer")
|
||||||
|
default:
|
||||||
|
return header(r, strings.TrimPrefix(target, headerTarget))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathAndQuery returns the target of r's request line, r.RequestURI, as
|
||||||
|
// the client sent it, less any scheme and host: a target with a scheme
|
||||||
|
// gives what follows the scheme and its :, and the host when // follows.
|
||||||
|
// So http://host/path, as a client sends it to a proxy, gives /path, and
|
||||||
|
// so does http:/path, which Go reads as a target with a scheme and no
|
||||||
|
// host. r.URL is not used: when the path holds a character it escapes,
|
||||||
|
// such as \ or a non-ASCII byte, it decodes the whole path and escapes it
|
||||||
|
// again, so that \ becomes %5C and %2e a dot.
|
||||||
|
func pathAndQuery(r *http.Request) string {
|
||||||
|
if !r.URL.IsAbs() {
|
||||||
|
return r.RequestURI
|
||||||
|
}
|
||||||
|
|
||||||
|
_, afterScheme, _ := strings.Cut(r.RequestURI, ":")
|
||||||
|
|
||||||
|
hostAndRest, hasHost := strings.CutPrefix(afterScheme, "//")
|
||||||
|
if !hasHost {
|
||||||
|
return afterScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
start := strings.IndexAny(hostAndRest, "/?")
|
||||||
|
if start < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return hostAndRest[start:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// header returns the values of r's header name joined by ", ", or "" if
|
||||||
|
// r has no such header.
|
||||||
|
func header(r *http.Request, name string) string {
|
||||||
|
return strings.Join(r.Header.Values(name), ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeOnce returns s with each percent escape, such as %2e, replaced by
|
||||||
|
// the byte it stands for. A % that is not followed by two hex digits is
|
||||||
|
// left as it is, so that a malformed escape cannot keep the rest of s
|
||||||
|
// from being decoded.
|
||||||
|
func decodeOnce(s string) string {
|
||||||
|
var decoded strings.Builder
|
||||||
|
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '%' && i+escapeLength <= len(s) {
|
||||||
|
b, err := hex.DecodeString(s[i+1 : i+escapeLength])
|
||||||
|
if err == nil {
|
||||||
|
decoded.Write(b)
|
||||||
|
|
||||||
|
i += escapeLength - 1
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded.WriteByte(s[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
package rules_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// What the process log says once Watch watches the directory, after
|
||||||
|
// each reading of the rule files, and for one that has an error.
|
||||||
|
watching = "watching the rule files for edits"
|
||||||
|
read = "read the rule files"
|
||||||
|
hasError = "a rule file has an error, and the rules stay as they were"
|
||||||
|
// maxLogLines is how many lines of the process log wait for a test to
|
||||||
|
// read them.
|
||||||
|
maxLogLines = 64
|
||||||
|
// browser is the user agent of an ordinary visitor.
|
||||||
|
browser = "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
|
||||||
|
// testFile is the rule file of a test that needs only one, and
|
||||||
|
// firstFile the first of a test's rule files.
|
||||||
|
testFile = "test.rules"
|
||||||
|
firstFile = "00-a.rules"
|
||||||
|
// userAgent is the header that carries the user agent.
|
||||||
|
userAgent = "User-Agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEachTargetMatchesWhatItNames(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
rule string // its target, action and regex
|
||||||
|
uri string // the request's path and query
|
||||||
|
header http.Header
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"path as received", `path log ^/%2eenv$`, "/%2eenv", nil, true},
|
||||||
|
{"path not decoded", `path log ^/\.env$`, "/%2eenv", nil, false},
|
||||||
|
{"path without the query", `path log ^/a$`, "/a?b=c", nil, true},
|
||||||
|
{"query as received", `query log ^b=%2e$`, "/a?b=%2e", nil, true},
|
||||||
|
{"uri as received", `uri log ^/a\?b=%2e$`, "/a?b=%2e", nil, true},
|
||||||
|
{"uri decoded", `uri log (\.\./){2}`, "/a?f=%2e%2e%2f%2e%2e%2f", nil, true},
|
||||||
|
{
|
||||||
|
"uri decoded past malformed escapes", `uri log (\.\./){2}&h=%$`,
|
||||||
|
"/a?g=%zz&f=%2e%2e%2f%2e%2e%2f&h=%", nil, true,
|
||||||
|
},
|
||||||
|
{"uri decoded only once", `uri log ^/a\.b$`, "/a%252eb", nil, false},
|
||||||
|
{"method", `method log ^PUT$`, "/", nil, true},
|
||||||
|
{"host", `host log ^app\.example$`, "/", nil, true},
|
||||||
|
{
|
||||||
|
"user_agent", `user_agent log ^sqlmap/`, "/",
|
||||||
|
http.Header{userAgent: {"sqlmap/1.8"}}, true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_agent sent twice", `user_agent log ^curl/8, sqlmap/`, "/",
|
||||||
|
http.Header{userAgent: {"curl/8", "sqlmap/1.8"}}, true,
|
||||||
|
},
|
||||||
|
{"user_agent missing", `user_agent log ^$`, "/", nil, true},
|
||||||
|
{
|
||||||
|
"referer", `referer log ^https://spam\.example/`, "/",
|
||||||
|
http.Header{"Referer": {"https://spam.example/buy"}}, true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a header sent twice", `header:x-api-version log ^2, 3$`, "/",
|
||||||
|
http.Header{"X-Api-Version": {"2", "3"}}, true,
|
||||||
|
},
|
||||||
|
{"a header missing", `header:X-Api-Version log ^$`, "/", nil, true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
files := load(t, ruleFiles{testFile: "a-rule " + tc.rule + "\n"})
|
||||||
|
|
||||||
|
// Every request is a PUT, which the method rule looks for.
|
||||||
|
r := httptest.NewRequestWithContext(t.Context(), http.MethodPut,
|
||||||
|
"http://app.example"+tc.uri, nil)
|
||||||
|
maps.Copy(r.Header, tc.header)
|
||||||
|
|
||||||
|
got := len(files.Match(r)) == 1
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("%s matches %s: %t, want %t", tc.rule, tc.uri, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathMatchedAsTheClientSentIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Each path holds a character Go's URL type would escape again, \ or
|
||||||
|
// a non-ASCII byte, and each rule is written for the path as sent.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
rule string // its target, action and regex
|
||||||
|
sent string // the path and query the client sent
|
||||||
|
}{
|
||||||
|
{`path log ^/\.\.\\\.\.\\windows\\win\.ini$`, `/..\..\windows\win.ini`},
|
||||||
|
{`path log ^/%2e%2e\\%2e%2e\\windows\\win\.ini$`, `/%2e%2e\%2e%2e\windows\win.ini`},
|
||||||
|
{`path log ^/café$`, "/café?x=1"},
|
||||||
|
{`uri log ^/%2e%2e\\%2e%2e\\boot\.ini\?x=1$`, `/%2e%2e\%2e%2e\boot.ini?x=1`},
|
||||||
|
} {
|
||||||
|
files := load(t, ruleFiles{testFile: "as-sent " + tc.rule + "\n"})
|
||||||
|
|
||||||
|
// The target in origin form, as traefik sends it, in absolute form,
|
||||||
|
// as a client sends it to a proxy, and with a scheme but no host,
|
||||||
|
// which Go reads as absolute form with no host, sending the app
|
||||||
|
// the path.
|
||||||
|
for _, target := range []string{
|
||||||
|
tc.sent, "http://app.example" + tc.sent, "http:" + tc.sent, "foo:" + tc.sent,
|
||||||
|
} {
|
||||||
|
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
||||||
|
wantMatched(t, files, r, "as-sent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchingStopsAtTheFirstRuleThatRefuses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
files := load(t, ruleFiles{testFile: `
|
||||||
|
every-path path log ^/
|
||||||
|
no-path path log ^$
|
||||||
|
first-refusal path block ^/probe
|
||||||
|
later-ban path ban ^/probe
|
||||||
|
after path log ^/
|
||||||
|
`})
|
||||||
|
|
||||||
|
// Every log rule that matches is noted, and the block rule ends the
|
||||||
|
// matching.
|
||||||
|
wantMatched(t, files, get(t, "/probe"), "every-path", "first-refusal")
|
||||||
|
wantMatched(t, files, get(t, "/page"), "every-path", "after")
|
||||||
|
|
||||||
|
// A ban rule ends it too.
|
||||||
|
files = load(t, ruleFiles{testFile: "ban path ban ^/\nlater path block ^/\n"})
|
||||||
|
wantMatched(t, files, get(t, "/"), "ban")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesReadInNameOrderThenLineOrder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
files := load(t, ruleFiles{
|
||||||
|
"50-b.rules": "b1 path log ^/\n\n# a comment\n # an indented one\nb2 path log ^/\n",
|
||||||
|
firstFile: "a1 path log ^/\r\n",
|
||||||
|
// None is a rule file.
|
||||||
|
"notes.txt": "notes, not rules\n",
|
||||||
|
"10-c.rules.bak": "an old copy\n",
|
||||||
|
"20-d.rules/keep": "a file in a directory\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
wantMatched(t, files, get(t, "/"), "a1", "b1", "b2")
|
||||||
|
|
||||||
|
if files.Len() != 3 {
|
||||||
|
t.Errorf("%d rules loaded, want 3", files.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileWhoseNameStartsWithADotIsNotARuleFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := writeFiles(t, ruleFiles{firstFile: "probe path block ^/probe\n"})
|
||||||
|
|
||||||
|
// The lock file Emacs makes beside a file while it is edited: a link to
|
||||||
|
// nothing, which cannot be read.
|
||||||
|
err := os.Symlink("user@host.1234:1700000000", filepath.Join(dir, ".#"+firstFile))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
params, _ := newParams(dir)
|
||||||
|
|
||||||
|
files, err := rules.Load(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantMatched(t, files, get(t, "/probe"), "probe")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
line int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"too few fields", "env-file path ban\n", 1,
|
||||||
|
"is not a rule: an id, a target, an action and a regex, " +
|
||||||
|
"separated by spaces or tabs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"an id of other characters", "# ids\n\nenv.file path ban ^/\n", 3,
|
||||||
|
`the id "env.file" is not an id of letters, digits, - and _`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"an unknown target", "env-file paths ban ^/\n", 1,
|
||||||
|
`the target "paths" is not path, query, uri, method, host, ` +
|
||||||
|
"user_agent, referer or header:<Name>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a header without a name", "env-file header: ban ^/\n", 1,
|
||||||
|
`the target "header:" is not path, query, uri, method, host, ` +
|
||||||
|
"user_agent, referer or header:<Name>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"the Host header", "host-header header:host block ^$\n", 1,
|
||||||
|
`the target "header:host" names a header that Go's HTTP server ` +
|
||||||
|
"takes out of every request, so a rule never sees it; " +
|
||||||
|
"the request's host is the target host",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"the Transfer-Encoding header",
|
||||||
|
"# bodies sent in chunks\nchunked header:Transfer-Encoding block ^chunked$\n", 2,
|
||||||
|
`the target "header:Transfer-Encoding" names a header that Go's ` +
|
||||||
|
"HTTP server takes out of every request, so a rule never sees it",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"an unknown action", "env-file path deny ^/\n", 1,
|
||||||
|
`the action "deny" is not log, block or ban`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a regex that does not compile", "env-file path ban ^/(\n", 1,
|
||||||
|
"the regex does not compile: error parsing regexp: " +
|
||||||
|
"missing closing ): `^/(`",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := writeFiles(t, ruleFiles{"00-default.rules": tc.content})
|
||||||
|
path := filepath.Join(dir, "00-default.rules")
|
||||||
|
|
||||||
|
wantRefused(t, dir, path+", line "+strconv.Itoa(tc.line)+": "+tc.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIDUsedTwiceStopsTheStartNamingBothPlaces(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := writeFiles(t, ruleFiles{
|
||||||
|
"00-a.rules": "probe path log ^/a\n",
|
||||||
|
"50-b.rules": "other path log ^/b\nprobe path ban ^/c\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
wantRefused(t, dir, filepath.Join(dir, "50-b.rules")+`, line 2: the id "probe" `+
|
||||||
|
"is already the id of the rule at "+filepath.Join(dir, "00-a.rules")+", line 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||||
|
|
||||||
|
wantRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+
|
||||||
|
": no such file or directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
params, lines := newParams(writeFiles(t, ruleFiles{"00-default.rules": "# none\n"}))
|
||||||
|
|
||||||
|
files, err := rules.Load(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := lines.waitFor(t, read)
|
||||||
|
if files.Len() != 0 || line["rules"] != 0.0 {
|
||||||
|
t.Errorf("%d rules loaded, and the log says %v, want none", files.Len(), line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleFilesOffReadNothing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// SWWAF_RULES_DIR does not exist, which would stop the start.
|
||||||
|
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
||||||
|
params.Enabled = false
|
||||||
|
|
||||||
|
files, err := rules.Load(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if files.Len() != 0 || files.Match(get(t, "/")) != nil {
|
||||||
|
t.Errorf("%d rules loaded with the rule files off", files.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// It would watch until the test ends.
|
||||||
|
files.Watch(t.Context())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditsTakenInWhileRunning(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
|
||||||
|
files, lines := watch(t, dir)
|
||||||
|
|
||||||
|
// matches reports whether path matches a rule.
|
||||||
|
matches := func(path string) bool { return len(files.Match(get(t, path))) == 1 }
|
||||||
|
|
||||||
|
// A file added.
|
||||||
|
save(t, dir, "50-b.rules", "second path block ^/second\n")
|
||||||
|
lines.waitUntil(t, func() bool { return matches("/second") })
|
||||||
|
wantMatched(t, files, get(t, "/first"), "first")
|
||||||
|
|
||||||
|
// A file edited.
|
||||||
|
save(t, dir, firstFile, "first path block ^/edited\n")
|
||||||
|
lines.waitUntil(t, func() bool { return !matches("/first") })
|
||||||
|
wantMatched(t, files, get(t, "/edited"), "first")
|
||||||
|
|
||||||
|
// A file removed.
|
||||||
|
err := os.Remove(filepath.Join(dir, "50-b.rules"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("remove: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.waitUntil(t, func() bool { return !matches("/second") })
|
||||||
|
wantMatched(t, files, get(t, "/edited"), "first")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
|
||||||
|
files, lines := watch(t, dir)
|
||||||
|
|
||||||
|
// The edit's second line has an unknown action, so the rules stay as
|
||||||
|
// they were, the first line's earlier version included.
|
||||||
|
save(t, dir, firstFile, "first path block ^/edited\nsecond path bann ^/second\n")
|
||||||
|
|
||||||
|
line := lines.waitFor(t, hasError)
|
||||||
|
want := filepath.Join(dir, firstFile) +
|
||||||
|
`, line 2: the action "bann" is not log, block or ban`
|
||||||
|
|
||||||
|
if line["error"] != want || line["level"] != "ERROR" {
|
||||||
|
t.Errorf("logged %v, want an error %q", line, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantMatched(t, files, get(t, "/first"), "first")
|
||||||
|
wantMatched(t, files, get(t, "/second"))
|
||||||
|
|
||||||
|
// Once mended, the file is read again.
|
||||||
|
save(t, dir, firstFile, "first path block ^/edited\nsecond path ban ^/second\n")
|
||||||
|
lines.waitUntil(t, func() bool { return len(files.Match(get(t, "/second"))) == 1 })
|
||||||
|
wantMatched(t, files, get(t, "/edited"), "first")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultFileBansProbesAtTheSiteRootAlone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
params, _ := newParams(filepath.Join("..", "..", "share", "rules.d"))
|
||||||
|
|
||||||
|
files, err := rules.Load(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load the default file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probes sent by a browser, by the rule that refuses them.
|
||||||
|
for rule, targets := range map[string][]string{
|
||||||
|
"env-file": {"/.env", "/.env.production", "/.ENV"},
|
||||||
|
"vcs-dir": {"/.git/config", "/.git", "/.svn/entries"},
|
||||||
|
"secrets-dir": {"/.aws/credentials", "/.ssh/id_rsa"},
|
||||||
|
"secret-file": {"/.htpasswd", "/.DS_Store", "/.git-credentials"},
|
||||||
|
"editor-dir": {"/.vscode/sftp.json"},
|
||||||
|
"backup-file": {
|
||||||
|
"/wp-config.php.bak", "/index.php~", "/dump.sql", "/backup.sql.gz",
|
||||||
|
},
|
||||||
|
"log-file": {"/debug.log"},
|
||||||
|
"compose-file": {"/docker-compose.yml", "/compose.yaml"},
|
||||||
|
"php-shell": {"/shell.php"},
|
||||||
|
"path-traversal": {
|
||||||
|
"/static/../../etc/passwd", "/f?f=%2e%2e%2f%2e%2e%2fetc%2fpasswd",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
for _, target := range targets {
|
||||||
|
wantRefusedBy(t, files, target, browser, rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scanners, by their user agents.
|
||||||
|
for _, scanner := range []string{
|
||||||
|
"sqlmap/1.8.4#stable (https://sqlmap.org)",
|
||||||
|
"Mozilla/5.0 (compatible; Nuclei - Open-source project)",
|
||||||
|
} {
|
||||||
|
wantRefusedBy(t, files, "/", scanner, "scanner-agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordinary requests to a code forge for files of those names deeper
|
||||||
|
// in its paths, and for other files at its root.
|
||||||
|
for _, target := range []string{
|
||||||
|
"/owner/repo/src/branch/main/.env.example",
|
||||||
|
"/owner/repo/src/branch/main/.env",
|
||||||
|
"/owner/repo/src/branch/main/.github/workflows/ci.yml",
|
||||||
|
"/owner/repo/src/branch/main/.vscode/settings.json",
|
||||||
|
"/owner/repo/src/branch/main/.htaccess",
|
||||||
|
"/owner/repo/src/branch/main/docker-compose.yml",
|
||||||
|
"/owner/repo/src/branch/main/db/schema.sql",
|
||||||
|
"/owner/repo/raw/branch/main/debug.log",
|
||||||
|
"/owner/repo.git/info/refs?service=git-upload-pack",
|
||||||
|
"/owner/repo/src/branch/main/docs/../README.md",
|
||||||
|
"/user/login?redirect_to=%2fowner%2frepo",
|
||||||
|
"/index.php",
|
||||||
|
"/.well-known/security.txt",
|
||||||
|
} {
|
||||||
|
r := get(t, target)
|
||||||
|
r.Header.Set(userAgent, browser)
|
||||||
|
|
||||||
|
matched := files.Match(r)
|
||||||
|
if len(matched) != 0 {
|
||||||
|
t.Errorf("%s matched %v, want no rule", target, ids(matched))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request without a user agent is only noted.
|
||||||
|
wantMatched(t, files, get(t, "/"), "empty-agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruleFiles are files to write into a directory of rule files, by name.
|
||||||
|
type ruleFiles map[string]string
|
||||||
|
|
||||||
|
// writeFiles writes files into a new directory, and returns it.
|
||||||
|
func writeFiles(t *testing.T, files ruleFiles) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
for name, content := range files {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
|
||||||
|
err := os.MkdirAll(filepath.Dir(path), 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(path, []byte(content), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// save writes content to the rule file name in dir as an editor that
|
||||||
|
// saves by renaming does, so that the file is never seen half written.
|
||||||
|
func save(t *testing.T, dir, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
|
||||||
|
err := os.WriteFile(path+".tmp", []byte(content), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Rename(path+".tmp", path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rename: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newParams returns Params for the rule files in dir, switched on, with
|
||||||
|
// the process log in the processLog returned.
|
||||||
|
func newParams(dir string) (rules.Params, processLog) {
|
||||||
|
lines := make(processLog, maxLogLines)
|
||||||
|
|
||||||
|
return rules.Params{
|
||||||
|
Dir: dir,
|
||||||
|
Enabled: true,
|
||||||
|
ProcessLog: slog.New(slog.NewJSONHandler(lines, nil)),
|
||||||
|
}, lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// load writes files into a new directory and loads the rules in it.
|
||||||
|
func load(t *testing.T, files ruleFiles) *rules.Files {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
params, _ := newParams(writeFiles(t, files))
|
||||||
|
params.ProcessLog = slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
loaded, err := rules.Load(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
// watch loads the rules in dir, runs their Watch until the test ends, and
|
||||||
|
// waits until it watches the directory.
|
||||||
|
func watch(t *testing.T, dir string) (*rules.Files, processLog) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
params, lines := newParams(dir)
|
||||||
|
|
||||||
|
files, err := rules.Load(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
files.Watch(ctx)
|
||||||
|
close(stopped)
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
stop()
|
||||||
|
<-stopped
|
||||||
|
})
|
||||||
|
|
||||||
|
lines.waitFor(t, watching)
|
||||||
|
|
||||||
|
return files, lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantRefused checks that loading the rule files in dir fails with the
|
||||||
|
// error want.
|
||||||
|
func wantRefused(t *testing.T, dir, want string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
params, _ := newParams(dir)
|
||||||
|
|
||||||
|
_, err := rules.Load(params)
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("error %v, want %s", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get returns a GET request for target, a path and an optional query, as
|
||||||
|
// smallwebwaf's server reads it, without a user agent.
|
||||||
|
func get(t *testing.T, target string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"http://app.example"+target, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantRefusedBy checks that a GET request for target with the user agent
|
||||||
|
// sent matches rule alone, and that rule refuses it.
|
||||||
|
func wantRefusedBy(t *testing.T, files *rules.Files, target, sent, rule string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
r := get(t, target)
|
||||||
|
r.Header.Set(userAgent, sent)
|
||||||
|
|
||||||
|
matched := files.Match(r)
|
||||||
|
if len(matched) != 1 || matched[0].ID != rule || matched[0].Action == rules.ActionLog {
|
||||||
|
t.Errorf("%s from %q matched %v, want %s alone, refusing it", target,
|
||||||
|
sent, ids(matched), rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantMatched checks the ids of the rules r matches, in order.
|
||||||
|
func wantMatched(t *testing.T, files *rules.Files, r *http.Request, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got := ids(files.Match(r))
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("%s matched %v, want %v", r.URL, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ids returns the ids of matched.
|
||||||
|
func ids(matched []rules.Rule) []string {
|
||||||
|
got := make([]string, 0, len(matched))
|
||||||
|
for _, rule := range matched {
|
||||||
|
got = append(got, rule.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// processLog receives the lines of a process log, each a JSON object, for
|
||||||
|
// a test to wait for.
|
||||||
|
type processLog chan string
|
||||||
|
|
||||||
|
// Write receives a line of the process log.
|
||||||
|
func (l processLog) Write(line []byte) (int, error) {
|
||||||
|
l <- string(line)
|
||||||
|
|
||||||
|
return len(line), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitFor returns the next line of the process log whose message is msg,
|
||||||
|
// passing over the lines before it. It waits as long as that takes, so
|
||||||
|
// that a slow test process cannot fail the test.
|
||||||
|
func (l processLog) waitFor(t *testing.T, msg string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for line := range l {
|
||||||
|
var fields map[string]any
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(line), &fields)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("process log line %q is not JSON: %v", line, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields["msg"] == msg {
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitUntil waits for the rule files to be read until done reports true,
|
||||||
|
// as it does once they have been read after the test's last change. They
|
||||||
|
// can be read before then too, as they are once Watch starts watching.
|
||||||
|
func (l processLog) waitUntil(t *testing.T, done func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for !done() {
|
||||||
|
l.waitFor(t, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The tests below run readAfterChanges in a synctest bubble, where time is
|
||||||
|
// a clock of the test's own: time.Sleep moves it on at once, and
|
||||||
|
// synctest.Wait returns once readAfterChanges waits again, so that every
|
||||||
|
// reading due by then is done. The test sends the changes itself, as the
|
||||||
|
// watch of a directory cannot run in a bubble.
|
||||||
|
|
||||||
|
func TestFileWrittenInTwoPartsTakenInOnlyWhole(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "50-app.rules")
|
||||||
|
writeFile(t, path, "first path block ^/first\n")
|
||||||
|
files := load(t, dir)
|
||||||
|
changes := run(t, files)
|
||||||
|
|
||||||
|
file, err := os.Create(path) //nolint:gosec // a file the test wrote
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = file.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// The first part ends in the middle of a ban rule's regex, which,
|
||||||
|
// read then, would ban every request.
|
||||||
|
write(t, file, "first path block ^/first\nprobe path ban ^/")
|
||||||
|
|
||||||
|
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
||||||
|
|
||||||
|
time.Sleep(quietTime - time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantMatched(t, files, "/anything")
|
||||||
|
|
||||||
|
// The second part starts the wait again.
|
||||||
|
write(t, file, `\.env$`+"\n")
|
||||||
|
|
||||||
|
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
||||||
|
|
||||||
|
time.Sleep(quietTime - time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantMatched(t, files, "/.env")
|
||||||
|
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantMatched(t, files, "/.env", "probe")
|
||||||
|
wantMatched(t, files, "/anything")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditSavedBeforeTheWatchStartsTakenIn(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "50-app.rules")
|
||||||
|
writeFile(t, path, "first path block ^/first\n")
|
||||||
|
files := load(t, dir)
|
||||||
|
|
||||||
|
// Saved after Load read the files, and before the directory was
|
||||||
|
// watched, so that no change is seen for it.
|
||||||
|
writeFile(t, path, "first path block ^/edited\n")
|
||||||
|
run(t, files)
|
||||||
|
time.Sleep(quietTime)
|
||||||
|
synctest.Wait()
|
||||||
|
wantMatched(t, files, "/edited", "first")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// load loads the rules in dir.
|
||||||
|
func load(t *testing.T, dir string) *Files {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
files, err := Load(Params{
|
||||||
|
Dir: dir, Enabled: true, ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// run runs files' readAfterChanges until the test ends, and returns the
|
||||||
|
// channel that sends it changes.
|
||||||
|
func run(t *testing.T, files *Files) chan<- fsnotify.Event {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
changes := make(chan fsnotify.Event)
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
files.readAfterChanges(ctx, changes, nil)
|
||||||
|
close(stopped)
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
stop()
|
||||||
|
<-stopped
|
||||||
|
})
|
||||||
|
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFile writes content to the file at path.
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(content), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write writes text to the end of file.
|
||||||
|
func write(t *testing.T, file *os.File, text string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := file.WriteString(text)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantMatched checks the ids of the rules that a GET request for path
|
||||||
|
// matches, in order.
|
||||||
|
func wantMatched(t *testing.T, files *Files, path string, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"http://app.example"+path, nil)
|
||||||
|
|
||||||
|
matched := files.Match(r)
|
||||||
|
|
||||||
|
got := make([]string, 0, len(matched))
|
||||||
|
for _, rule := range matched {
|
||||||
|
got = append(got, rule.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("%s matched %v, want %v", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ func TestHealthCheck(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
upstreamURL: app.URL,
|
upstreamURL: app.URL,
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
|
rulesDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package smallwebwaf runs the smallwebwaf process: it reads the settings
|
// Package smallwebwaf runs the smallwebwaf process: it reads the settings,
|
||||||
// and the state files, serves requests until it is told to stop, and then
|
// the rule files and the state files, serves requests until it is told to
|
||||||
// stops in an orderly way, writing the state files.
|
// stop, and then stops in an orderly way, writing the state files.
|
||||||
package smallwebwaf
|
package smallwebwaf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,9 +57,9 @@ func Main(version string) int {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run reads the settings and the state files, then serves requests until
|
// Run reads the settings, the rule files and the state files, then serves
|
||||||
// ctx is done. It returns the process's exit status, 1 when smallwebwaf
|
// requests until ctx is done. It returns the process's exit status, 1
|
||||||
// cannot start.
|
// when smallwebwaf cannot start.
|
||||||
func Run(ctx context.Context, params Params) int {
|
func Run(ctx context.Context, params Params) int {
|
||||||
processLog := requestlog.NewProcessLogger(params.Stdout)
|
processLog := requestlog.NewProcessLogger(params.Stdout)
|
||||||
|
|
||||||
@@ -69,6 +70,17 @@ func Run(ctx context.Context, params Params) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ruleFiles, err := rules.Load(rules.Params{
|
||||||
|
Dir: cfg.RulesDir,
|
||||||
|
Enabled: cfg.RulesEnabled,
|
||||||
|
ProcessLog: processLog,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
processLog.Error("cannot use the rule files", "error", err.Error())
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
// The state files give times in UTC.
|
// The state files give times in UTC.
|
||||||
now := func() time.Time { return time.Now().UTC() }
|
now := func() time.Time { return time.Now().UTC() }
|
||||||
|
|
||||||
@@ -78,6 +90,7 @@ func Run(ctx context.Context, params Params) int {
|
|||||||
ProcessLog: processLog,
|
ProcessLog: processLog,
|
||||||
GeoJSURL: lookup.URL,
|
GeoJSURL: lookup.URL,
|
||||||
Now: now,
|
Now: now,
|
||||||
|
Rules: ruleFiles,
|
||||||
})
|
})
|
||||||
|
|
||||||
files, err := state.Load(state.Params{
|
files, err := state.Load(state.Params{
|
||||||
@@ -110,16 +123,16 @@ func Run(ctx context.Context, params Params) int {
|
|||||||
"address", listener.Addr().String(),
|
"address", listener.Addr().String(),
|
||||||
"settings", cfg)
|
"settings", cfg)
|
||||||
|
|
||||||
return serve(ctx, server.Server, listener, files, processLog)
|
return serve(ctx, server.Server, listener, files, ruleFiles, processLog)
|
||||||
}
|
}
|
||||||
|
|
||||||
// serve serves requests on listener, writes the state files as they are
|
// serve serves requests on listener, writes the state files as they are
|
||||||
// due, and takes in an admin's edits of them, until ctx is done. Then it
|
// due, takes in an admin's edits of them, and reads the rule files again
|
||||||
// gives the requests in progress shutdownTimeout to finish, and writes
|
// as they change, until ctx is done. Then it gives the requests in
|
||||||
// every state file.
|
// progress shutdownTimeout to finish, and writes every state file.
|
||||||
func serve(
|
func serve(
|
||||||
ctx context.Context, server *http.Server, listener net.Listener,
|
ctx context.Context, server *http.Server, listener net.Listener,
|
||||||
files *state.Files, processLog *slog.Logger,
|
files *state.Files, ruleFiles *rules.Files, processLog *slog.Logger,
|
||||||
) int {
|
) int {
|
||||||
served := make(chan error, 1)
|
served := make(chan error, 1)
|
||||||
|
|
||||||
@@ -132,6 +145,7 @@ func serve(
|
|||||||
|
|
||||||
written := make(chan struct{})
|
written := make(chan struct{})
|
||||||
watched := make(chan struct{})
|
watched := make(chan struct{})
|
||||||
|
rulesWatched := make(chan struct{})
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
files.Run(writing)
|
files.Run(writing)
|
||||||
@@ -143,6 +157,11 @@ func serve(
|
|||||||
close(watched)
|
close(watched)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ruleFiles.Watch(writing)
|
||||||
|
close(rulesWatched)
|
||||||
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case err := <-served:
|
case err := <-served:
|
||||||
processLog.Error("serving failed", "error", err.Error())
|
processLog.Error("serving failed", "error", err.Error())
|
||||||
@@ -181,6 +200,7 @@ func serve(
|
|||||||
// missing from clients.json.
|
// missing from clients.json.
|
||||||
<-written
|
<-written
|
||||||
<-watched
|
<-watched
|
||||||
|
<-rulesWatched
|
||||||
|
|
||||||
err = files.WriteAll()
|
err = files.WriteAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const (
|
|||||||
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
||||||
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
||||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||||
|
rulesDir = "SWWAF_RULES_DIR"
|
||||||
// greeting is what the tests' app answers.
|
// greeting is what the tests' app answers.
|
||||||
greeting = "hello from the app"
|
greeting = "hello from the app"
|
||||||
)
|
)
|
||||||
@@ -163,6 +164,7 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
|
|||||||
status := run(t.Context(), map[string]string{
|
status := run(t.Context(), map[string]string{
|
||||||
listenAddr: taken.Addr().String(),
|
listenAddr: taken.Addr().String(),
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
|
rulesDir: t.TempDir(),
|
||||||
}, out)
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
t.Errorf("exit status %d, want 1", status)
|
t.Errorf("exit status %d, want 1", status)
|
||||||
@@ -186,9 +188,15 @@ func TestServesUntilToldToStop(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
upstreamURL: appURL,
|
upstreamURL: appURL,
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
|
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||||
}, out)
|
}, out)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// The default rule file is read.
|
||||||
|
if rules := out.line(t, "msg", "read the rule files")["rules"]; rules != 12.0 {
|
||||||
|
t.Errorf("read %v rules from the default rule file, want 12", rules)
|
||||||
|
}
|
||||||
|
|
||||||
starting := out.line(t, "msg", "starting")
|
starting := out.line(t, "msg", "starting")
|
||||||
wantStartingLine(t, starting, appURL, dir)
|
wantStartingLine(t, starting, appURL, dir)
|
||||||
|
|
||||||
@@ -217,6 +225,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
upstreamURL: startApp(t),
|
upstreamURL: startApp(t),
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
|
rulesDir: t.TempDir(),
|
||||||
rateLimitPerDay: "2",
|
rateLimitPerDay: "2",
|
||||||
// Neither comes due in the test: the files are written as
|
// Neither comes due in the test: the files are written as
|
||||||
// smallwebwaf stops.
|
// smallwebwaf stops.
|
||||||
@@ -253,6 +262,7 @@ func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
upstreamURL: startApp(t),
|
upstreamURL: startApp(t),
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
|
rulesDir: t.TempDir(),
|
||||||
trustedProxies: localhost + "/32",
|
trustedProxies: localhost + "/32",
|
||||||
rateLimitPerDay: "1",
|
rateLimitPerDay: "1",
|
||||||
scope: "24",
|
scope: "24",
|
||||||
@@ -300,6 +310,7 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
upstreamURL: startApp(t),
|
upstreamURL: startApp(t),
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
|
rulesDir: t.TempDir(),
|
||||||
trustedProxies: localhost + "/32",
|
trustedProxies: localhost + "/32",
|
||||||
// No write comes due in the test, so only the watch on the
|
// No write comes due in the test, so only the watch on the
|
||||||
// directory can take the edits in.
|
// directory can take the edits in.
|
||||||
@@ -316,6 +327,64 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
env := map[string]string{
|
||||||
|
listenAddr: localhost + ":0",
|
||||||
|
upstreamURL: startApp(t),
|
||||||
|
stateDir: t.TempDir(),
|
||||||
|
rulesDir: dir,
|
||||||
|
// The requests sent until the rule takes effect must not break a
|
||||||
|
// rate limit, whose ban would refuse them too.
|
||||||
|
"SWWAF_RATE_LIMIT_PER_MINUTE": "off",
|
||||||
|
}
|
||||||
|
|
||||||
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
|
wantGreeting(t, url)
|
||||||
|
|
||||||
|
// Written once: each change would start the rule files' wait
|
||||||
|
// again. A file written before smallwebwaf watches the directory is
|
||||||
|
// read once it does.
|
||||||
|
err := os.WriteFile(filepath.Join(dir, "50-app.rules"),
|
||||||
|
[]byte("everything path block ^/\n"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write the rule file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// As long as that takes, so that a slow test process cannot fail
|
||||||
|
// the test.
|
||||||
|
for statusFrom(t, url, "203.0.113.9") != http.StatusForbidden {
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
out.line(t, "action", "rule_blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "00-default.rules")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte("# probes\nenv-file path bann ^/\\.env$\n"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write the rule file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantRulesRefused(t, dir, path+`, line 2: the action "bann" is not log, block or ban`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||||
|
|
||||||
|
wantRulesRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+
|
||||||
|
": no such file or directory")
|
||||||
|
}
|
||||||
|
|
||||||
func TestStateFileThatDoesNotParseStopsTheStart(t *testing.T) {
|
func TestStateFileThatDoesNotParseStopsTheStart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -349,7 +418,9 @@ func wantStartRefused(t *testing.T, dir, want string) {
|
|||||||
|
|
||||||
out := &output{}
|
out := &output{}
|
||||||
|
|
||||||
status := run(ctx, map[string]string{listenAddr: localhost + ":0", stateDir: dir}, out)
|
status := run(ctx, map[string]string{
|
||||||
|
listenAddr: localhost + ":0", stateDir: dir, rulesDir: t.TempDir(),
|
||||||
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
t.Fatalf("exit status %d, want 1", status)
|
t.Fatalf("exit status %d, want 1", status)
|
||||||
}
|
}
|
||||||
@@ -362,6 +433,30 @@ func wantStartRefused(t *testing.T, dir, want string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wantRulesRefused runs smallwebwaf with its rule files in dir, and
|
||||||
|
// checks that it stops at start, with the error want. If it starts
|
||||||
|
// instead, it is stopped after waitLimit.
|
||||||
|
func wantRulesRefused(t *testing.T, dir, want string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, stop := context.WithTimeout(t.Context(), waitLimit)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
out := &output{}
|
||||||
|
|
||||||
|
status := run(ctx, map[string]string{
|
||||||
|
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: dir,
|
||||||
|
}, out)
|
||||||
|
if status != 1 {
|
||||||
|
t.Fatalf("exit status %d, want 1", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := out.line(t, "msg", "cannot use the rule files")
|
||||||
|
if line["error"] != want || line["level"] != "ERROR" {
|
||||||
|
t.Errorf("start refused with %v, want the error %q", line, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// startApp starts an app that answers every request with greeting, and
|
// startApp starts an app that answers every request with greeting, and
|
||||||
// returns its URL.
|
// returns its URL.
|
||||||
func startApp(t *testing.T) string {
|
func startApp(t *testing.T) string {
|
||||||
@@ -443,8 +538,10 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) {
|
|||||||
"SWWAF_LIMIT_BAN_DURATION": "1h",
|
"SWWAF_LIMIT_BAN_DURATION": "1h",
|
||||||
"SWWAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
|
"SWWAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
|
||||||
"SWWAF_MAX_BAN_DURATION": "7d",
|
"SWWAF_MAX_BAN_DURATION": "7d",
|
||||||
|
"SWWAF_ATTACK_BAN_DURATION": "7d",
|
||||||
"SWWAF_MAX_BANS": "5000",
|
"SWWAF_MAX_BANS": "5000",
|
||||||
"SWWAF_BAN_SCOPE_V4_PREFIX": "32",
|
"SWWAF_BAN_SCOPE_V4_PREFIX": "32",
|
||||||
|
"SWWAF_RULES_ENABLED": "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, value := range want {
|
for name, value := range want {
|
||||||
|
|||||||
+12
-4
@@ -48,6 +48,7 @@ var (
|
|||||||
errVersion = errors.New("unknown version")
|
errVersion = errors.New("unknown version")
|
||||||
// errMissing is for an entry without a field it needs.
|
// errMissing is for an entry without a field it needs.
|
||||||
errMissing = errors.New("has no")
|
errMissing = errors.New("has no")
|
||||||
|
errCause = errors.New("is not limit or attack")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params are what Load needs.
|
// Params are what Load needs.
|
||||||
@@ -95,11 +96,12 @@ type bansFile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// banEntry is a ban as bans.json holds it: a permanent ban's expires is
|
// banEntry is a ban as bans.json holds it: a permanent ban's expires is
|
||||||
// null.
|
// null, and a ban an admin added may have no cause.
|
||||||
type banEntry struct {
|
type banEntry struct {
|
||||||
Netblock netip.Prefix `json:"netblock"`
|
Netblock netip.Prefix `json:"netblock"`
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
Expires *time.Time `json:"expires"`
|
Expires *time.Time `json:"expires"`
|
||||||
|
Cause string `json:"cause,omitempty"`
|
||||||
Notes bans.Notes `json:"notes"`
|
Notes bans.Notes `json:"notes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,7 +446,9 @@ func (f *Files) encode(name string) ([]byte, error) {
|
|||||||
|
|
||||||
// newBanEntry returns ban as bans.json holds it.
|
// newBanEntry returns ban as bans.json holds it.
|
||||||
func newBanEntry(ban bans.Ban) banEntry {
|
func newBanEntry(ban bans.Ban) banEntry {
|
||||||
entry := banEntry{Netblock: ban.Netblock, Start: ban.Start, Notes: ban.Notes}
|
entry := banEntry{
|
||||||
|
Netblock: ban.Netblock, Start: ban.Start, Cause: ban.Cause, Notes: ban.Notes,
|
||||||
|
}
|
||||||
if !ban.Permanent() {
|
if !ban.Permanent() {
|
||||||
entry.Expires = &ban.Expires
|
entry.Expires = &ban.Expires
|
||||||
}
|
}
|
||||||
@@ -454,7 +458,7 @@ func newBanEntry(ban bans.Ban) banEntry {
|
|||||||
|
|
||||||
// ban returns the ban an entry of bans.json holds.
|
// ban returns the ban an entry of bans.json holds.
|
||||||
func (e banEntry) ban() bans.Ban {
|
func (e banEntry) ban() bans.Ban {
|
||||||
ban := bans.Ban{Netblock: e.Netblock, Start: e.Start, Notes: e.Notes}
|
ban := bans.Ban{Netblock: e.Netblock, Start: e.Start, Cause: e.Cause, Notes: e.Notes}
|
||||||
if e.Expires != nil {
|
if e.Expires != nil {
|
||||||
ban.Expires = *e.Expires
|
ban.Expires = *e.Expires
|
||||||
}
|
}
|
||||||
@@ -466,7 +470,8 @@ func (e banEntry) ban() bans.Ban {
|
|||||||
// client, a start, from which the length of the netblock's next ban is
|
// client, a start, from which the length of the netblock's next ban is
|
||||||
// worked out, or an expires, which would make it permanent. A permanent
|
// worked out, or an expires, which would make it permanent. A permanent
|
||||||
// ban's expires is null, which Bans cannot tell from a missing one, so
|
// ban's expires is null, which Bans cannot tell from a missing one, so
|
||||||
// each expires is read again as written.
|
// each expires is read again as written. A cause other than limit or
|
||||||
|
// attack, most likely misspelt, is refused too.
|
||||||
func (f *bansFile) check(data []byte) error {
|
func (f *bansFile) check(data []byte) error {
|
||||||
var written struct {
|
var written struct {
|
||||||
Bans []struct {
|
Bans []struct {
|
||||||
@@ -487,6 +492,9 @@ func (f *bansFile) check(data []byte) error {
|
|||||||
return missing(i, "start")
|
return missing(i, "start")
|
||||||
case written.Bans[i].Expires == nil:
|
case written.Bans[i].Expires == nil:
|
||||||
return missing(i, "expires")
|
return missing(i, "expires")
|
||||||
|
case entry.Cause != "" && entry.Cause != bans.CauseLimit &&
|
||||||
|
entry.Cause != bans.CauseAttack:
|
||||||
|
return fmt.Errorf("entry %d's cause %q %w", i+1, entry.Cause, errCause)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ const permanentBansJSON = `{
|
|||||||
},
|
},
|
||||||
"requests": 1500,
|
"requests": 1500,
|
||||||
"refused": 3,
|
"refused": 3,
|
||||||
"earlier_bans": 5
|
"earlier_bans": {
|
||||||
|
"limit": 3,
|
||||||
|
"attack": 1,
|
||||||
|
"without_cause": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -263,6 +267,17 @@ func TestEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBanWithACauseSmallwebwafDoesNotGiveStopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
wantRefused(t, bansJSON, `{"version": 1, "bans": [`+
|
||||||
|
`{"netblock": "203.0.113.9/32", "start": "2026-10-06T00:00:00Z", `+
|
||||||
|
`"expires": null, "cause": "attack"}, `+
|
||||||
|
`{"netblock": "203.0.113.10/32", "start": "2026-10-06T00:00:00Z", `+
|
||||||
|
`"expires": null, "cause": "atack"}]}`,
|
||||||
|
`: entry 2's cause "atack" is not limit or attack`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnknownVersionStopsTheStart(t *testing.T) {
|
func TestUnknownVersionStopsTheStart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -881,6 +896,7 @@ func newParams(dir string) state.Params {
|
|||||||
LimitBanDuration: time.Hour,
|
LimitBanDuration: time.Hour,
|
||||||
LimitBanRepeatWindow: 24 * time.Hour,
|
LimitBanRepeatWindow: 24 * time.Hour,
|
||||||
MaxBanDuration: 7 * 24 * time.Hour,
|
MaxBanDuration: 7 * 24 * time.Hour,
|
||||||
|
AttackBanDuration: 7 * 24 * time.Hour,
|
||||||
MaxBans: 5000,
|
MaxBans: 5000,
|
||||||
}),
|
}),
|
||||||
Limiter: ratelimit.New(ratelimit.Limits{}),
|
Limiter: ratelimit.New(ratelimit.Limits{}),
|
||||||
@@ -893,14 +909,17 @@ func newParams(dir string) state.Params {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fill puts a ban that ends and one that does not, clients with counts
|
// fill puts a permanent ban without a cause, as an admin adds one, a ban
|
||||||
// and histories, and GeoJS answers into the parts of params.
|
// for a broken limit and one for a clear sign of attack, clients with
|
||||||
|
// counts and histories, and GeoJS answers into the parts of params.
|
||||||
func fill(params state.Params) {
|
func fill(params state.Params) {
|
||||||
now := midnight()
|
now := midnight()
|
||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
params.Ledger.Load([]bans.Ban{permanentBan()})
|
params.Ledger.Load([]bans.Ban{permanentBan()})
|
||||||
params.Ledger.BanForLimit(client, now, bans.Notes{Country: "DE", Limit: 1})
|
params.Ledger.BanForLimit(client, now, bans.Notes{Country: "DE", Limit: 1})
|
||||||
|
params.Ledger.BanForAttack(netip.MustParsePrefix("192.0.2.1/32"), now,
|
||||||
|
bans.Notes{RuleID: "env-file", Target: "path"})
|
||||||
|
|
||||||
for _, c := range []string{"2001:db8::/64", "203.0.113.9/32", "192.0.2.1/32"} {
|
for _, c := range []string{"2001:db8::/64", "203.0.113.9/32", "192.0.2.1/32"} {
|
||||||
params.Limiter.Count(netip.MustParsePrefix(c), now)
|
params.Limiter.Count(netip.MustParsePrefix(c), now)
|
||||||
@@ -939,7 +958,7 @@ func permanentBan() bans.Ban {
|
|||||||
},
|
},
|
||||||
Requests: 1500,
|
Requests: 1500,
|
||||||
Refused: 3,
|
Refused: 3,
|
||||||
EarlierBans: 5,
|
EarlierBans: bans.EarlierBans{Limit: 3, Attack: 1, WithoutCause: 1},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-5
@@ -3,9 +3,10 @@
|
|||||||
# deploy/example-app, then run the app's container with a volume for the
|
# deploy/example-app, then run the app's container with a volume for the
|
||||||
# state files and check that the health check passes, that a request is
|
# state files and check that the health check passes, that a request is
|
||||||
# served through smallwebwaf, that a second one in a minute bans the
|
# served through smallwebwaf, that a second one in a minute bans the
|
||||||
# client, that `sv stop` stops smallwebwaf in order, that `docker stop`
|
# client, that a probe for /.env bans another client, which its next
|
||||||
# stops the container without having to kill it, and that a new
|
# request bans for good, that `sv stop` stops smallwebwaf in order, that
|
||||||
# container on the same volume still refuses the banned client. The
|
# `docker stop` stops the container without having to kill it, and that
|
||||||
|
# a new container on the same volume still refuses the banned client. The
|
||||||
# containers, the volume and both images are removed however the script
|
# containers, the volume and both images are removed however the script
|
||||||
# ends. Building the app needs network access, for nixpkgs' binary cache.
|
# ends. Building the app needs network access, for nixpkgs' binary cache.
|
||||||
# script/check does not run this.
|
# script/check does not run this.
|
||||||
@@ -52,9 +53,13 @@ healthy() {
|
|||||||
[ "$status" = healthy ]
|
[ "$status" = healthy ]
|
||||||
}
|
}
|
||||||
|
|
||||||
# logged <text>: the container's output holds text.
|
# logged <text>...: a line of the container's output holds every text,
|
||||||
|
# in any order.
|
||||||
logged() {
|
logged() {
|
||||||
docker logs "$CONTAINER" 2>&1 | grep -qF "$1"
|
lines="$(docker logs "$CONTAINER" 2>&1)"
|
||||||
|
for text in "$@"; do
|
||||||
|
lines="$(printf '%s\n' "$lines" | grep -F "$text")" || return 1
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# start_container: run the app's container, with the state files on the
|
# start_container: run the app's container, with the state files on the
|
||||||
@@ -77,6 +82,15 @@ refused() {
|
|||||||
[ "$code" = 403 ]
|
[ "$code" = 403 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# refused_from <client> <path>: a request for path from client, as
|
||||||
|
# X-Forwarded-For names it, gets 403. smallwebwaf believes the header
|
||||||
|
# from docker's gateway, a private address.
|
||||||
|
refused_from() {
|
||||||
|
code="$(curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||||
|
--max-time 10 --header "X-Forwarded-For: $1" "http://$address$2")" || true
|
||||||
|
[ "$code" = 403 ]
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
@@ -100,6 +114,14 @@ main() {
|
|||||||
wait_for "smallwebwaf logged no ban" logged '"action":"rate_limited"'
|
wait_for "smallwebwaf logged no ban" logged '"action":"rate_limited"'
|
||||||
echo "example-app: a second request in a minute bans the client"
|
echo "example-app: a second request in a minute bans the client"
|
||||||
|
|
||||||
|
refused_from 203.0.113.9 /.env || fail "a probe for /.env was not refused"
|
||||||
|
wait_for "smallwebwaf logged no ban for the probe" \
|
||||||
|
logged '"action":"banned"' '"rule_ids":["env-file"]'
|
||||||
|
refused_from 203.0.113.9 / || fail "the client of the probe was let through"
|
||||||
|
wait_for "the client's next request did not make its ban permanent" \
|
||||||
|
logged '"ban_expires":"permanent"'
|
||||||
|
echo "example-app: a probe for /.env bans the client, its next request for good"
|
||||||
|
|
||||||
docker exec "$CONTAINER" sv stop smallwebwaf >/dev/null ||
|
docker exec "$CONTAINER" sv stop smallwebwaf >/dev/null ||
|
||||||
fail "sv stop smallwebwaf failed"
|
fail "sv stop smallwebwaf failed"
|
||||||
wait_for "smallwebwaf did not stop in order" logged '"msg":"stopped"'
|
wait_for "smallwebwaf did not stop in order" logged '"msg":"stopped"'
|
||||||
|
|||||||
+7
-1
@@ -1,7 +1,9 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/run: build bin/smallwebwaf with script/build and run it, with
|
# script/run: build bin/smallwebwaf with script/build and run it, with
|
||||||
# the settings in the environment. Unless SWWAF_STATE_DIR is set, the
|
# the settings in the environment. Unless SWWAF_STATE_DIR is set, the
|
||||||
# state files go in bin/state, beside the binary.
|
# state files go in bin/state, beside the binary, and unless
|
||||||
|
# SWWAF_RULES_DIR is set, the rule files are those of share/rules.d,
|
||||||
|
# which the image ships.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -14,6 +16,10 @@ main() {
|
|||||||
export SWWAF_STATE_DIR
|
export SWWAF_STATE_DIR
|
||||||
mkdir -p "$SWWAF_STATE_DIR"
|
mkdir -p "$SWWAF_STATE_DIR"
|
||||||
fi
|
fi
|
||||||
|
if [ -z "${SWWAF_RULES_DIR+set}" ]; then
|
||||||
|
SWWAF_RULES_DIR="$ROOT/share/rules.d"
|
||||||
|
export SWWAF_RULES_DIR
|
||||||
|
fi
|
||||||
exec "$ROOT/bin/smallwebwaf"
|
exec "$ROOT/bin/smallwebwaf"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 00-default.rules: probes no real visitor sends, anchored at the site root
|
||||||
|
|
||||||
|
# id target action regex
|
||||||
|
env-file path ban (?i)^/\.env(\.[a-z]+)?$
|
||||||
|
vcs-dir path ban (?i)^/\.(git|svn|hg|bzr)(/|$)
|
||||||
|
secrets-dir path ban (?i)^/\.(aws|ssh|docker|kube)/
|
||||||
|
secret-file path ban (?i)^/\.(htpasswd|htaccess|npmrc|netrc|pgpass|git-credentials|bash_history|DS_Store)$
|
||||||
|
editor-dir path ban (?i)^/\.(vscode|idea)/
|
||||||
|
backup-file path ban (?i)^/[^/]+\.(php(\.[a-z0-9]+|~)|sql(\.[a-z0-9]+)?)$
|
||||||
|
log-file path ban (?i)^/(debug|error|access)\.log$
|
||||||
|
compose-file path ban (?i)^/(docker-)?compose\.ya?ml$
|
||||||
|
php-shell path ban (?i)^/(shell|c99|r57|wso|alfa)\.php$
|
||||||
|
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|masscan|zgrab|wpscan)\b
|
||||||
|
path-traversal uri block (\.\./){2,}
|
||||||
|
empty-agent user_agent log ^$
|
||||||
Reference in New Issue
Block a user