Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fcd79c182 | ||
|
|
6850cb0e9f |
No files matched your search
@@ -265,6 +265,7 @@ Every setting the code reads, with its default:
|
|||||||
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
|
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
|
||||||
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
||||||
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
|
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
|
||||||
|
| `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
|
||||||
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
|
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
|
||||||
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
||||||
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
||||||
@@ -279,6 +280,7 @@ Every setting the code reads, with its default:
|
|||||||
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||||
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
||||||
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
||||||
|
| `SWW_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;cloud.example.com:gitea`; only those apps' fixes apply there. Unset, every app's fixes apply to every hostname. |
|
||||||
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
||||||
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||||
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||||
@@ -567,7 +569,9 @@ the `tags` field of its log line:
|
|||||||
or `vpn=mullvad`;
|
or `vpn=mullvad`;
|
||||||
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
|
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
|
||||||
the client;
|
the client;
|
||||||
- `exploitsignature`, when a rule file or the Core Rule Set matched the request.
|
- `exploitsignature`, when a rule file or the Core Rule Set matched the request;
|
||||||
|
- `asn=<number>` and `prefix=<netblock>`, from a routewatch instance, while
|
||||||
|
`SWW_ROUTEWATCH_URL` is set.
|
||||||
|
|
||||||
`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a
|
`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a
|
||||||
client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
||||||
@@ -578,11 +582,15 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
|||||||
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
||||||
front of a code forge, bodies carry what people write and publish, which the
|
front of a code forge, bodies carry what people write and publish, which the
|
||||||
rules take for attacks.
|
rules take for attacks.
|
||||||
- `smallwebwaf` changes the Core Rule Set in eight ways so that it lets ordinary
|
- `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary
|
||||||
requests to a code forge through, such as git over HTTP and sign-in through
|
requests to a code forge through, such as git over HTTP and sign-in through
|
||||||
Git Credential Manager. On gitea's own paths, its searches, file names and
|
Git Credential Manager. No setting undoes the changes; "Attack detection" in
|
||||||
branch names get through too. No setting undoes the changes; "Attack
|
[`SPEC.md`](SPEC.md) lists them.
|
||||||
detection" in [`SPEC.md`](SPEC.md) lists them.
|
- Each supported app also has its own fixes, which let its ordinary requests
|
||||||
|
through. gitea's let its searches, file names and branch names through.
|
||||||
|
`SWW_APPS` names the apps each hostname serves. A hostname gets only its own
|
||||||
|
apps' fixes, and a hostname it does not list gets none. Unset, every app's
|
||||||
|
fixes apply to every hostname.
|
||||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
||||||
fields or paths.
|
fields or paths.
|
||||||
- Rule files are plain text, one rule to a line, edited by hand and read again
|
- Rule files are plain text, one rule to a line, edited by hand and read again
|
||||||
@@ -630,8 +638,8 @@ is set aside as `<name>.bad`, and never stops the running process (see
|
|||||||
- `internal/discriminator`: `discriminator`'s server, which runs the checks and
|
- `internal/discriminator`: `discriminator`'s server, which runs the checks and
|
||||||
answers its own endpoints.
|
answers its own endpoints.
|
||||||
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
|
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
|
||||||
`internal/lookup`, `internal/reputation`, `internal/anomaly` and
|
`internal/lookup`, `internal/reputation`, `internal/routewatch`,
|
||||||
`internal/alerts`: one check or source each.
|
`internal/anomaly` and `internal/alerts`: one check or source each.
|
||||||
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
|
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
|
||||||
`internal/metrics`: the state files, the log lines, the syslog sender and the
|
`internal/metrics`: the state files, the log lines, the syslog sender and the
|
||||||
metrics.
|
metrics.
|
||||||
|
|||||||
@@ -81,12 +81,14 @@ to them.
|
|||||||
of bans is full.
|
of bans is full.
|
||||||
- `internal/rules`: reads the rule files at start and again when they
|
- `internal/rules`: reads the rule files at start and again when they
|
||||||
change, and tells which rules a request matches.
|
change, and tells which rules a request matches.
|
||||||
- `internal/waf`: the Core Rule Set, with the eight changes listed under
|
- `internal/waf`: the Core Rule Set, with the six changes and the apps'
|
||||||
"Attack detection", as Coraza directives.
|
fixes listed under "Attack detection", as Coraza directives.
|
||||||
- `internal/lookup`: looks clients up through GeoJS or in the lookup
|
- `internal/lookup`: looks clients up through GeoJS or in the lookup
|
||||||
database.
|
database.
|
||||||
- `internal/reputation`: fetches the lists, asks the DNS blocklists and
|
- `internal/reputation`: fetches the lists, asks the DNS blocklists and
|
||||||
AbuseIPDB in the background, and keeps their answers.
|
AbuseIPDB in the background, and keeps their answers.
|
||||||
|
- `internal/routewatch`: asks a routewatch instance about clients in the
|
||||||
|
background, and keeps its answers.
|
||||||
- `internal/anomaly`: the anomaly counters and their alerts.
|
- `internal/anomaly`: the anomaly counters and their alerts.
|
||||||
- `internal/alerts`: holds back repeats and alerts past the hourly limit,
|
- `internal/alerts`: holds back repeats and alerts past the hourly limit,
|
||||||
and sends the rest to the webhook, Slack and ntfy, each from a queue of
|
and sends the rest to the webhook, Slack and ntfy, each from a queue of
|
||||||
@@ -144,7 +146,9 @@ request log's `heuristic` field gives that name. `refusal_limit` and
|
|||||||
around its address, a /64 by default, since one abuser usually controls a
|
around its address, a /64 by default, since one abuser usually controls a
|
||||||
whole /64. A client is therefore one IPv4 address or one IPv6 group.
|
whole /64. A client is therefore one IPv4 address or one IPv6 group.
|
||||||
3. The tag lists (`tag_lists`; see "Request tags"). Each list that holds the
|
3. The tag lists (`tag_lists`; see "Request tags"). Each list that holds the
|
||||||
client adds its tag. A client in `SWW_ALLOW_NETS` gets no tag.
|
client adds its tag. Next, `routewatch_tags` adds the client's `asn=` and
|
||||||
|
`prefix=` tags while `SWW_ROUTEWATCH_URL` is set. A client in
|
||||||
|
`SWW_ALLOW_NETS` gets no tag.
|
||||||
4. The static lists. A client in `SWW_ALLOW_NETS` skips steps 5 to 15, the byte
|
4. The static lists. A client in `SWW_ALLOW_NETS` skips steps 5 to 15, the byte
|
||||||
limits and the error burst. A client in `SWW_DENY_NETS` is refused
|
limits and the error burst. A client in `SWW_DENY_NETS` is refused
|
||||||
(`deny_nets`); a client in both lists is let through.
|
(`deny_nets`); a client in both lists is let through.
|
||||||
@@ -554,6 +558,21 @@ Each tag and where it comes from:
|
|||||||
- `exploitsignature`, when a rule file or the Core Rule Set matched the request.
|
- `exploitsignature`, when a rule file or the Core Rule Set matched the request.
|
||||||
For the Core Rule Set a match is a score at or over
|
For the Core Rule Set a match is a score at or over
|
||||||
`SWW_WAF_ANOMALY_THRESHOLD`.
|
`SWW_WAF_ANOMALY_THRESHOLD`.
|
||||||
|
- `asn=<number>` and `prefix=<netblock>` from a routewatch instance
|
||||||
|
(`routewatch_tags`), while `SWW_ROUTEWATCH_URL` is set:
|
||||||
|
- The tag `prefix=` holds the netblock the client's address is announced in,
|
||||||
|
such as `prefix=192.0.2.0/24`.
|
||||||
|
- An `asn=` tag has the same spelling as the lookup's. When routewatch and
|
||||||
|
the lookup give the same AS number, a request has one `asn=` tag. When
|
||||||
|
they differ, it has both.
|
||||||
|
- They are added before `deny_nets`, so the log line of a request it refused
|
||||||
|
has them.
|
||||||
|
- `discriminator` asks routewatch in the background and never waits for it.
|
||||||
|
An answer is kept for 1 hour, in memory only.
|
||||||
|
- After a query fails, routewatch is not asked for 1 minute.
|
||||||
|
- A request without these tags gives the reason in the log field
|
||||||
|
`routewatch_tags_missing` (see
|
||||||
|
[`docs/request-log.md`](docs/request-log.md)).
|
||||||
|
|
||||||
A client in `SWW_ALLOW_NETS` skips the checks and gets no tag. Every tag is in
|
A client in `SWW_ALLOW_NETS` skips the checks and gets no tag. Every tag is in
|
||||||
lower case, except a country code, which is in capitals. `SWW_TAG_DENY` and
|
lower case, except a country code, which is in capitals. `SWW_TAG_DENY` and
|
||||||
@@ -571,7 +590,9 @@ Two settings act on tags:
|
|||||||
|
|
||||||
The tag settings are empty by default, for the same reason as the country list.
|
The tag settings are empty by default, for the same reason as the country list.
|
||||||
The lookup waits for GeoJS when either names an `asn=` or `country=` tag. A
|
The lookup waits for GeoJS when either names an `asn=` or `country=` tag. A
|
||||||
setting that names one stops the start while `SWW_LOOKUP_SOURCE` is `off`.
|
setting that names one stops the start while `SWW_LOOKUP_SOURCE` is `off`. The
|
||||||
|
exception is an `asn=` tag while `SWW_ROUTEWATCH_URL` is set, since routewatch
|
||||||
|
gives that tag.
|
||||||
|
|
||||||
### Biased thresholds
|
### Biased thresholds
|
||||||
|
|
||||||
@@ -645,7 +666,7 @@ files, the limits and the bans still apply to the client that sends it.
|
|||||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as
|
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as
|
||||||
Coraza directives. An exclusion switches one rule off for some fields or paths
|
Coraza directives. An exclusion switches one rule off for some fields or paths
|
||||||
only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file
|
only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file
|
||||||
holds an app's own exclusions; the eighth change below holds gitea's.
|
holds an app's own exclusions; gitea's fixes below hold gitea's.
|
||||||
- `discriminator` loads the file after `smallwebwaf`'s own setup rules and
|
- `discriminator` loads the file after `smallwebwaf`'s own setup rules and
|
||||||
before the Core Rule Set's rules. The Core Rule Set loads its own
|
before the Core Rule Set's rules. The Core Rule Set loads its own
|
||||||
`REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point.
|
`REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point.
|
||||||
@@ -691,7 +712,7 @@ files, the limits and the bans still apply to the client that sends it.
|
|||||||
scanner from an ordinary client. In front of a code forge, registry and git
|
scanner from an ordinary client. In front of a code forge, registry and git
|
||||||
clients get `404` and `401` by design, hundreds of times a minute.
|
clients get `404` and `401` by design, hundreds of times a minute.
|
||||||
|
|
||||||
`smallwebwaf` changes the Core Rule Set 4.25.0 in eight ways, since in front of
|
`smallwebwaf` changes the Core Rule Set 4.25.0 in six ways, since in front of
|
||||||
gitea it would otherwise refuse ordinary requests. The changes hold in front of
|
gitea it would otherwise refuse ordinary requests. The changes hold in front of
|
||||||
every app, and no setting undoes them:
|
every app, and no setting undoes them:
|
||||||
|
|
||||||
@@ -710,8 +731,52 @@ every app, and no setting undoes them:
|
|||||||
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
|
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
|
||||||
`http://127.0.0.1` on the user's own machine. The server never fetches that
|
`http://127.0.0.1` on the user's own machine. The server never fetches that
|
||||||
address.
|
address.
|
||||||
4. Ten query parameters are not checked against the Core Rule Set's lists of
|
4. `Referer` is not checked for a Unix command given without arguments (932340),
|
||||||
system files (930120), shell paths (932160) and command names (932260):
|
for Java starting a process (944110) or for a Java class name (944130).
|
||||||
|
`Referer` names the page a request comes from, on everything that page loads
|
||||||
|
and on every link followed from it. The Core Rule Set takes some page
|
||||||
|
addresses for attacks:
|
||||||
|
- a search for `env`;
|
||||||
|
- OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`;
|
||||||
|
- a file named `java.lang.String.md`.
|
||||||
|
|
||||||
|
Every request from such a page would be refused. `Referer` keeps the other
|
||||||
|
rules, except after some gitea searches (gitea's fixes). An app that passes
|
||||||
|
`Referer` to a shell must check it itself.
|
||||||
|
|
||||||
|
5. Responses are not inspected. A raw file from a repository, such as a shell
|
||||||
|
script, looks to the response rules like source code leaking from the server.
|
||||||
|
6. The `from` parameter of a container registry's upload path,
|
||||||
|
`/v2/<image>/blobs/uploads/`, is not checked for command names (932260). When
|
||||||
|
a push reuses a layer, `from` names the image the layer comes from. An image
|
||||||
|
such as `owner/python3-app` would have that push refused.
|
||||||
|
|
||||||
|
Each supported app also has its own fixes, which let its ordinary requests
|
||||||
|
through. An app's fixes hold for the hostnames `SWW_APPS` lists the app for:
|
||||||
|
|
||||||
|
- `SWW_APPS` holds entries separated by semicolons. Each is a hostname, a colon,
|
||||||
|
then one or more apps separated by commas, for example
|
||||||
|
`git.example.com:gitea;code.example.com:gitea`.
|
||||||
|
- The request's `Host` is matched by exact name, without its port and without
|
||||||
|
regard to case. There are no wildcards.
|
||||||
|
- A hostname not listed gets no app's fixes, so every rule checks it in full.
|
||||||
|
Set empty, `SWW_APPS` lists no hostname.
|
||||||
|
- Unset, every app's fixes hold for every hostname. An app's fixes then weaken
|
||||||
|
the check of any other app that serves the same paths or fields.
|
||||||
|
- Any of these mistakes stops the start:
|
||||||
|
- an unknown app;
|
||||||
|
- a hostname that is not a DNS name, such as `*.example.com`;
|
||||||
|
- a hostname listed twice;
|
||||||
|
- an app listed twice for one hostname;
|
||||||
|
- an entry without a colon.
|
||||||
|
|
||||||
|
`gitea` is the only app supported. Each app's fixes are in one file, such as
|
||||||
|
`internal/waf/gitea.conf`, and one entry of `apps` in `internal/waf/waf.go`.
|
||||||
|
gitea's fixes are:
|
||||||
|
|
||||||
|
1. Ten query parameters are not checked against the Core Rule Set's lists of
|
||||||
|
system files (930120), shell paths (932160) and command names (932260), on
|
||||||
|
any path:
|
||||||
- `path`, `files`, `skip-to` and `sub_path`;
|
- `path`, `files`, `skip-to` and `sub_path`;
|
||||||
- `ref`, `sha`, `branch` and `workflow`;
|
- `ref`, `sha`, `branch` and `workflow`;
|
||||||
- `artifactName` and `redirect_to`.
|
- `artifactName` and `redirect_to`.
|
||||||
@@ -724,41 +789,18 @@ every app, and no setting undoes them:
|
|||||||
catch reaches the app, such as the command `|cat /etc/passwd`. Path
|
catch reaches the app, such as the command `|cat /etc/passwd`. Path
|
||||||
traversal, SQL and script injection, and PHP, Java and Node.js code are
|
traversal, SQL and script injection, and PHP, Java and Node.js code are
|
||||||
still refused there. Other parameters keep all three rules, except on the
|
still refused there. Other parameters keep all three rules, except on the
|
||||||
gitea paths the eighth change names. An app that uses one of these
|
gitea paths the third fix names.
|
||||||
parameters as a file on the server, or passes it to a shell, must check the
|
|
||||||
value itself. A rule file can refuse it instead.
|
|
||||||
|
|
||||||
5. Two cookies are left out, and `Referer` keeps fewer rules:
|
2. The Core Rule Set reads the request without the `gitea_flash` and
|
||||||
- The Core Rule Set reads the request without the `gitea_flash` and
|
`redirect_to` cookies. gitea puts a message naming what was just done into
|
||||||
`redirect_to` cookies. gitea puts a message naming what was just done into
|
`gitea_flash`. It puts into `redirect_to` the page to return to after signing
|
||||||
`gitea_flash`. It puts into `redirect_to` the page to return to after
|
in. The Core Rule Set takes file names in them, such as `package.json`, for
|
||||||
signing in. The Core Rule Set takes file names in them, such as
|
attacks. A browser sends such a cookie with every request until gitea
|
||||||
`package.json`, for attacks. A browser sends such a cookie with every
|
replaces it. gitea cannot replace it while those requests are refused, so one
|
||||||
request until gitea replaces it. gitea cannot replace it while those
|
refusal would keep the browser out of the whole site. gitea shows the message
|
||||||
requests are refused, so one refusal would keep the browser out of the
|
with any script removed and returns only to its own pages. Every other cookie
|
||||||
whole site. gitea shows the message with any script removed and returns
|
is read in full.
|
||||||
only to its own pages. Every other cookie is read in full. An app that
|
3. On gitea's paths, some fields skip the rules that refused ordinary gitea
|
||||||
reads a cookie of either name must check it itself.
|
|
||||||
- `Referer` is not checked for a Unix command given without arguments
|
|
||||||
(932340), for Java starting a process (944110) or for a Java class name
|
|
||||||
(944130). `Referer` names the page a request comes from, on everything
|
|
||||||
that page loads and on every link followed from it. The Core Rule Set
|
|
||||||
takes some page addresses for attacks:
|
|
||||||
- a search for `env`;
|
|
||||||
- OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`;
|
|
||||||
- a file named `java.lang.String.md`.
|
|
||||||
|
|
||||||
Every request from such a page would be refused. `Referer` keeps the
|
|
||||||
other rules, except after some gitea searches (the eighth change). An
|
|
||||||
app that passes `Referer` to a shell must check it itself.
|
|
||||||
|
|
||||||
6. Responses are not inspected. A raw file from a repository, such as a shell
|
|
||||||
script, looks to the response rules like source code leaking from the server.
|
|
||||||
7. The `from` parameter of a container registry's upload path,
|
|
||||||
`/v2/<image>/blobs/uploads/`, is not checked for command names (932260). When
|
|
||||||
a push reuses a layer, `from` names the image the layer comes from. An image
|
|
||||||
such as `owner/python3-app` would have that push refused.
|
|
||||||
8. On gitea's paths, some fields skip the rules that refused ordinary gitea
|
|
||||||
requests there. They are the fields where gitea sends a search, a file name
|
requests there. They are the fields where gitea sends a search, a file name
|
||||||
or a branch name. `internal/waf/gitea.conf` lists each rule and the request
|
or a branch name. `internal/waf/gitea.conf` lists each rule and the request
|
||||||
it refused. The fields are:
|
it refused. The fields are:
|
||||||
@@ -776,8 +818,7 @@ every app, and no setting undoes them:
|
|||||||
- packages.
|
- packages.
|
||||||
|
|
||||||
A user's or an organisation's page is any path of one segment, so on
|
A user's or an organisation's page is any path of one segment, so on
|
||||||
every such path, on any app, `q` skips these three rules, for example on
|
every such path `q` skips these three rules, for example on `/search`.
|
||||||
`/search`.
|
|
||||||
|
|
||||||
Some of these searches also skip the rule for Java class names (944130),
|
Some of these searches also skip the rule for Java class names (944130),
|
||||||
which refused `org.apache.commons.logging`.
|
which refused `org.apache.commons.logging`.
|
||||||
@@ -815,11 +856,14 @@ every app, and no setting undoes them:
|
|||||||
`actions/upload-artifact@v3` (920450).
|
`actions/upload-artifact@v3` (920450).
|
||||||
|
|
||||||
Elsewhere these fields keep every rule. The paths are those of gitea at the
|
Elsewhere these fields keep every rule. The paths are those of gitea at the
|
||||||
site root. An app other than gitea that serves one of them must check these
|
site root.
|
||||||
fields itself.
|
|
||||||
|
|
||||||
The parameter and cookie names in these changes are matched without regard to
|
On a hostname that gets gitea's fixes, an app other than gitea must check these
|
||||||
case, as Coraza matches them.
|
fields and cookies itself. Listing each hostname's own apps in `SWW_APPS` keeps
|
||||||
|
the full check for the others.
|
||||||
|
|
||||||
|
The parameter names in these changes and fixes are matched without regard to
|
||||||
|
case, and the cookie names exactly.
|
||||||
|
|
||||||
### Ban lengths, response and scope
|
### Ban lengths, response and scope
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,12 @@ Every one of these lines has:
|
|||||||
- `abuseipdb`.
|
- `abuseipdb`.
|
||||||
- `tags`: the request's tags, sorted, left out when it has none. Each tag and
|
- `tags`: the request's tags, sorted, left out when it has none. Each tag and
|
||||||
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
|
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
|
||||||
|
- `routewatch_tags_missing`: why the request got no tags from routewatch,
|
||||||
|
present only while `SWW_ROUTEWATCH_URL` is set:
|
||||||
|
- `no_answer_yet`: routewatch has not answered about this client yet;
|
||||||
|
- `request_failed`: a query to routewatch failed within the last minute, and
|
||||||
|
the process log says why;
|
||||||
|
- `no_route`: routewatch knows no live route for the address.
|
||||||
- `offence`: `limit`, for a request that broke a limit.
|
- `offence`: `limit`, for a request that broke a limit.
|
||||||
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
||||||
refused under one: a time, or `permanent`.
|
refused under one: a time, or `permanent`.
|
||||||
|
|||||||
+29
-2
@@ -145,8 +145,8 @@ cut off; the client is banned.
|
|||||||
the start while a setting that needs a lookup is set:
|
the start while a setting that needs a lookup is set:
|
||||||
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
|
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
|
||||||
- `SWW_ADD_LOOKUP_HEADERS`;
|
- `SWW_ADD_LOOKUP_HEADERS`;
|
||||||
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming an `asn=` or `country=`
|
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming a `country=` tag, or an
|
||||||
tag;
|
`asn=` tag while `SWW_ROUTEWATCH_URL` is unset;
|
||||||
- the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`;
|
- the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`;
|
||||||
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
|
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
|
||||||
- an anomaly threshold per AS number.
|
- an anomaly threshold per AS number.
|
||||||
@@ -186,11 +186,20 @@ Every request gets tags from what `discriminator` knows of its client. See
|
|||||||
`exploitsignature`: a request gets that tag after the rate limits have counted
|
`exploitsignature`: a request gets that tag after the rate limits have counted
|
||||||
it.
|
it.
|
||||||
|
|
||||||
|
- `SWW_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a
|
||||||
|
routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a
|
||||||
|
service that gives an address's announced netblock and origin AS.
|
||||||
|
`discriminator` asks it about each client, in the background, and adds the
|
||||||
|
tags `asn=<number>` and `prefix=<netblock>` from its answer. While it is
|
||||||
|
unset, nothing asks routewatch and no request gets a `prefix=` tag.
|
||||||
|
|
||||||
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag
|
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag
|
||||||
that no request can get stops the start. A request can get only these:
|
that no request can get stops the start. A request can get only these:
|
||||||
|
|
||||||
- a name in `SWW_TAG_LISTS`;
|
- a name in `SWW_TAG_LISTS`;
|
||||||
- an `asn=` or `country=` tag;
|
- an `asn=` or `country=` tag;
|
||||||
|
- a `prefix=` tag with a netblock, while `SWW_ROUTEWATCH_URL` is set. It matches
|
||||||
|
only that exact announced netblock, such as `prefix=192.0.2.0/24`;
|
||||||
- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key
|
- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key
|
||||||
masked as the `reputation` field shows it;
|
masked as the `reputation` field shows it;
|
||||||
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set;
|
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set;
|
||||||
@@ -246,6 +255,24 @@ the request first.
|
|||||||
a range. So does a directive that removes or changes rules as the file loads,
|
a range. So does a directive that removes or changes rules as the file loads,
|
||||||
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
||||||
yet. It has no `_FILE` form.
|
yet. It has no `_FILE` form.
|
||||||
|
- `SWW_APPS` (default unset): the apps each hostname serves. A request gets the
|
||||||
|
fixes of its hostname's apps only (see "Attack detection" in
|
||||||
|
[`SPEC.md`](../SPEC.md)).
|
||||||
|
- Entries are separated by semicolons. Each is a hostname, a colon, then one
|
||||||
|
or more apps separated by commas, for example
|
||||||
|
`git.example.com:gitea;code.example.com:gitea`.
|
||||||
|
- `gitea` is the only app supported.
|
||||||
|
- The request's `Host` is matched by exact name, without its port and
|
||||||
|
without regard to case. There are no wildcards.
|
||||||
|
- A hostname not listed gets no app's fixes, so every rule checks it in
|
||||||
|
full. Set empty, it lists no hostname.
|
||||||
|
- Unset, every app's fixes apply to every hostname.
|
||||||
|
- Any of these mistakes stops the start:
|
||||||
|
- an unknown app;
|
||||||
|
- a hostname that is not a DNS name, such as `*.example.com`;
|
||||||
|
- a hostname listed twice;
|
||||||
|
- an app listed twice for one hostname;
|
||||||
|
- an entry without a colon.
|
||||||
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get
|
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get
|
||||||
in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
||||||
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
||||||
|
|||||||
+150
-10
@@ -12,6 +12,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -29,6 +30,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the settings of `sww discriminator`. A time or size limit of
|
// Config is the settings of `sww discriminator`. A time or size limit of
|
||||||
@@ -106,6 +108,9 @@ type Config struct {
|
|||||||
TagLists []TagList
|
TagLists []TagList
|
||||||
TagDeny []string
|
TagDeny []string
|
||||||
TagLimitPercent map[string]int64
|
TagLimitPercent map[string]int64
|
||||||
|
// RouteWatchURL is where the routewatch instance is that adds the asn=
|
||||||
|
// and prefix= tags, "" for none (SWW_ROUTEWATCH_URL).
|
||||||
|
RouteWatchURL string
|
||||||
// The biased thresholds. ASNBytesPercent and CountryBytesPercent give
|
// The biased thresholds. ASNBytesPercent and CountryBytesPercent give
|
||||||
// those they list a percentage of the byte limits in place of the one
|
// those they list a percentage of the byte limits in place of the one
|
||||||
// TagLimitPercent gives their asn= or country= tag
|
// TagLimitPercent gives their asn= or country= tag
|
||||||
@@ -231,6 +236,11 @@ type Config struct {
|
|||||||
WAFExemptPaths []string
|
WAFExemptPaths []string
|
||||||
WAFBodyLimit int64
|
WAFBodyLimit int64
|
||||||
CoreRuleSetExclusionsFile string
|
CoreRuleSetExclusionsFile string
|
||||||
|
// Apps are the names of the apps each hostname serves, by hostname in
|
||||||
|
// lowercase (SWW_APPS): a request gets only its hostname's apps' fixes
|
||||||
|
// to the Core Rule Set. Apps is nil while SWW_APPS is unset, and every
|
||||||
|
// request gets every app's fixes.
|
||||||
|
Apps map[string][]string
|
||||||
// TrapPaths are the paths a request for which is a clear sign of
|
// TrapPaths are the paths a request for which is a clear sign of
|
||||||
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
|
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
|
||||||
TrapPaths []string
|
TrapPaths []string
|
||||||
@@ -400,6 +410,11 @@ var (
|
|||||||
errNotTrapPath = errors.New(
|
errNotTrapPath = errors.New(
|
||||||
"is not a path starting with / and without a ?, such as /wp-login.php",
|
"is not a path starting with / and without a ?, such as /wp-login.php",
|
||||||
)
|
)
|
||||||
|
errNotAppsEntry = errors.New(
|
||||||
|
"is not a hostname, : and a list of apps, such as git.example.com:gitea",
|
||||||
|
)
|
||||||
|
errNotHostname = errors.New("is not a DNS name such as git.example.com")
|
||||||
|
errNotApp = errors.New("is not an app sww has fixes for; the apps are")
|
||||||
errNotWAFMode = errors.New("is not off, detect or block")
|
errNotWAFMode = errors.New("is not off, detect or block")
|
||||||
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
||||||
errNotRuleID = errors.New(
|
errNotRuleID = errors.New(
|
||||||
@@ -462,6 +477,9 @@ var (
|
|||||||
errNotLAPIURL = errors.New(
|
errNotLAPIURL = errors.New(
|
||||||
"is not an http or https URL without a user or a fragment, " +
|
"is not an http or https URL without a user or a fragment, " +
|
||||||
"such as http://172.17.0.1:8080")
|
"such as http://172.17.0.1:8080")
|
||||||
|
errNotRouteWatchURL = errors.New(
|
||||||
|
"is not an http or https URL without a user or a fragment, " +
|
||||||
|
"such as https://routewatch.example")
|
||||||
errNeedsLAPIKey = errors.New("the engine answers no request without it")
|
errNeedsLAPIKey = errors.New("the engine answers no request without it")
|
||||||
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
|
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
|
||||||
errAnotherList = errors.New(
|
errAnotherList = errors.New(
|
||||||
@@ -475,7 +493,9 @@ var (
|
|||||||
"is not a tag, : and a percentage, such as vpn:50 or asn=14061:25")
|
"is not a tag, : and a percentage, such as vpn:50 or asn=14061:25")
|
||||||
errNotTagPercent = errors.New("is not a percentage from 1 to 99")
|
errNotTagPercent = errors.New("is not a percentage from 1 to 99")
|
||||||
errNotASNTag = errors.New("is not asn= and a number, such as asn=14061")
|
errNotASNTag = errors.New("is not asn= and a number, such as asn=14061")
|
||||||
errExploitTag = errors.New(
|
errNotPrefixTag = errors.New(
|
||||||
|
"is not prefix= and a netblock, such as prefix=192.0.2.0/24")
|
||||||
|
errExploitTag = errors.New(
|
||||||
"cannot lower limits: the rate limits count a request before its " +
|
"cannot lower limits: the rate limits count a request before its " +
|
||||||
"rules are matched")
|
"rules are matched")
|
||||||
errCrowdSecTag = errors.New(
|
errCrowdSecTag = errors.New(
|
||||||
@@ -483,8 +503,9 @@ var (
|
|||||||
errNoRequestGetsTag = errors.New(
|
errNoRequestGetsTag = errors.New(
|
||||||
"is a tag no request can get: it is not a name in SWW_TAG_LISTS, an " +
|
"is a tag no request can get: it is not a name in SWW_TAG_LISTS, an " +
|
||||||
"asn= or country= tag, a dnsbl= tag of a zone in SWW_DNSBL_ZONES, " +
|
"asn= or country= tag, a dnsbl= tag of a zone in SWW_DNSBL_ZONES, " +
|
||||||
"abuseipdb with SWW_ABUSEIPDB_KEY set, or exploitsignature with " +
|
"a prefix= tag with SWW_ROUTEWATCH_URL set, abuseipdb with " +
|
||||||
"rule files or the Core Rule Set on")
|
"SWW_ABUSEIPDB_KEY set, or exploitsignature with rule files or the " +
|
||||||
|
"Core Rule Set on")
|
||||||
)
|
)
|
||||||
|
|
||||||
// FromEnvironment reads the settings with lookupEnv, normally
|
// FromEnvironment reads the settings with lookupEnv, normally
|
||||||
@@ -524,6 +545,7 @@ func FromEnvironment(
|
|||||||
TagLists: env.tagLists("SWW_TAG_LISTS"),
|
TagLists: env.tagLists("SWW_TAG_LISTS"),
|
||||||
TagDeny: env.tags("SWW_TAG_DENY"),
|
TagDeny: env.tags("SWW_TAG_DENY"),
|
||||||
TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"),
|
TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"),
|
||||||
|
RouteWatchURL: env.routeWatchURL("SWW_ROUTEWATCH_URL"),
|
||||||
ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN),
|
ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN),
|
||||||
CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry),
|
CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry),
|
||||||
UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"),
|
UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"),
|
||||||
@@ -566,6 +588,7 @@ func FromEnvironment(
|
|||||||
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
|
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
|
||||||
CoreRuleSetExclusionsFile: env.filePath(
|
CoreRuleSetExclusionsFile: env.filePath(
|
||||||
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
||||||
|
Apps: env.apps("SWW_APPS"),
|
||||||
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
|
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
|
||||||
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
|
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
|
||||||
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
|
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
|
||||||
@@ -821,6 +844,25 @@ func (e *environment) pathPrefixes(name, defaultValue string) []string {
|
|||||||
return prefixes
|
return prefixes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// apps reads the setting that is the apps each hostname serves. It is nil
|
||||||
|
// while the setting is unset, and logged as null, unlike a setting set
|
||||||
|
// empty.
|
||||||
|
func (e *environment) apps(name string) map[string][]string {
|
||||||
|
value, set := e.lookup(name)
|
||||||
|
if !set {
|
||||||
|
e.settings = append(e.settings, slog.Any(name, nil))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
e.settings = append(e.settings, slog.String(name, value))
|
||||||
|
|
||||||
|
apps, err := parseApps(value)
|
||||||
|
e.check(name, err)
|
||||||
|
|
||||||
|
return apps
|
||||||
|
}
|
||||||
|
|
||||||
// trapPaths reads the setting that is the list of trap paths. It is empty
|
// trapPaths reads the setting that is the list of trap paths. It is empty
|
||||||
// by default.
|
// by default.
|
||||||
func (e *environment) trapPaths(name string) []string {
|
func (e *environment) trapPaths(name string) []string {
|
||||||
@@ -1019,6 +1061,22 @@ func (e *environment) crowdSecDecisionsURL(name string) string {
|
|||||||
return lapi.JoinPath("v1", "decisions").String()
|
return lapi.JoinPath("v1", "decisions").String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// routeWatchURL reads the setting that is the URL of a routewatch instance,
|
||||||
|
// "" while it is unset or empty.
|
||||||
|
func (e *environment) routeWatchURL(name string) string {
|
||||||
|
value := e.value(name, "")
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
instance, err := url.Parse(value)
|
||||||
|
if err != nil || !isHTTPURL(instance) {
|
||||||
|
e.check(name, fmt.Errorf("%q %w", value, errNotRouteWatchURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// lookupSource reads the setting that is where clients are looked up:
|
// lookupSource reads the setting that is where clients are looked up:
|
||||||
// geojs, file, or off.
|
// geojs, file, or off.
|
||||||
func (e *environment) lookupSource(name, defaultValue string) string {
|
func (e *environment) lookupSource(name, defaultValue string) string {
|
||||||
@@ -1064,14 +1122,21 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// routewatch gives asn= tags too.
|
||||||
|
onlyLookupGives := func(tag string) bool {
|
||||||
|
return isLookupTag(tag) &&
|
||||||
|
(cfg.RouteWatchURL == "" || !strings.HasPrefix(tag, ASNTagPrefix))
|
||||||
|
}
|
||||||
|
|
||||||
for _, setting := range []struct {
|
for _, setting := range []struct {
|
||||||
name string
|
name string
|
||||||
set bool
|
set bool
|
||||||
}{
|
}{
|
||||||
{"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
|
{"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
|
||||||
{"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
|
{"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
|
||||||
{"SWW_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, isLookupTag)},
|
{"SWW_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, onlyLookupGives)},
|
||||||
{"SWW_TAG_LIMIT_PERCENT", tagLimitNeedsLookups(cfg)},
|
{"SWW_TAG_LIMIT_PERCENT", slices.ContainsFunc(
|
||||||
|
slices.Collect(maps.Keys(cfg.TagLimitPercent)), onlyLookupGives)},
|
||||||
{"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
|
{"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
|
||||||
{"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
|
{"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
|
||||||
{"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
|
{"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
|
||||||
@@ -1171,6 +1236,10 @@ func requestCanGetTag(cfg *Config, tag string) bool {
|
|||||||
return cfg.AbuseIPDBKey != ""
|
return cfg.AbuseIPDBKey != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(tag, PrefixTagPrefix) {
|
||||||
|
return cfg.RouteWatchURL != ""
|
||||||
|
}
|
||||||
|
|
||||||
zone, isZoneTag := strings.CutPrefix(tag, DNSBLTagPrefix)
|
zone, isZoneTag := strings.CutPrefix(tag, DNSBLTagPrefix)
|
||||||
|
|
||||||
return isZoneTag && slices.ContainsFunc(cfg.DNSBLZones, func(each string) bool {
|
return isZoneTag && slices.ContainsFunc(cfg.DNSBLZones, func(each string) bool {
|
||||||
@@ -1179,7 +1248,7 @@ func requestCanGetTag(cfg *Config, tag string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT
|
// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT
|
||||||
// names an asn= or country= tag, which only the lookup can give a request.
|
// names an asn= or country= tag.
|
||||||
func (c *Config) TagsNeedLookup() bool {
|
func (c *Config) TagsNeedLookup() bool {
|
||||||
return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c)
|
return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c)
|
||||||
}
|
}
|
||||||
@@ -1811,6 +1880,65 @@ func parseTrapPaths(value string) ([]string, error) {
|
|||||||
return paths, nil
|
return paths, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseApps reads the apps each hostname serves: entries separated by
|
||||||
|
// semicolons, each a hostname, a colon, and a comma-separated list of the
|
||||||
|
// names waf.AppNames returns, such as
|
||||||
|
// git.example.com:gitea;cloud.example.com:nextcloud,wordpress. Each
|
||||||
|
// hostname is a DNS name, without a wildcard. It returns them by hostname
|
||||||
|
// in lowercase, since a hostname is matched without regard to case. An
|
||||||
|
// empty value lists no hostname.
|
||||||
|
func parseApps(value string) (map[string][]string, error) {
|
||||||
|
apps := map[string][]string{}
|
||||||
|
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return apps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry := range strings.SplitSeq(value, ";") {
|
||||||
|
hostname, list, found := strings.Cut(entry, ":")
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname = strings.ToLower(strings.TrimSpace(hostname))
|
||||||
|
if hostname == "" {
|
||||||
|
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isZone(hostname) {
|
||||||
|
return nil, fmt.Errorf("%q %w", hostname, errNotHostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, listed := apps[hostname]; listed {
|
||||||
|
return nil, fmt.Errorf("%q %w", hostname, errListedTwice)
|
||||||
|
}
|
||||||
|
|
||||||
|
names, err := parseList(list)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, name := range names {
|
||||||
|
if !slices.Contains(waf.AppNames(), name) {
|
||||||
|
return nil, fmt.Errorf("%q %w %s", name, errNotApp,
|
||||||
|
strings.Join(waf.AppNames(), ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(names[:i], name) {
|
||||||
|
return nil, fmt.Errorf("%q %w", name, errListedTwice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apps[hostname] = names
|
||||||
|
}
|
||||||
|
|
||||||
|
return apps, nil
|
||||||
|
}
|
||||||
|
|
||||||
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
|
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
|
||||||
// rules, each a whole number above zero and outside firstSetupRuleID to
|
// rules, each a whole number above zero and outside firstSetupRuleID to
|
||||||
// lastSetupRuleID.
|
// lastSetupRuleID.
|
||||||
@@ -1923,6 +2051,7 @@ const (
|
|||||||
ASNTagPrefix = "asn="
|
ASNTagPrefix = "asn="
|
||||||
CountryTagPrefix = "country="
|
CountryTagPrefix = "country="
|
||||||
DNSBLTagPrefix = "dnsbl="
|
DNSBLTagPrefix = "dnsbl="
|
||||||
|
PrefixTagPrefix = "prefix="
|
||||||
AbuseIPDBTag = "abuseipdb"
|
AbuseIPDBTag = "abuseipdb"
|
||||||
CrowdSecTag = "crowdsec"
|
CrowdSecTag = "crowdsec"
|
||||||
// ExploitSignatureTag is the tag of a request a rule file or the Core
|
// ExploitSignatureTag is the tag of a request a rule file or the Core
|
||||||
@@ -1999,9 +2128,10 @@ func parseTags(value string) ([]string, error) {
|
|||||||
|
|
||||||
// parseTag reads a tag in either case, with spaces around it, and returns it
|
// parseTag reads a tag in either case, with spaces around it, and returns it
|
||||||
// as a request carries it: in lower case, but for a country code, which is in
|
// as a request carries it: in lower case, but for a country code, which is in
|
||||||
// capitals, and an asn= tag's number without leading zeros. An asn= or
|
// capitals, an asn= tag's number without leading zeros, and a prefix= tag's
|
||||||
// country= tag must name a number or a country, so that a mistyped one stops
|
// netblock as routewatch writes it. An asn=, country= or prefix= tag must name
|
||||||
// the start instead of never matching.
|
// a number, a country or a netblock, so that a mistyped one stops the start
|
||||||
|
// instead of never matching.
|
||||||
func parseTag(value string) (string, error) {
|
func parseTag(value string) (string, error) {
|
||||||
tag := strings.ToLower(strings.TrimSpace(value))
|
tag := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
|
||||||
@@ -2023,6 +2153,15 @@ func parseTag(value string) (string, error) {
|
|||||||
return CountryTagPrefix + country, nil
|
return CountryTagPrefix + country, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if text, isPrefix := strings.CutPrefix(tag, PrefixTagPrefix); isPrefix {
|
||||||
|
netblock, err := netip.ParsePrefix(text)
|
||||||
|
if err != nil || netblock != netblock.Masked() {
|
||||||
|
return "", fmt.Errorf("%q %w", value, errNotPrefixTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return PrefixTagPrefix + netblock.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
return tag, nil
|
return tag, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2406,7 +2545,8 @@ func MaskZoneKey(zone string) string {
|
|||||||
return masked + "." + rest
|
return masked + "." + rest
|
||||||
}
|
}
|
||||||
|
|
||||||
// isZone reports whether each label of zone is as parseZones takes it.
|
// isZone reports whether each label of zone is as parseZones takes it. It
|
||||||
|
// also checks the hostnames of SWW_APPS, which are DNS names too.
|
||||||
func isZone(zone string) bool {
|
func isZone(zone string) bool {
|
||||||
for label := range strings.SplitSeq(zone, ".") {
|
for label := range strings.SplitSeq(zone, ".") {
|
||||||
badChar := strings.ContainsFunc(label, func(char rune) bool {
|
badChar := strings.ContainsFunc(label, func(char rune) bool {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const (
|
|||||||
reputationAction = "SWW_REPUTATION_ACTION"
|
reputationAction = "SWW_REPUTATION_ACTION"
|
||||||
reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
|
reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
|
||||||
reputationTimeout = "SWW_REPUTATION_TIMEOUT"
|
reputationTimeout = "SWW_REPUTATION_TIMEOUT"
|
||||||
|
routeWatchURL = "SWW_ROUTEWATCH_URL"
|
||||||
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
||||||
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
||||||
banResponse = "SWW_BAN_RESPONSE"
|
banResponse = "SWW_BAN_RESPONSE"
|
||||||
@@ -88,6 +89,7 @@ const (
|
|||||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
||||||
|
apps = "SWW_APPS"
|
||||||
trapPaths = "SWW_TRAP_PATHS"
|
trapPaths = "SWW_TRAP_PATHS"
|
||||||
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||||
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
||||||
@@ -581,6 +583,92 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApps(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
env environment
|
||||||
|
want map[string][]string
|
||||||
|
}{
|
||||||
|
{environment{}, nil},
|
||||||
|
{environment{apps: ""}, map[string][]string{}},
|
||||||
|
{
|
||||||
|
environment{apps: "Git.Example.com:gitea; cloud.example.com : gitea "},
|
||||||
|
map[string][]string{
|
||||||
|
"git.example.com": {"gitea"}, "cloud.example.com": {"gitea"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
cfg := fromEnvironment(t, tc.env)
|
||||||
|
if !reflect.DeepEqual(cfg.Apps, tc.want) {
|
||||||
|
t.Errorf("%v gave %#v, want %#v", tc.env, cfg.Apps, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unset and set empty, SWW_APPS has opposite effects, so it logs differently.
|
||||||
|
func TestUnsetAndEmptyAppsAreLoggedDifferently(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
env environment
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{environment{}, `"` + apps + `":null`},
|
||||||
|
{environment{apps: ""}, `"` + apps + `":""`},
|
||||||
|
} {
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
slog.New(slog.NewJSONHandler(&out, nil)).
|
||||||
|
Info("starting", "settings", fromEnvironment(t, tc.env))
|
||||||
|
|
||||||
|
if !strings.Contains(out.String(), tc.want) {
|
||||||
|
t.Errorf("%v logged %s, want %s in it", tc.env, out.String(), tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidAppsStopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
notEntry = " is not a hostname, : and a list of apps, " +
|
||||||
|
"such as git.example.com:gitea"
|
||||||
|
notHostname = " is not a DNS name such as git.example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, tc := range []struct{ value, want string }{
|
||||||
|
{"git.example.com:gitea;blog.example.com", `"blog.example.com"` + notEntry},
|
||||||
|
{"git.example.com:gitea;", `""` + notEntry},
|
||||||
|
{":gitea", `":gitea"` + notEntry},
|
||||||
|
{"git.example.com:", `"git.example.com:"` + notEntry},
|
||||||
|
{"*.example.com:gitea", `"*.example.com"` + notHostname},
|
||||||
|
{"git.example.com/:gitea", `"git.example.com/"` + notHostname},
|
||||||
|
{"git example.com:gitea", `"git example.com"` + notHostname},
|
||||||
|
{
|
||||||
|
"git.example.com:gitea,nextcloud",
|
||||||
|
`"nextcloud" is not an app sww has fixes for; the apps are gitea`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"git.example.com:gitea;GIT.example.com:gitea",
|
||||||
|
`"git.example.com" is listed twice`,
|
||||||
|
},
|
||||||
|
{"git.example.com:gitea,gitea", `"gitea" is listed twice`},
|
||||||
|
{"git.example.com:gitea,", `"gitea," has an empty item in its list`},
|
||||||
|
} {
|
||||||
|
t.Run(tc.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := config.FromEnvironment(environment{apps: tc.value}.lookupEnv)
|
||||||
|
|
||||||
|
want := apps + ": " + tc.want
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("error %v, want %s", err, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWAFBodyLimitOf64K(t *testing.T) {
|
func TestWAFBodyLimitOf64K(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1403,6 +1491,63 @@ func TestTagSettingsAsSet(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRouteWatchURLAndPrefixTags(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const rw = "https://routewatch.example"
|
||||||
|
|
||||||
|
if cfg := fromEnvironment(t, environment{}); cfg.RouteWatchURL != "" {
|
||||||
|
t.Errorf("default %s is %q, want unset", routeWatchURL, cfg.RouteWatchURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := fromEnvironment(t, environment{
|
||||||
|
routeWatchURL: rw, tagDeny: "prefix=2001:DB8::/32",
|
||||||
|
tagLimitPercent: "prefix=192.0.2.0/24:50",
|
||||||
|
})
|
||||||
|
wantDeny := []string{"prefix=2001:db8::/32"}
|
||||||
|
|
||||||
|
if cfg.RouteWatchURL != rw || !slices.Equal(cfg.TagDeny, wantDeny) ||
|
||||||
|
cfg.TagLimitPercent["prefix=192.0.2.0/24"] != 50 {
|
||||||
|
t.Errorf("gave %q, %v, %v", cfg.RouteWatchURL, cfg.TagDeny, cfg.TagLimitPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// routewatch gives asn= tags too, so an asn= tag needs no lookups while it is set.
|
||||||
|
fromEnvironment(t, environment{
|
||||||
|
lookupSource: off, routeWatchURL: rw, tagDeny: "asn=64496",
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, stopped := range []struct {
|
||||||
|
env environment
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{environment{routeWatchURL: "ftp://routewatch.example"}, routeWatchURL},
|
||||||
|
{environment{routeWatchURL: "routewatch.example"}, routeWatchURL},
|
||||||
|
{environment{tagDeny: "prefix=192.0.2.0/24"}, tagDeny},
|
||||||
|
{environment{routeWatchURL: rw, tagDeny: "prefix=nowhere"}, tagDeny},
|
||||||
|
{environment{routeWatchURL: rw, tagDeny: "prefix=192.0.2.5/24"}, tagDeny},
|
||||||
|
{
|
||||||
|
environment{routeWatchURL: rw, tagLimitPercent: "prefix=192.0.2.0/33:50"},
|
||||||
|
tagLimitPercent,
|
||||||
|
},
|
||||||
|
{environment{lookupSource: off, routeWatchURL: rw, tagDeny: "country=DE"}, tagDeny},
|
||||||
|
{environment{lookupSource: off, tagDeny: "asn=64496"}, tagDeny},
|
||||||
|
} {
|
||||||
|
_, err := config.FromEnvironment(stopped.env.lookupEnv)
|
||||||
|
if err == nil || !strings.HasPrefix(err.Error(), stopped.name+": ") {
|
||||||
|
t.Errorf("%v gave the error %v, want one naming %s", stopped.env, err, stopped.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
badURL := environment{routeWatchURL: "ftp://routewatch.example"}
|
||||||
|
_, err := config.FromEnvironment(badURL.lookupEnv)
|
||||||
|
|
||||||
|
want := routeWatchURL + `: "ftp://routewatch.example" is not an http or https URL ` +
|
||||||
|
"without a user or a fragment, such as https://routewatch.example"
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("error %v, want %s", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
|
func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -2503,6 +2648,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
reputationAction: "limit:25",
|
reputationAction: "limit:25",
|
||||||
reputationCacheTTL: defaultReputationCacheTTL,
|
reputationCacheTTL: defaultReputationCacheTTL,
|
||||||
reputationTimeout: "2s",
|
reputationTimeout: "2s",
|
||||||
|
routeWatchURL: "",
|
||||||
crowdSecURL: "",
|
crowdSecURL: "",
|
||||||
crowdSecKey: "",
|
crowdSecKey: "",
|
||||||
banResponse: "403",
|
banResponse: "403",
|
||||||
@@ -2530,6 +2676,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
wafExemptPaths: "",
|
wafExemptPaths: "",
|
||||||
wafBodyLimit: off,
|
wafBodyLimit: off,
|
||||||
exclusionsFile: "",
|
exclusionsFile: "",
|
||||||
|
apps: "", // logged as null, which decodes as ""
|
||||||
trapPaths: "",
|
trapPaths: "",
|
||||||
errorBurstThreshold: "30",
|
errorBurstThreshold: "30",
|
||||||
logRemoteURL: "",
|
logRemoteURL: "",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
@@ -21,6 +22,7 @@ const (
|
|||||||
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
|
apps = "SWW_APPS"
|
||||||
block = "block"
|
block = "block"
|
||||||
detect = "detect"
|
detect = "detect"
|
||||||
)
|
)
|
||||||
@@ -282,6 +284,27 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Only gitea's fixes let a file name through in path, a parameter
|
||||||
|
// gitea sends file names in.
|
||||||
|
const fileName = "/?path=.gitignore"
|
||||||
|
|
||||||
|
s := startDiscriminator(t, "", map[string]string{
|
||||||
|
wafMode: block, apps: siteA + ":gitea",
|
||||||
|
})
|
||||||
|
|
||||||
|
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
|
||||||
|
http.StatusOK, discriminator.DispositionAllow)
|
||||||
|
wantDisposition(t, do(t, s.question(siteB, client, fileName)),
|
||||||
|
http.StatusForbidden, discriminator.DispositionDeny)
|
||||||
|
|
||||||
|
lines := s.out.requestLines(t, 2)
|
||||||
|
wantLine(t, lines[0], http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionWAFBlocked)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ func New(params Params) *Server {
|
|||||||
Lists: h.lists,
|
Lists: h.lists,
|
||||||
DNSBL: h.dnsbl,
|
DNSBL: h.dnsbl,
|
||||||
AbuseIPDB: h.abuseIPDB,
|
AbuseIPDB: h.abuseIPDB,
|
||||||
|
RouteWatch: h.routeWatch,
|
||||||
Metrics: h.metrics,
|
Metrics: h.metrics,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const (
|
|||||||
func beforeLookup() []heuristic {
|
func beforeLookup() []heuristic {
|
||||||
return []heuristic{
|
return []heuristic{
|
||||||
tagLists{},
|
tagLists{},
|
||||||
|
routeWatchTags{},
|
||||||
denyNets{},
|
denyNets{},
|
||||||
activeBan{},
|
activeBan{},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,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/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/routewatch"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
)
|
)
|
||||||
@@ -85,7 +86,8 @@ type Params struct {
|
|||||||
// Server is the server discriminator runs, with the parts whose state the state
|
// Server is the server discriminator runs, with the parts whose state the state
|
||||||
// files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the
|
// files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the
|
||||||
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
|
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
|
||||||
// AbuseIPDB's scores and checks spent, and the metrics.
|
// AbuseIPDB's scores and checks spent, and the metrics. RouteWatch, nil while
|
||||||
|
// SWW_ROUTEWATCH_URL is unset, keeps its answers in memory only.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
*http.Server
|
*http.Server
|
||||||
|
|
||||||
@@ -97,6 +99,7 @@ type Server struct {
|
|||||||
Lists *reputation.Lists
|
Lists *reputation.Lists
|
||||||
DNSBL *reputation.DNSBL
|
DNSBL *reputation.DNSBL
|
||||||
AbuseIPDB *reputation.AbuseIPDB
|
AbuseIPDB *reputation.AbuseIPDB
|
||||||
|
RouteWatch *routewatch.Client
|
||||||
Metrics *metrics.Metrics
|
Metrics *metrics.Metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +155,12 @@ func newHandler(params Params) *handler {
|
|||||||
coreRuleSet: params.CoreRuleSet,
|
coreRuleSet: params.CoreRuleSet,
|
||||||
alerts: params.Alerts,
|
alerts: params.Alerts,
|
||||||
}
|
}
|
||||||
|
if params.Config.RouteWatchURL != "" {
|
||||||
|
h.routeWatch = routewatch.New(routewatch.Params{
|
||||||
|
URL: params.Config.RouteWatchURL, Now: params.Now, ProcessLog: params.ProcessLog,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
h.geojs = lookup.New(lookup.Params{
|
h.geojs = lookup.New(lookup.Params{
|
||||||
URL: params.GeoJSURL,
|
URL: params.GeoJSURL,
|
||||||
Timeout: params.Config.LookupTimeout,
|
Timeout: params.Config.LookupTimeout,
|
||||||
@@ -212,7 +221,8 @@ func newReputation(
|
|||||||
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
|
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
|
||||||
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
|
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
|
||||||
// up to SWW_WAF_BODY_LIMIT, with the exclusions in
|
// up to SWW_WAF_BODY_LIMIT, with the exclusions in
|
||||||
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It
|
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_APPS,
|
||||||
|
// or nil while SWW_WAF_MODE is off. It
|
||||||
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
||||||
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
||||||
// JSON or XML body over its limit. An exclusions file that waf.New refuses is
|
// JSON or XML body over its limit. An exclusions file that waf.New refuses is
|
||||||
@@ -227,6 +237,7 @@ func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) {
|
|||||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
||||||
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
||||||
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
||||||
|
Apps: cfg.Apps,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
||||||
@@ -255,6 +266,7 @@ type handler struct {
|
|||||||
lists *reputation.Lists
|
lists *reputation.Lists
|
||||||
dnsbl *reputation.DNSBL
|
dnsbl *reputation.DNSBL
|
||||||
abuseIPDB *reputation.AbuseIPDB
|
abuseIPDB *reputation.AbuseIPDB
|
||||||
|
routeWatch *routewatch.Client
|
||||||
rules *rules.Files
|
rules *rules.Files
|
||||||
coreRuleSet *waf.CoreRuleSet
|
coreRuleSet *waf.CoreRuleSet
|
||||||
alerts *alerts.Queue
|
alerts *alerts.Queue
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic routewatch_tags. It reads SWW_ROUTEWATCH_URL.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// routeWatchTags adds to the request the tags asn=<number> and prefix=<netblock>
|
||||||
|
// from what routewatch answered about the client. It refuses nothing, and the
|
||||||
|
// request does not wait for routewatch. A request that gets no tags from it has
|
||||||
|
// the reason in the log line's routewatch_tags_missing. While SWW_ROUTEWATCH_URL
|
||||||
|
// is unset it does nothing. Answers are kept by client and asked for by the
|
||||||
|
// request's address, and a route without an origin AS gives only the prefix= tag.
|
||||||
|
type routeWatchTags struct{}
|
||||||
|
|
||||||
|
func (routeWatchTags) name() string { return "routewatch_tags" }
|
||||||
|
|
||||||
|
func (routeWatchTags) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
if rq.h.routeWatch == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
route, missing := rq.h.routeWatch.Route(ctx, rq.h.clientGroup(rq.client), rq.client)
|
||||||
|
if missing != "" {
|
||||||
|
rq.line.RouteWatchTagsMissing = string(missing)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.addTag(config.PrefixTagPrefix + route.Netblock.String())
|
||||||
|
|
||||||
|
if route.ASN != 0 {
|
||||||
|
rq.addTag(config.ASNTagPrefix + strconv.FormatUint(uint64(route.ASN), 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package discriminator_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// routeWatchURL is SWW_ROUTEWATCH_URL, and tagsMissing the log line's field
|
||||||
|
// that says why a request got no tags from routewatch.
|
||||||
|
const (
|
||||||
|
routeWatchURL = "SWW_ROUTEWATCH_URL"
|
||||||
|
tagsMissing = "routewatch_tags_missing"
|
||||||
|
fromV6 = "2001:db8:1::5"
|
||||||
|
prefixV6 = "2001:db8::/32"
|
||||||
|
prefixDE = "203.0.113.0/24"
|
||||||
|
prefixKP = "198.51.100.0/24"
|
||||||
|
)
|
||||||
|
|
||||||
|
// standInRoute is what the stand-in for routewatch answers about an address.
|
||||||
|
type standInRoute struct {
|
||||||
|
Netblock string `json:"netblock"`
|
||||||
|
ASN int `json:"asn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// startRouteWatch starts a stand-in for routewatch's IP info API that knows the
|
||||||
|
// routes given by address, and returns its URL.
|
||||||
|
func startRouteWatch(t *testing.T, routes map[string]standInRoute) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
address := strings.TrimPrefix(r.URL.Path, "/api/v1/ip/")
|
||||||
|
|
||||||
|
result := map[string]any{"ip": address}
|
||||||
|
if route, known := routes[address]; known {
|
||||||
|
result["netblock"], result["asn"] = route.Netblock, route.ASN
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "ok",
|
||||||
|
"data": map[string]any{"results": []any{result}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
return server.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForRouteWatch waits until routewatch's answer about from, in the client
|
||||||
|
// group of the given length, is kept, so that the next request gets its tags.
|
||||||
|
func waitForRouteWatch(
|
||||||
|
t *testing.T, server *discriminator.Server, from string, bits int,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
addr := netip.MustParseAddr(from)
|
||||||
|
client := netip.PrefixFrom(addr, bits).Masked()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, missing := server.RouteWatch.Route(context.Background(), client, addr)
|
||||||
|
if missing == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("routewatch's answer about %s not kept: %q", from, missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func wantTagsMissing(t *testing.T, line logLine, want string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got, _ := line.fields[tagsMissing].(string)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("log line has %s %q, want %q", tagsMissing, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteWatchTagsAreOnTheLogLineWithTheLookupsTags(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 64496}})
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{routeWatchURL: url})
|
||||||
|
|
||||||
|
waitForRouteWatch(t, server, fromDE, 32)
|
||||||
|
|
||||||
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantTags(t, line, "asn=64496", "country=DE", "prefix="+prefixDE)
|
||||||
|
wantTagsMissing(t, line, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteWatchTagsAreOnALineDenyNetsRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
url := startRouteWatch(t, map[string]standInRoute{fromKP: {prefixKP, 64511}})
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
|
lookupSource: off, routeWatchURL: url, denyNets: fromKP,
|
||||||
|
})
|
||||||
|
|
||||||
|
waitForRouteWatch(t, server, fromKP, 32)
|
||||||
|
|
||||||
|
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
|
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+fromKP+`/32"}`)
|
||||||
|
wantTags(t, line, "asn=64511", "prefix="+prefixKP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteWithoutAnOriginASGivesOnlyThePrefixTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 0}})
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
|
lookupSource: off, routeWatchURL: url,
|
||||||
|
})
|
||||||
|
|
||||||
|
waitForRouteWatch(t, server, fromDE, 32)
|
||||||
|
|
||||||
|
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
"prefix="+prefixDE)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsetRouteWatchChangesNothing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{lookupSource: off})
|
||||||
|
|
||||||
|
if server.RouteWatch != nil {
|
||||||
|
t.Error("RouteWatch is set while SWW_ROUTEWATCH_URL is unset")
|
||||||
|
}
|
||||||
|
|
||||||
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
if _, present := line.fields[tagsMissing]; present {
|
||||||
|
t.Errorf("log line has %s", tagsMissing)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, present := line.fields["tags"]; present {
|
||||||
|
t.Errorf("log line has tags %v", line.fields["tags"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnreachableRouteWatchLetsTheRequestThroughAndSaysWhy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A stand-in that is closed at once, so that the port refuses connections.
|
||||||
|
dead := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
url := dead.URL
|
||||||
|
|
||||||
|
dead.Close()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
|
lookupSource: off, routeWatchURL: url,
|
||||||
|
})
|
||||||
|
|
||||||
|
wantTagsMissing(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
"no_answer_yet")
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
for {
|
||||||
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
missing, _ := line.fields[tagsMissing].(string)
|
||||||
|
if missing == "request_failed" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("still %q, want request_failed", missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddressWithoutARouteHasNoTagsAndTheLineSaysSo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
|
lookupSource: off, routeWatchURL: startRouteWatch(t, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
for {
|
||||||
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
missing, _ := line.fields[tagsMissing].(string)
|
||||||
|
if missing == "no_route" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("still %q, want no_route", missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagDenyRefusesOnARouteWatchPrefix(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
url := startRouteWatch(t, map[string]standInRoute{
|
||||||
|
fromDE: {prefixDE, 64496}, fromKP: {prefixKP, 64511},
|
||||||
|
})
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
|
lookupSource: off, routeWatchURL: url, tagDeny: "prefix=" + prefixKP,
|
||||||
|
})
|
||||||
|
|
||||||
|
waitForRouteWatch(t, server, fromKP, 32)
|
||||||
|
waitForRouteWatch(t, server, fromDE, 32)
|
||||||
|
|
||||||
|
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
|
||||||
|
"tag_deny", `{"tag": "prefix=`+prefixKP+`"}`)
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagLimitPercentWorksWithAnIPv6Prefix(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
url := startRouteWatch(t, map[string]standInRoute{fromV6: {prefixV6, 64496}})
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
|
lookupSource: off, routeWatchURL: url, rateLimitPerMinute: "2",
|
||||||
|
tagLimitPercent: "prefix=" + prefixV6 + ":50",
|
||||||
|
})
|
||||||
|
|
||||||
|
waitForRouteWatch(t, server, fromV6, 64)
|
||||||
|
|
||||||
|
// Half of two is one: the second request breaks the limit.
|
||||||
|
line := s.get(fromV6, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantTags(t, line, "asn=64496", "prefix="+prefixV6)
|
||||||
|
s.get(fromV6, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
}
|
||||||
@@ -182,6 +182,10 @@ type Line struct {
|
|||||||
Reputation []string `json:"reputation,omitempty"`
|
Reputation []string `json:"reputation,omitempty"`
|
||||||
// Tags are the request's tags, sorted.
|
// Tags are the request's tags, sorted.
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
// RouteWatchTagsMissing, while SWW_ROUTEWATCH_URL is set, says why the
|
||||||
|
// request got no tags from routewatch: no_answer_yet, request_failed or
|
||||||
|
// no_route.
|
||||||
|
RouteWatchTagsMissing string `json:"routewatch_tags_missing,omitempty"`
|
||||||
// Offence is the offence the request was held as, OffenceLimit.
|
// Offence is the offence the request was held as, OffenceLimit.
|
||||||
Offence string `json:"offence,omitempty"`
|
Offence string `json:"offence,omitempty"`
|
||||||
// BanExpires is when the ban the request made, or was refused under,
|
// BanExpires is when the ban the request made, or was refused under,
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// Package routewatch asks a routewatch instance about client addresses, in the
|
||||||
|
// background, and keeps its answers in memory. A routewatch instance knows, for
|
||||||
|
// an address, the netblock it is announced in and the AS that originates it.
|
||||||
|
package routewatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// answerKept is how long an answer is used after it came.
|
||||||
|
answerKept = time.Hour
|
||||||
|
// failureKept is how long routewatch is not asked again after a query
|
||||||
|
// fails, so that a dead routewatch is not asked on every request.
|
||||||
|
failureKept = time.Minute
|
||||||
|
// queryTimeout is how long one query may take. routewatch answers only
|
||||||
|
// after a reverse DNS lookup it allows 2 seconds, so this is longer.
|
||||||
|
queryTimeout = 5 * time.Second
|
||||||
|
// maxAnswers is how many answers are kept. Past it, the one that came
|
||||||
|
// longest ago is dropped.
|
||||||
|
maxAnswers = 100000
|
||||||
|
// maxQueries is how many queries may be under way at once. Past it, a
|
||||||
|
// client is not asked about until its next request, so that a swarm of
|
||||||
|
// new addresses cannot fill the memory.
|
||||||
|
maxQueries = 100
|
||||||
|
// maxAnswerBytes is the most of an answer that is read.
|
||||||
|
maxAnswerBytes = 64 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// Missing is why Route gives no route. The request log's field
|
||||||
|
// routewatch_tags_missing gives these values.
|
||||||
|
type Missing string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// NoAnswerYet is a client routewatch has not answered about yet.
|
||||||
|
NoAnswerYet Missing = "no_answer_yet"
|
||||||
|
// RequestFailed is no answer because a recent query failed.
|
||||||
|
RequestFailed Missing = "request_failed"
|
||||||
|
// NoRoute is routewatch knowing no live route for the address.
|
||||||
|
NoRoute Missing = "no_route"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errStatus = errors.New("routewatch answered")
|
||||||
|
errNoResult = errors.New("the answer holds no result")
|
||||||
|
errNotNetblock = errors.New("the answer's netblock is not a netblock")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Route is what routewatch knows of an address: the netblock it is announced
|
||||||
|
// in, and the AS that originates it.
|
||||||
|
type Route struct {
|
||||||
|
Netblock netip.Prefix
|
||||||
|
ASN uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params are what New needs.
|
||||||
|
type Params struct {
|
||||||
|
// URL is where routewatch is (SWW_ROUTEWATCH_URL).
|
||||||
|
URL string
|
||||||
|
// Now tells the time, normally time.Now in UTC.
|
||||||
|
Now func() time.Time
|
||||||
|
// ProcessLog receives the query that fails, and why.
|
||||||
|
ProcessLog *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client asks a routewatch instance about clients, in the background, and keeps
|
||||||
|
// its answers. It is safe for concurrent use.
|
||||||
|
type Client struct {
|
||||||
|
params Params
|
||||||
|
httpClient *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
// answers are by client. Each is added as it comes and never moved up,
|
||||||
|
// so that the one that came longest ago is the first dropped.
|
||||||
|
answers *simplelru.LRU[netip.Prefix, answer]
|
||||||
|
// asking are the clients whose query is under way.
|
||||||
|
asking map[netip.Prefix]bool
|
||||||
|
// retryAt is when routewatch may be asked again after a query failed.
|
||||||
|
retryAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// answer is one kept answer: the route, or why there is none, and when it came.
|
||||||
|
type answer struct {
|
||||||
|
route Route
|
||||||
|
missing Missing
|
||||||
|
came time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Client with no answer yet.
|
||||||
|
func New(params Params) *Client {
|
||||||
|
answers, err := simplelru.NewLRU[netip.Prefix, answer](maxAnswers, nil)
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // NewLRU fails only for a size below one
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
params: params,
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
answers: answers,
|
||||||
|
asking: map[netip.Prefix]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route returns what routewatch answered about client, an IPv4 address or an
|
||||||
|
// IPv6 group, or why it has no route. It never waits for a query. A client
|
||||||
|
// without an answer is asked about in the background, by addr, the address its
|
||||||
|
// request came from, unless its query is under way, a query failed less than
|
||||||
|
// failureKept ago, or maxQueries are under way. ctx is the context of the
|
||||||
|
// client's request, and a query goes on after the request ends.
|
||||||
|
func (c *Client) Route(
|
||||||
|
ctx context.Context, client netip.Prefix, addr netip.Addr,
|
||||||
|
) (Route, Missing) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
now := c.params.Now()
|
||||||
|
|
||||||
|
kept, found := c.answers.Peek(client)
|
||||||
|
if found && now.Sub(kept.came) < answerKept {
|
||||||
|
return kept.route, kept.missing
|
||||||
|
}
|
||||||
|
|
||||||
|
if now.Before(c.retryAt) {
|
||||||
|
return Route{}, RequestFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.asking[client] && len(c.asking) < maxQueries {
|
||||||
|
c.asking[client] = true
|
||||||
|
|
||||||
|
go c.ask(context.WithoutCancel(ctx), client, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Route{}, NoAnswerYet
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask asks routewatch about addr, keeps the answer as client's, and notes the
|
||||||
|
// query as no longer under way. A query that fails gives no answer, and
|
||||||
|
// routewatch is not asked again for failureKept. Only the first failure of
|
||||||
|
// those under way is logged.
|
||||||
|
func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) {
|
||||||
|
route, missing, err := c.query(ctx, addr)
|
||||||
|
now := c.params.Now()
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
|
||||||
|
delete(c.asking, client)
|
||||||
|
|
||||||
|
firstFailure := err != nil && !now.Before(c.retryAt)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
c.answers.Add(client, answer{route: route, missing: missing, came: now})
|
||||||
|
} else {
|
||||||
|
c.retryAt = now.Add(failureKept)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if firstFailure {
|
||||||
|
c.params.ProcessLog.Warn("asking routewatch failed", "error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// query asks routewatch's IP info API about addr. An answer other than 200, one
|
||||||
|
// that cannot be read, and none within queryTimeout, fail. A result with no
|
||||||
|
// netblock is an address without a live route.
|
||||||
|
func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, Missing, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
endpoint, err := url.JoinPath(c.params.URL, "api/v1/ip", addr.String())
|
||||||
|
if err != nil {
|
||||||
|
return Route{}, "", fmt.Errorf("make the URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
|
||||||
|
if err != nil {
|
||||||
|
return Route{}, "", fmt.Errorf("make the request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
res, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Do's error names the URL, which holds the client's address, which
|
||||||
|
// is not to be logged: only what went wrong is kept.
|
||||||
|
return Route{}, "", fmt.Errorf("ask routewatch: %w", errors.Unwrap(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = res.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return Route{}, "", fmt.Errorf("%w %s", errStatus, res.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return readAnswer(io.LimitReader(res.Body, maxAnswerBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAnswer reads routewatch's answer about one address.
|
||||||
|
func readAnswer(body io.Reader) (Route, Missing, error) {
|
||||||
|
var decoded struct {
|
||||||
|
Data struct {
|
||||||
|
Results []struct {
|
||||||
|
Netblock string `json:"netblock"`
|
||||||
|
ASN uint32 `json:"asn"`
|
||||||
|
} `json:"results"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.NewDecoder(body).Decode(&decoded)
|
||||||
|
if err != nil {
|
||||||
|
return Route{}, "", fmt.Errorf("read the answer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(decoded.Data.Results) == 0 {
|
||||||
|
return Route{}, "", errNoResult
|
||||||
|
}
|
||||||
|
|
||||||
|
result := decoded.Data.Results[0]
|
||||||
|
if result.Netblock == "" {
|
||||||
|
return Route{}, NoRoute, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
netblock, err := netip.ParsePrefix(result.Netblock)
|
||||||
|
if err != nil {
|
||||||
|
return Route{}, "", fmt.Errorf("%w: %q", errNotNetblock, result.Netblock)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Route{Netblock: netblock.Masked(), ASN: result.ASN}, "", nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package routewatch_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/routewatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
clientAddr = "192.0.2.1"
|
||||||
|
routedAnswer = `{"status":"ok","data":{"results":[` +
|
||||||
|
`{"ip":"192.0.2.1","netblock":"192.0.2.0/24","asn":64496}]}}`
|
||||||
|
noRouteAnswer = `{"status":"ok","data":{"results":[{"ip":"192.0.2.1"}]}}`
|
||||||
|
// slowAnswer is longer than routewatch allows a reverse DNS lookup.
|
||||||
|
slowAnswer = 2500 * time.Millisecond
|
||||||
|
// waitLimit is longer than the Client allows a query.
|
||||||
|
waitLimit = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// clock is a time the tests move by hand.
|
||||||
|
type clock struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clock) Now() time.Time {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
return c.now
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clock) advance(by time.Duration) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.now = c.now.Add(by)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixture is a Client asking a stand-in for routewatch. queries counts what the
|
||||||
|
// stand-in was asked, paths holds the last path it was asked for, and log holds
|
||||||
|
// the process log.
|
||||||
|
type fixture struct {
|
||||||
|
t *testing.T
|
||||||
|
server *httptest.Server
|
||||||
|
client *routewatch.Client
|
||||||
|
clk *clock
|
||||||
|
queries *atomic.Int32
|
||||||
|
path *atomic.Value
|
||||||
|
log *syncBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncBuffer is a buffer the background queries can log to while a test reads it.
|
||||||
|
type syncBuffer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
return b.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *syncBuffer) String() string {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
return b.buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFixture(t *testing.T, standIn http.HandlerFunc) *fixture {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
f := &fixture{
|
||||||
|
t: t,
|
||||||
|
clk: &clock{now: time.Date(2026, 11, 9, 0, 0, 0, 0, time.UTC)},
|
||||||
|
queries: &atomic.Int32{},
|
||||||
|
path: &atomic.Value{},
|
||||||
|
log: &syncBuffer{},
|
||||||
|
}
|
||||||
|
|
||||||
|
f.server = httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.queries.Add(1)
|
||||||
|
f.path.Store(r.URL.Path)
|
||||||
|
standIn(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(f.server.Close)
|
||||||
|
|
||||||
|
f.client = routewatch.New(routewatch.Params{
|
||||||
|
URL: f.server.URL + "/", Now: f.clk.Now,
|
||||||
|
ProcessLog: slog.New(slog.NewTextHandler(f.log, nil)),
|
||||||
|
})
|
||||||
|
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func answering(body string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fixture) route() (routewatch.Route, routewatch.Missing) {
|
||||||
|
addr := netip.MustParseAddr(clientAddr)
|
||||||
|
client := netip.PrefixFrom(addr, addr.BitLen())
|
||||||
|
|
||||||
|
return f.client.Route(context.Background(), client, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitFor asks until the answer is why, or, while why is "", until there is a
|
||||||
|
// route, and returns the route.
|
||||||
|
func (f *fixture) waitFor(why routewatch.Missing) routewatch.Route {
|
||||||
|
f.t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(waitLimit)
|
||||||
|
|
||||||
|
for {
|
||||||
|
route, missing := f.route()
|
||||||
|
if missing == why {
|
||||||
|
return route
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
f.t.Fatalf("still %q after %s, want %q", missing, waitLimit, why)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantFailureLoggedWithoutTheClient waits for the process log to name a failed
|
||||||
|
// query, and checks that it does not name the client.
|
||||||
|
func (f *fixture) wantFailureLoggedWithoutTheClient() {
|
||||||
|
f.t.Helper()
|
||||||
|
|
||||||
|
// The failure is noted before it is logged.
|
||||||
|
deadline := time.Now().Add(waitLimit)
|
||||||
|
for !strings.Contains(f.log.String(), "asking routewatch failed") {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
f.t.Fatalf("process log %q names no failure", f.log.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(f.log.String(), clientAddr) {
|
||||||
|
f.t.Errorf("process log %q names the client", f.log.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteGivesRoutewatchsAnswerAndAsksByAddress(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newFixture(t, answering(routedAnswer))
|
||||||
|
|
||||||
|
if _, missing := f.route(); missing != routewatch.NoAnswerYet {
|
||||||
|
t.Errorf("before the answer: %q, want %q", missing, routewatch.NoAnswerYet)
|
||||||
|
}
|
||||||
|
|
||||||
|
route := f.waitFor("")
|
||||||
|
|
||||||
|
want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496}
|
||||||
|
if route != want {
|
||||||
|
t.Errorf("route %+v, want %+v", route, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := f.path.Load(); got != "/api/v1/ip/"+clientAddr {
|
||||||
|
t.Errorf("asked for path %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnswerAfterASlowReverseDNSLookupIsKept(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
time.Sleep(slowAnswer)
|
||||||
|
|
||||||
|
_, _ = w.Write([]byte(routedAnswer))
|
||||||
|
})
|
||||||
|
|
||||||
|
f.route()
|
||||||
|
|
||||||
|
route := f.waitFor("")
|
||||||
|
|
||||||
|
want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496}
|
||||||
|
if route != want {
|
||||||
|
t.Errorf("route %+v, want %+v", route, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddressWithoutARouteIsKeptAsNoRoute(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newFixture(t, answering(noRouteAnswer))
|
||||||
|
|
||||||
|
f.route()
|
||||||
|
f.waitFor(routewatch.NoRoute)
|
||||||
|
|
||||||
|
if got := f.queries.Load(); got != 1 {
|
||||||
|
t.Errorf("%d queries, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnswerRoutewatchCannotBeTakenFromFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := map[string]http.HandlerFunc{
|
||||||
|
"500": func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
},
|
||||||
|
"not JSON": answering("no"),
|
||||||
|
"no results": answering(`{"status":"ok","data":{"results":[]}}`),
|
||||||
|
"bad netblock": answering(
|
||||||
|
`{"status":"ok","data":{"results":[{"netblock":"nowhere","asn":1}]}}`),
|
||||||
|
}
|
||||||
|
for name, standIn := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newFixture(t, standIn)
|
||||||
|
|
||||||
|
f.route()
|
||||||
|
f.waitFor(routewatch.RequestFailed)
|
||||||
|
f.wantFailureLoggedWithoutTheClient()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefusedConnectionFailsTheQueryAndTheLogOmitsTheClient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newFixture(t, answering(routedAnswer))
|
||||||
|
|
||||||
|
f.server.Close()
|
||||||
|
|
||||||
|
f.route()
|
||||||
|
f.waitFor(routewatch.RequestFailed)
|
||||||
|
f.wantFailureLoggedWithoutTheClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
|
||||||
|
f.route()
|
||||||
|
f.waitFor(routewatch.RequestFailed)
|
||||||
|
f.route()
|
||||||
|
|
||||||
|
if got := f.queries.Load(); got != 1 {
|
||||||
|
t.Errorf("%d queries while the failure is kept, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.clk.advance(time.Minute)
|
||||||
|
f.route()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(waitLimit)
|
||||||
|
for f.queries.Load() != 2 {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("%d queries after the failure expired, want 2", f.queries.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHangingRoutewatchNeverDelaysAndFailsTheQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newFixture(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
<-r.Context().Done()
|
||||||
|
})
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
f.route()
|
||||||
|
|
||||||
|
if elapsed := time.Since(start); elapsed > time.Second {
|
||||||
|
t.Errorf("Route took %s while routewatch hangs", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.waitFor(routewatch.RequestFailed)
|
||||||
|
f.wantFailureLoggedWithoutTheClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepeatedRequestsMakeOneQueryAndTheAnswerExpires(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
<-release
|
||||||
|
|
||||||
|
_, _ = w.Write([]byte(routedAnswer))
|
||||||
|
})
|
||||||
|
|
||||||
|
for range 50 {
|
||||||
|
f.route()
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
f.waitFor("")
|
||||||
|
|
||||||
|
for range 50 {
|
||||||
|
f.route()
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := f.queries.Load(); got != 1 {
|
||||||
|
t.Errorf("%d queries for repeated requests, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.clk.advance(time.Hour)
|
||||||
|
f.waitFor(routewatch.NoAnswerYet)
|
||||||
|
f.waitFor("")
|
||||||
|
|
||||||
|
if got := f.queries.Load(); got != 2 {
|
||||||
|
t.Errorf("%d queries after the answer expired, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-7
@@ -1,8 +1,51 @@
|
|||||||
# The eighth change: on gitea's paths, some fields skip the rules that refused
|
# gitea's fixes: some fields skip the rules that refused ordinary gitea requests.
|
||||||
# ordinary gitea requests there. They are the fields where gitea sends a search,
|
# They are the fields where gitea sends a search, a file name or a branch name.
|
||||||
# a file name or a branch name. Each exclusion runs with the request, before the
|
# Each exclusion runs with the request, before the rules it switches off, those
|
||||||
# rules it switches off, those of phase 1 included, so this file is loaded before
|
# of phase 1 included, so this file is loaded before the Core Rule Set's rules.
|
||||||
# the Core Rule Set's rules. Each comment names a request the rule refused.
|
# Each comment names a request the rule refused.
|
||||||
|
|
||||||
|
# A request whose hostname SWW_APPS does not list gitea for skips every rule
|
||||||
|
# below. Inspect sets tx.app_gitea on the others.
|
||||||
|
SecRule &TX:app_gitea "@eq 0" \
|
||||||
|
"id:900590,phase:1,pass,nolog,skipAfter:END_GITEA"
|
||||||
|
|
||||||
|
# The query parameters in which gitea sends names within a repository or its
|
||||||
|
# own records, or a page of its own site, are not checked against the lists of
|
||||||
|
# system files, shell paths and command names, on any path:
|
||||||
|
# 930120: .gitignore
|
||||||
|
# 932160: bin/docker-entrypoint
|
||||||
|
# 932260: docker-build
|
||||||
|
SecAction "id:900591,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:files,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:skip-to,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:sub_path,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:ref,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:sha,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:branch,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:workflow,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:artifactName,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:files,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:skip-to,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:sub_path,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:ref,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:sha,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:branch,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:workflow,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:artifactName,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:files,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:skip-to,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:sub_path,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:ref,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:sha,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:branch,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:workflow,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:artifactName,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:redirect_to"
|
||||||
|
|
||||||
# gitea's searches of code, of issues and pull requests, and of commits send the
|
# gitea's searches of code, of issues and pull requests, and of commits send the
|
||||||
# text searched for in q, which skips these rules there:
|
# text searched for in q, which skips these rules there:
|
||||||
@@ -155,8 +198,8 @@ SecRule REQUEST_FILENAME \
|
|||||||
# 942140: information_schema.sql
|
# 942140: information_schema.sql
|
||||||
# 944110: java/lang/Runtime.java
|
# 944110: java/lang/Runtime.java
|
||||||
# 944130: java.lang.String.md
|
# 944130: java.lang.String.md
|
||||||
# Each request skips the rules that refused it. The fourth change already has
|
# Each request skips the rules that refused it. Rule 900591 already has path and
|
||||||
# path and redirect_to skip 930120, 932160 and 932260 on every path.
|
# redirect_to skip 930120, 932160 and 932260 on every path.
|
||||||
|
|
||||||
# A file's commit graph, in file.
|
# A file's commit graph, in file.
|
||||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
|
||||||
@@ -281,3 +324,5 @@ SecRule REQUEST_FILENAME \
|
|||||||
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
|
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
|
||||||
"id:900640,phase:1,pass,nolog,\
|
"id:900640,phase:1,pass,nolog,\
|
||||||
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
||||||
|
|
||||||
|
SecMarker END_GITEA
|
||||||
+101
-57
@@ -1,9 +1,9 @@
|
|||||||
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
||||||
// method, the URL with its query and the headers of a request, and on its
|
// method, the URL with its query and the headers of a request, and on its
|
||||||
// body while SWW_WAF_BODY_LIMIT is set, with the eight changes sww
|
// body while SWW_WAF_BODY_LIMIT is set, with the six changes sww
|
||||||
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
|
// makes to it, the fixes for the apps SWW_APPS names, and the exclusions in
|
||||||
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
|
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings"
|
||||||
// response.
|
// in SPEC.md describes them. It reads no response.
|
||||||
//
|
//
|
||||||
// sww writes only to its state directory, so Coraza is built with
|
// sww writes only to its state directory, so Coraza is built with
|
||||||
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
@@ -32,8 +34,8 @@ import (
|
|||||||
|
|
||||||
// directives are the Core Rule Set as sww runs it, with the
|
// directives are the Core Rule Set as sww runs it, with the
|
||||||
// paranoia level for %d, bodyDirectives for the first %s while
|
// paranoia level for %d, bodyDirectives for the first %s while
|
||||||
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and
|
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and the
|
||||||
// giteaExclusions for the third.
|
// directives of every app in apps for the third.
|
||||||
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
||||||
// Core Rule Set keeps for the rules that set it up, which
|
// Core Rule Set keeps for the rules that set it up, which
|
||||||
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
|
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
|
||||||
@@ -81,44 +83,28 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
|
|||||||
# before the rules of phase 1 too.
|
# before the rules of phase 1 too.
|
||||||
%s
|
%s
|
||||||
|
|
||||||
# The eighth change comes before the rules for the same reason.
|
# The apps' fixes come before the rules for the same reason.
|
||||||
%s
|
%s
|
||||||
|
|
||||||
# The sixth: only the rules for requests are loaded, and no response is
|
# The fifth: only the rules for requests are loaded, and no response is
|
||||||
# inspected.
|
# inspected.
|
||||||
Include @owasp_crs/REQUEST-*.conf
|
Include @owasp_crs/REQUEST-*.conf
|
||||||
|
|
||||||
# The third: redirect_uri is not checked for a URL naming an IP address or
|
# The third: redirect_uri is not checked for a URL naming an IP address or
|
||||||
# localhost. Coraza matches a parameter name here, and in the fourth,
|
# localhost. Coraza matches a parameter name here without regard to case.
|
||||||
# without regard to case. ARGS holds the fields of a form data or multipart
|
# ARGS holds the fields of a form data or multipart body Coraza reads as
|
||||||
# body Coraza reads as well as the query parameters, so a field of one of
|
# well as the query parameters, so a field of this name is left out too.
|
||||||
# these names is left out too.
|
|
||||||
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
|
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
|
||||||
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
|
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
|
||||||
|
|
||||||
# The fourth: the query parameters in which gitea sends names within a
|
# The fourth: Referer is not checked for a Unix command without arguments,
|
||||||
# repository or its own records, or a page of its own site, are not
|
# for Java starting a process, or for a Java class name. 944130 refused
|
||||||
# checked against the lists of system files, shell paths and command
|
# every page opened from the page of java.lang.String.md.
|
||||||
# names. Coraza takes one rule id per directive.
|
|
||||||
SecRuleUpdateTargetById 930120 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
||||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
||||||
!ARGS:artifactName|!ARGS:redirect_to"
|
|
||||||
SecRuleUpdateTargetById 932160 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
||||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
||||||
!ARGS:artifactName|!ARGS:redirect_to"
|
|
||||||
SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
||||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
||||||
!ARGS:artifactName|!ARGS:redirect_to"
|
|
||||||
|
|
||||||
# The fifth, for Referer: it is not checked for a Unix command without
|
|
||||||
# arguments, for Java starting a process, or for a Java class name. 944130
|
|
||||||
# refused every page opened from the page of java.lang.String.md. The
|
|
||||||
# cookies are left out in Inspect.
|
|
||||||
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
||||||
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
||||||
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
|
||||||
|
|
||||||
# The seventh: a container image push that reuses a layer names the image
|
# The sixth: a container image push that reuses a layer names the image
|
||||||
# it takes the layer from in the query parameter from, which is not
|
# it takes the layer from in the query parameter from, which is not
|
||||||
# checked for command names. 932260 refused the push of a layer from
|
# checked for command names. 932260 refused the push of a layer from
|
||||||
# owner/python3-app.
|
# owner/python3-app.
|
||||||
@@ -168,18 +154,36 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
|
|||||||
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
||||||
`
|
`
|
||||||
|
|
||||||
// giteaExclusions are the eighth change: the fields of gitea's own
|
// app is an app whose ordinary requests the Core Rule Set would refuse,
|
||||||
// requests that skip the rules that refused them there, as Coraza
|
// with the fixes that let them pass.
|
||||||
// directives.
|
type app struct {
|
||||||
//
|
// directives are Coraza exclusions, loaded before the Core Rule Set's
|
||||||
//go:embed gitea.conf
|
// rules. Their first rule skips the rest unless Inspect sets the
|
||||||
var giteaExclusions string
|
// transaction variable app_ and the app's name, and they end with the
|
||||||
|
// marker it skips to.
|
||||||
|
directives string
|
||||||
|
// cookies are the cookies the Core Rule Set reads a request without.
|
||||||
|
cookies []string
|
||||||
|
}
|
||||||
|
|
||||||
// cookiesNotRead are the cookies the Core Rule Set reads a request
|
//go:embed gitea.conf
|
||||||
// without, the rest of the fifth change.
|
var giteaDirectives string
|
||||||
|
|
||||||
|
// apps are the apps whose fixes sww has, by the name SWW_APPS gives each.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a map
|
||||||
var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
|
var apps = map[string]app{
|
||||||
|
"gitea": {
|
||||||
|
directives: giteaDirectives,
|
||||||
|
cookies: []string{"gitea_flash", "redirect_to"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppNames returns the names of the apps whose fixes sww has, in
|
||||||
|
// alphabetical order.
|
||||||
|
func AppNames() []string {
|
||||||
|
return slices.Sorted(maps.Keys(apps))
|
||||||
|
}
|
||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
@@ -195,6 +199,10 @@ type Params struct {
|
|||||||
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
||||||
// there is none.
|
// there is none.
|
||||||
ExclusionsFile string
|
ExclusionsFile string
|
||||||
|
// Apps are the names of the apps each hostname serves, by hostname in
|
||||||
|
// lowercase (SWW_APPS). A request gets the fixes of its hostname's
|
||||||
|
// apps only, or of every app while Apps is nil.
|
||||||
|
Apps map[string][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// The ids of sww's own rules, which an exclusions file may not
|
// The ids of sww's own rules, which an exclusions file may not
|
||||||
@@ -286,13 +294,15 @@ func checkExclusions(exclusions string) error {
|
|||||||
type CoreRuleSet struct {
|
type CoreRuleSet struct {
|
||||||
waf coraza.WAF
|
waf coraza.WAF
|
||||||
bodyLimit int64
|
bodyLimit int64
|
||||||
|
apps map[string][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns the Core Rule Set with the eight changes, at params'
|
// New returns the Core Rule Set with the six changes and the apps' fixes,
|
||||||
// paranoia level, without the rules it switches off, reading request
|
// at params' paranoia level, without the rules it switches off, reading
|
||||||
// bodies up to params' limit, and with params' exclusions file loaded
|
// request bodies up to params' limit, and with params' exclusions file
|
||||||
// before the Core Rule Set's rules. An exclusions file is an error if it
|
// loaded before the Core Rule Set's rules. An exclusions file is an error
|
||||||
// cannot be read or does not parse, or if checkExclusions refuses it.
|
// if it cannot be read or does not parse, or if checkExclusions refuses
|
||||||
|
// it.
|
||||||
func New(params Params) (*CoreRuleSet, error) {
|
func New(params Params) (*CoreRuleSet, error) {
|
||||||
body := ""
|
body := ""
|
||||||
if params.BodyLimit > 0 {
|
if params.BodyLimit > 0 {
|
||||||
@@ -315,8 +325,13 @@ func New(params Params) (*CoreRuleSet, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fixes strings.Builder
|
||||||
|
for _, name := range AppNames() {
|
||||||
|
fixes.WriteString(apps[name].directives + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
||||||
giteaExclusions)
|
fixes.String())
|
||||||
|
|
||||||
if len(params.DisabledRules) > 0 {
|
if len(params.DisabledRules) > 0 {
|
||||||
ids := make([]string, len(params.DisabledRules))
|
ids := make([]string, len(params.DisabledRules))
|
||||||
@@ -334,7 +349,7 @@ func New(params Params) (*CoreRuleSet, error) {
|
|||||||
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit}, nil
|
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit, apps: params.Apps}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result is what the Core Rule Set found in a request.
|
// Result is what the Core Rule Set found in a request.
|
||||||
@@ -346,11 +361,12 @@ type Result struct {
|
|||||||
Score int
|
Score int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inspect runs the Core Rule Set on r, a request from client: on its
|
// Inspect runs the Core Rule Set on r, a request from client, with the
|
||||||
// method, its URL with the query, and its headers, the Cookie header
|
// fixes of the apps appsFor names for r's hostname: on its method, its URL
|
||||||
// without the cookies in cookiesNotRead, and on body, r's body as the
|
// with the query, and its headers, the Cookie header without those apps'
|
||||||
// caller has it, as readBody reads it. It returns what it found, what it
|
// cookies, and on body, r's body as the caller has it, as readBody reads
|
||||||
// read of body, and the error that ended the reading early, if one did.
|
// it. It returns what it found, what it read of body, and the error that
|
||||||
|
// ended the reading early, if one did.
|
||||||
func (c *CoreRuleSet) Inspect(
|
func (c *CoreRuleSet) Inspect(
|
||||||
r *http.Request, client netip.Addr, body io.Reader,
|
r *http.Request, client netip.Addr, body io.Reader,
|
||||||
) (Result, []byte, error) {
|
) (Result, []byte, error) {
|
||||||
@@ -358,13 +374,24 @@ func (c *CoreRuleSet) Inspect(
|
|||||||
// Closing would remove the files Coraza wrote, and it writes none.
|
// Closing would remove the files Coraza wrote, and it writes none.
|
||||||
defer func() { _ = tx.Close() }()
|
defer func() { _ = tx.Close() }()
|
||||||
|
|
||||||
|
// The transaction's variables are set only through Coraza's interface
|
||||||
|
// for plugins.
|
||||||
|
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
|
||||||
|
|
||||||
|
var cookiesNotRead []string
|
||||||
|
|
||||||
|
for _, name := range c.appsFor(r.Host) {
|
||||||
|
state.Variables().TX().Set("app_"+name, []string{"1"})
|
||||||
|
cookiesNotRead = append(cookiesNotRead, apps[name].cookies...)
|
||||||
|
}
|
||||||
|
|
||||||
tx.ProcessConnection(client.String(), 0, "", 0)
|
tx.ProcessConnection(client.String(), 0, "", 0)
|
||||||
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
|
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
|
||||||
|
|
||||||
for name, values := range r.Header {
|
for name, values := range r.Header {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if name == "Cookie" {
|
if name == "Cookie" {
|
||||||
value = withoutCookiesNotRead(value)
|
value = withoutCookies(value, cookiesNotRead)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
continue // it held those cookies alone
|
continue // it held those cookies alone
|
||||||
}
|
}
|
||||||
@@ -457,9 +484,26 @@ func score(tx types.Transaction) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// withoutCookiesNotRead returns value, a Cookie header's, without the
|
// appsFor returns the names of the apps whose fixes a request for host,
|
||||||
// cookies in cookiesNotRead.
|
// its Host header, gets: every app while SWW_APPS is unset, and otherwise
|
||||||
func withoutCookiesNotRead(value string) string {
|
// those SWW_APPS lists for host's name, without the port and in
|
||||||
|
// lowercase.
|
||||||
|
func (c *CoreRuleSet) appsFor(host string) []string {
|
||||||
|
if c.apps == nil {
|
||||||
|
return AppNames()
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname, _, err := net.SplitHostPort(host)
|
||||||
|
if err != nil {
|
||||||
|
hostname = host // it has no port
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.apps[strings.ToLower(hostname)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutCookies returns value, a Cookie header's, without the cookies
|
||||||
|
// named in cookiesNotRead.
|
||||||
|
func withoutCookies(value string, cookiesNotRead []string) string {
|
||||||
var kept []string
|
var kept []string
|
||||||
|
|
||||||
for cookie := range strings.SplitSeq(value, ";") {
|
for cookie := range strings.SplitSeq(value, ";") {
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ func atDefaults(t *testing.T) *waf.CoreRuleSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wantChange checks that crs lets through passes, a gitea request one of
|
// wantChange checks that crs lets through passes, a gitea request one of
|
||||||
// the eight changes is for, and still finds result in refused, a request
|
// the six changes or gitea's fixes are for, and still finds result in
|
||||||
// like it that the change is not for.
|
// refused, a request like it that they are not for.
|
||||||
func wantChange(
|
func wantChange(
|
||||||
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
|
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
|
||||||
) {
|
) {
|
||||||
@@ -381,6 +381,96 @@ func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
|
|||||||
matched(930120, 932160))
|
matched(930120, 932160))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// giteaFixed are gitea requests that only gitea's fixes let through, one
|
||||||
|
// for each kind of fix, each with what the Core Rule Set finds in it
|
||||||
|
// without them.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
|
var giteaFixed = []struct {
|
||||||
|
request request
|
||||||
|
result waf.Result
|
||||||
|
}{
|
||||||
|
// A search of code, on the path of gitea's search.
|
||||||
|
{get("/explore/code?q=SELECT%20*%20FROM%20users%20WHERE"), matched(942100)},
|
||||||
|
// A file name, in a parameter gitea sends names in, on any path.
|
||||||
|
{get("/?path=.gitignore"), matched(930120)},
|
||||||
|
// A file name, in a cookie of gitea's.
|
||||||
|
{
|
||||||
|
get("/owner/repo", "Cookie: gitea_flash=success%3DFile%2Bpackage.json%2Bdeleted"),
|
||||||
|
matched(930120),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hostnames of the tests of SWW_APPS: the one that serves gitea, and
|
||||||
|
// one that serves another app.
|
||||||
|
const (
|
||||||
|
giteaHost = "git.example"
|
||||||
|
otherHost = "blog.example"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withHost returns r sent to host.
|
||||||
|
func withHost(r request, host string) request {
|
||||||
|
return request{r.method, r.target, append([]string{"Host: " + host}, r.headers...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// servingApps returns the Core Rule Set at the defaults, with SWW_APPS
|
||||||
|
// giving apps.
|
||||||
|
func servingApps(t *testing.T, apps map[string][]string) *waf.CoreRuleSet {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
crs, err := waf.New(waf.Params{
|
||||||
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, Apps: apps,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load the Core Rule Set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return crs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppsUnsetGivesEveryHostnameEveryAppsFixes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, nil)
|
||||||
|
|
||||||
|
for _, fixed := range giteaFixed {
|
||||||
|
for _, host := range []string{giteaHost, otherHost} {
|
||||||
|
wantResult(t, crs, withHost(fixed.request, host), waf.Result{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListedHostnameGetsItsAppsFixes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, map[string][]string{giteaHost: {"gitea"}})
|
||||||
|
|
||||||
|
// The hostname is matched without the port and without regard to case.
|
||||||
|
for _, host := range []string{giteaHost, "GIT.Example:8443"} {
|
||||||
|
for _, fixed := range giteaFixed {
|
||||||
|
wantResult(t, crs, withHost(fixed.request, host), waf.Result{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnlistedHostnameGetsTheFullCheck(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, apps := range []map[string][]string{
|
||||||
|
{giteaHost: {"gitea"}},
|
||||||
|
// SWW_APPS set empty lists no hostname.
|
||||||
|
{},
|
||||||
|
} {
|
||||||
|
crs := servingApps(t, apps)
|
||||||
|
|
||||||
|
for _, host := range []string{otherHost, otherHost + ":8443", "git.example.org"} {
|
||||||
|
for _, fixed := range giteaFixed {
|
||||||
|
wantResult(t, crs, withHost(fixed.request, host), fixed.result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// withExclusions returns the Core Rule Set at the defaults, with the
|
// withExclusions returns the Core Rule Set at the defaults, with the
|
||||||
// exclusions file at path.
|
// exclusions file at path.
|
||||||
func withExclusions(path string) (*waf.CoreRuleSet, error) {
|
func withExclusions(path string) (*waf.CoreRuleSet, error) {
|
||||||
|
|||||||
Reference in new issue
Block a user