Author SHA1 Message Date
sneak 8249a1110c fmt and feedback comments, no code changes
check / check (push) Failing after 1m36s
2026-11-06 18:33:31 -08:00
sneak c402926bad Add make publish: build the image on the Docker host and push it to the registry
check / check (push) Successful in 3m38s
script/publish runs script/docker with DOCKER_HOST set to the Docker
host (ssh://root@fsn1app1 unless set) and pushes the image to
git.eeqj.de/sneak/smallwebwaf under four tags: the UTC date as
YYYY-MM-DD, the short commit hash, the branch name and latest. It
refuses a working tree with uncommitted changes, since the hash tag
names a commit.
2026-11-06 18:06:12 -08:00
29 changed files with 1777 additions and 1420 deletions

No files matched your search

+3 -3
View File
@@ -153,15 +153,15 @@ Closest existing options, and why each still falls short:
- What it is: a single-binary reverse proxy that makes browsers solve a - What it is: a single-binary reverse proxy that makes browsers solve a
proof-of-work challenge before passing them to `TARGET`. Aimed at proof-of-work challenge before passing them to `TARGET`. Aimed at
scrapers, which is likely a large share of unwanted traffic on a public scrapers, which is likely a large share of unwanted traffic on a public
code forge. gitea.
- Covers: R10 well (one container, env vars for listener, target, - Covers: R10 well (one container, env vars for listener, target,
difficulty, cookies). Policy rules can match on path, user agent, headers, difficulty, cookies). Policy rules can match on path, user agent, headers,
IP ranges, and with the vendor's hosted data service also AS number and IP ranges, and with the vendor's hosted data service also AS number and
country, and can weigh a request toward a harder challenge. country, and can weigh a request toward a harder challenge.
- Misses: R1, R3, R4, R5, R6, R9. Bot policy needs a YAML file, not env - Misses: R1, R3, R4, R5, R6, R9. Bot policy needs a YAML file, not env
vars. AS number and country matching depend on the vendor's hosted vars. AS number and country matching depend on the vendor's hosted
service. Breaks non-browser clients unless paths are exempted; for a code service. Breaks non-browser clients unless paths are exempted; for gitea,
forge, git-over-HTTP and API paths must be allowed through by rule. git-over-HTTP and API paths must be allowed through by rule.
- Maturity: very active, widely deployed on code forges since 2025. - Maturity: very active, widely deployed on code forges since 2025.
- Value here: complementary. It can be chained (traefik, then the sidecar, - Value here: complementary. It can be chained (traefik, then the sidecar,
then Anubis, then the app) if challenge pages are wanted. then Anubis, then the app) if challenge pages are wanted.
+15 -3
View File
@@ -1,11 +1,14 @@
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \ .PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \
run example-app hooks build run example-app gitea-requests gitea-setup
# Makefile targets are thin shims; the implementations live in script/ # Makefile targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does, # of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
# which test checks. build and run are for working on the code by hand; # which test checks. build and run are for working on the code by hand;
# example-app checks the image with an app built on it. # example-app checks the image with an app built on it; gitea-requests
# reports the gitea requests the Core Rule Set refuses at the defaults;
# gitea-setup checks the setup in deploy/gitea; publish builds the image
# on the Docker host and pushes it to the registry.
bootstrap: bootstrap:
@script/bootstrap @script/bootstrap
@@ -34,6 +37,9 @@ check:
docker: docker:
@script/docker @script/docker
publish:
@script/publish
hooks: hooks:
@script/install-precommit @script/install-precommit
@@ -45,3 +51,9 @@ run:
example-app: example-app:
@script/example-app @script/example-app
gitea-requests:
@script/gitea-requests
gitea-setup:
@script/gitea-setup
+112 -14
View File
@@ -100,8 +100,8 @@ Point traefik's labels at port 8080 of `rproxy`
of the app or of `servewaf`. Anything on that network can reach the app around of the app or of `servewaf`. Anything on that network can reach the app around
`rproxy`, and can ask `servewaf` questions, so the network holds only what you `rproxy`, and can ask `servewaf` questions, so the network holds only what you
trust. Each further app adds its own container and its own `rproxy`, whose trust. Each further app adds its own container and its own `rproxy`, whose
`SWRP_WAF_BACKEND` names the same `servewaf`. The "Smallest setup" example below `SWRP_WAF_BACKEND` names the same `servewaf`. The "Smallest setup" and "In front
shows this shape. of gitea" examples below show this shape.
### `rproxy` in the app's container ### `rproxy` in the app's container
@@ -192,9 +192,9 @@ account, a key, a token or an alert destination stays off until you give one.
- Any `SWWAF_` setting can be given as a file instead, for secrets and long - Any `SWWAF_` setting can be given as a file instead, for secrets and long
lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`. lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`.
The file's contents are the value, less one newline at the end. Setting both The file's contents are the value, less one newline at the end. Setting both
forms stops the start. `SWWAF_LOG_REMOTE_TLS_CA_FILE` and forms stops the start. `SWWAF_LOG_REMOTE_TLS_CA_FILE` already names a file, so
`SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no it has no `_FILE` form, and no `SWRP_` setting has one, since `rproxy` never
`_FILE` form. No `SWRP_` setting has one, since `rproxy` never touches disk. touches disk.
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. - A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of - A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
1024: `512K`, `100M`, `5G`. 1024: `512K`, `100M`, `5G`.
@@ -272,7 +272,6 @@ Every setting the code reads, with its default:
| `SWWAF_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. | | `SWWAF_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWWAF_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. | | `SWWAF_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWWAF_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. | | `SWWAF_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWWAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. | | `SWWAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWWAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. | | `SWWAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWWAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. | | `SWWAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
@@ -339,8 +338,9 @@ full, and the limits that are fixed rather than settings.
Each example is a complete `compose.yaml`. `smallwebwaf` is the tag Each example is a complete `compose.yaml`. `smallwebwaf` is the tag
`make docker` gives the image; on another host, use the name you pushed it `make docker` gives the image; on another host, use the name you pushed it
under, by digest. Docker Compose puts the services on a private network of its under, by digest. Docker Compose puts the services on a private network of its
own, where each reaches the others by service name. Each example publishes own, where each reaches the others by service name. Only "In front of gitea"
`rproxy`'s port on the host, for trying it out. uses traefik. The other three publish `rproxy`'s port on the host, for trying it
out.
### Smallest setup ### Smallest setup
@@ -426,6 +426,88 @@ volumes:
servewaf-state: servewaf-state:
``` ```
### In front of gitea
gitea behind traefik, set up for a code forge:
- the upload limits raised, in size and in time;
- the response size limit raised, for clones over 1 GiB;
- the Actions runners exempt from counting;
- the default request and byte limits kept.
`traefik` is the network traefik is on, made outside this file.
```yaml
services:
gitea:
# gitea 1.27.3, 2026-08-29
image: gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef
environment:
GITEA__server__ROOT_URL: https://git.example/
# The host and the port of the SSH clone URLs gitea shows.
GITEA__server__SSH_DOMAIN: git.example
GITEA__server__SSH_PORT: "2222"
# gitea's TCP peer is rproxy, on the private network.
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
volumes:
- gitea-data:/data
networks:
- site
ports:
# SSH does not pass through smallwebwaf.
- "2222:22"
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
SWRP_UPSTREAM_URL: http://gitea:3000
# Pushes, LFS objects and image layers over 100 MiB, or slower
# than a minute, are cut off at the defaults.
SWRP_MAX_REQUEST_SIZE: 2G
SWRP_MAX_REQUEST_TIME_MS: "600000"
# A clone larger than 1 GiB is cut off at the default.
SWRP_MAX_RESPONSE_SIZE: 4G
networks:
- site
- traefik
labels:
traefik.enable: "true"
# rproxy is on two networks; traefik reaches it on this one.
traefik.docker.network: traefik
traefik.http.routers.git.rule: Host(`git.example`)
traefik.http.routers.git.entrypoints: websecure
traefik.http.routers.git.tls.certresolver: letsencrypt
traefik.http.services.git.loadbalancer.server.port: "8080"
servewaf:
image: smallwebwaf
environment:
SMALLWEBWAF_COMMAND: servewaf
SWWAF_INSTANCE_NAME: fsn1/servewaf
# The Actions runners ask for work all day; never count them.
SWWAF_RATE_LIMIT_EXEMPT_NETS: 10.0.4.0/24
# gitea never serves these; a request for one bans the client.
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php,/wp-admin/
volumes:
- servewaf-state:/var/lib/smallwebwaf
networks:
- site
networks:
site:
traefik:
external: true
volumes:
gitea-data:
servewaf-state:
```
`deploy/gitea` holds a complete setup of gitea, traefik included, as a Docker
Compose file. [`docs/gitea.md`](docs/gitea.md) explains it, and records what the
defaults do in front of gitea, request by request.
### `rproxy` beside the app ### `rproxy` beside the app
The app is built on the `smallwebwaf` image, with `rproxy` inside its container. The app is built on the `smallwebwaf` image, with `rproxy` inside its container.
@@ -554,13 +636,10 @@ and the status for a size or time limit, such as `413` for a body too large.
headers of every request, and no body unless `SWWAF_WAF_BODY_LIMIT` is set. In headers of every request, and no body unless `SWWAF_WAF_BODY_LIMIT` is set. In
front of a code forge, bodies carry what people write and publish, which the front of a code forge, bodies carry what people write and publish, which the
rules take for attacks. rules take for attacks.
- `smallwebwaf` changes the Core Rule Set in 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 them; "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.
- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's
own fields or paths.
- Rule files are plain text, one rule to a line, edited by hand and read again - Rule files are plain text, one rule to a line, edited by hand and read again
while `servewaf` runs. The image ships one file of probes no real visitor while `servewaf` runs. The image ships one file of probes no real visitor
sends (see [`docs/rules.md`](docs/rules.md)). sends (see [`docs/rules.md`](docs/rules.md)).
@@ -664,6 +743,11 @@ on the host.
`make check`. `make check`.
- `script/docker`: builds the image, whose build runs the tests and the linter - `script/docker`: builds the image, whose build runs the tests and the linter
first. `make docker`. first. `make docker`.
- `script/publish`: runs `script/docker` on the Docker host `DOCKER_HOST`
(`ssh://root@fsn1app1` unless set) and pushes the image to `PUBLISH_IMAGE`
(`git.eeqj.de/sneak/smallwebwaf` unless set) under four tags: the UTC date as
`YYYY-MM-DD`, the short commit hash, the branch name and `latest`. Refuses a
working tree with uncommitted changes. `make publish`.
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the - `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
image build. image build.
- `script/precommit`: run by the git pre-commit hook; runs `script/check`. - `script/precommit`: run by the git pre-commit hook; runs `script/check`.
@@ -692,6 +776,18 @@ on the host.
- orderly stops with `sv stop` and `docker stop`; - orderly stops with `sv stop` and `docker stop`;
- bans surviving a new container on the same volume; - bans surviving a new container on the same volume;
- a setting given as a file. - a setting given as a file.
- `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind
`rproxy` and `servewaf`, three containers on one network. Every setting is at
its default, but for those that join the containers and
`SWWAF_LOG_REQUEST_HEADERS`, which adds the header that labels each request.
It sends through them a sample of gitea's ordinary use and the requests the
Core Rule Set is known to refuse, and prints what `servewaf` did with each.
Needs network access, and a docker daemon that pushes to a registry on
`localhost` over plain HTTP. `make gitea-requests`.
- `script/gitea-setup`: builds the image, brings up the setup in `deploy/gitea`,
and checks it as [`docs/gitea.md`](docs/gitea.md) describes. Then it removes
the containers, the network, the volumes and the image it built. Needs network
access. `make gitea-setup`.
## TODO ## TODO
@@ -715,6 +811,8 @@ on the host.
- [`docs/state-files.md`](docs/state-files.md): the state files and what each - [`docs/state-files.md`](docs/state-files.md): the state files and what each
holds. holds.
- [`docs/image.md`](docs/image.md): what the image holds and how it is pinned. - [`docs/image.md`](docs/image.md): what the image holds and how it is pinned.
- [`docs/gitea.md`](docs/gitea.md): `smallwebwaf` in front of gitea: the setup
in `deploy/gitea`, and what the defaults do there.
## License ## License
+45 -112
View File
@@ -36,8 +36,7 @@ to them.
- the state files; - the state files;
- a lookup database; - a lookup database;
- a `SWWAF_` setting given as a file; - a `SWWAF_` setting given as a file;
- the file `SWWAF_LOG_REMOTE_TLS_CA_FILE` names; - the file `SWWAF_LOG_REMOTE_TLS_CA_FILE` names.
- the file `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` names.
## Non-goals ## Non-goals
@@ -82,7 +81,7 @@ to them.
of bans is full. of bans is full.
- `internal/rules`: reads the rule files at start and again when they - `internal/rules`: reads the rule files at start and again when they
change, and tells which rules a request matches. change, and tells which rules a request matches.
- `internal/waf`: the Core Rule Set, with the eight changes listed under - `internal/waf`: the Core Rule Set, with the six changes listed under
"Attack detection", as Coraza directives. "Attack detection", as Coraza directives.
- `internal/lookup`: looks clients up through GeoJS or in the lookup - `internal/lookup`: looks clients up through GeoJS or in the lookup
database. database.
@@ -326,10 +325,9 @@ Conventions:
lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`. lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`.
The file's contents are the value, less one newline at the end, and are The file's contents are the value, less one newline at the end, and are
checked as the value itself would be. Setting both forms stops the start, and checked as the value itself would be. Setting both forms stops the start, and
so does naming a file that cannot be read. `SWWAF_LOG_REMOTE_TLS_CA_FILE` and so does naming a file that cannot be read. `SWWAF_LOG_REMOTE_TLS_CA_FILE`
`SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` already hold a file name, so they have already holds a file name, so it has no `_FILE` form. No `SWRP_` setting has
no `_FILE` form. No `SWRP_` setting has one, since `rproxy` never touches one, since `rproxy` never touches disk.
disk.
- Settings, files included, are read once at start. Changing one means - Settings, files included, are read once at start. Changing one means
restarting the container. The files `servewaf` watches while it runs are its restarting the container. The files `servewaf` watches while it runs are its
state files, its rule files and the lookup database. state files, its rule files and the lookup database.
@@ -404,10 +402,10 @@ scrapers that send many requests slowly over hours or days. The hour limit is
the main one. the main one.
- Per minute: 120, 2 requests a second. It leaves room for a person opening two - Per minute: 120, 2 requests a second. It leaves room for a person opening two
pages at once with an empty cache. A web app's page opened with an empty cache pages at once with an empty cache. A gitea page opened with an empty cache
loads 20 to 60 files, and a handful once cached. loads 20 to 60 files, and a handful once cached.
- Per hour: 1,500. A person reading a site for a whole hour, a page every 20 - Per hour: 1,500. A person reading code in gitea for a whole hour, a page every
seconds, makes about 900 requests. A scraper making one request every 2 20 seconds, makes about 900 requests. A scraper making one request every 2
seconds makes 1,800 an hour, more than the limit. seconds makes 1,800 an hour, more than the limit.
- Per day: 6,000, four hours at the hour limit. A scraper making one request - Per day: 6,000, four hours at the hour limit. A scraper making one request
every 10 seconds makes 8,640 a day, more than the limit. every 10 seconds makes 8,640 a day, more than the limit.
@@ -419,7 +417,7 @@ the blocklists. An abuser that trips none of them is not caught.
Some legitimate clients pass the limits: Some legitimate clients pass the limits:
- A CI runner asks its code forge for work all day and can pass the limits. - An Actions runner asks gitea for work all day and can pass the limits.
- Everyone behind one shared address, such as an office, shares one client's - Everyone behind one shared address, such as an office, shares one client's
limits. limits.
@@ -552,11 +550,12 @@ service's abuse comes from.
By default the Core Rule Set reads the method, the URL with its query string, By default the Core Rule Set reads the method, the URL with its query string,
and the headers of every request. That is where automated attacks show: and the headers of every request. That is where automated attacks show:
injection in query strings, path traversal, attacks carried in headers, injection in query strings, path traversal, attacks carried in headers,
scanners' user agents. It reads no request body. In front of a code forge, scanners' user agents. It reads no request body. In front of a code forge such
bodies carry what people write and publish, such as issue text and files saved as gitea, bodies carry what people write and publish, such as issue text and
in the web editor. The Core Rule Set takes the shell commands and code in them files saved in the web editor. The Core Rule Set takes the shell commands and
for attacks. The default gives up refusing an attack carried in a body. The rule code in them for attacks. The default gives up refusing an attack carried in a
files, the limits and the bans still apply to the client that sends it. body. The rule files, the limits and the bans still apply to the client that
sends it.
- The Core Rule Set skips a path under `SWWAF_WAF_EXEMPT_PATHS`. - The Core Rule Set skips a path under `SWWAF_WAF_EXEMPT_PATHS`.
- `SWWAF_WAF_PARANOIA_LEVEL` and `SWWAF_WAF_ANOMALY_THRESHOLD` are the Core Rule - `SWWAF_WAF_PARANOIA_LEVEL` and `SWWAF_WAF_ANOMALY_THRESHOLD` are the Core Rule
@@ -584,27 +583,6 @@ files, the limits and the bans still apply to the client that sends it.
probes these rules caught (see "Rule files"). A list you give replaces the probes these rules caught (see "Rule files"). A list you give replaces the
default, so name these six ids in it to keep them off. default, so name these six ids in it to keep them off.
- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as
Coraza directives. An exclusion switches one rule off for some fields or paths
only, where `SWWAF_WAF_DISABLED_RULES` switches it off everywhere. The file
holds an app's own exclusions; the eighth change below holds gitea's.
- `servewaf` loads the file after `smallwebwaf`'s own setup rules and before
the Core Rule Set's rules. The Core Rule Set loads its own
`REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point.
- So the file holds only `SecAction` and `SecRule` lines that exclude with
`ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. These run with each
request, before the rules they exclude, those of phase 1 included.
- A rule that `SWWAF_WAF_DISABLED_RULES` switches off and the file names is
simply off.
- The start stops on a file that cannot be read or does not parse, whatever
`SWWAF_WAF_MODE` is.
- It stops too on a file that names a number from 900000 to 900999, the ids
of `smallwebwaf`'s own rules, alone or in a range. Comment lines are not
checked.
- It stops too on 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 yet.
- `SWWAF_WAF_BODY_LIMIT` switches body inspection on, up to that size. It takes - `SWWAF_WAF_BODY_LIMIT` switches body inspection on, up to that size. It takes
at most `64K`, since `servewaf` gets no more of a body than that (see "The at most `64K`, since `servewaf` gets no more of a body than that (see "The
answer"). Form data and multipart bodies are read up to the size. JSON and XML answer"). Form data and multipart bodies are read up to the size. JSON and XML
@@ -613,14 +591,15 @@ files, the limits and the bans still apply to the client that sends it.
data, where binary content such as a git push trips rules written for text. A data, where binary content such as a git push trips rules written for text. A
body the Core Rule Set cannot parse adds 5 to the score, and so does a body the Core Rule Set cannot parse adds 5 to the score, and so does a
multipart body cut by the limit in the middle of a part's header. Body multipart body cut by the limit in the middle of a part's header. Body
inspection suits apps whose forms carry no code. inspection suits apps whose forms carry no code; `docs/gitea.md` records what
it refuses in front of gitea.
- `SWWAF_TRAP_PATHS` names paths the app never serves and only scanners ask for, - `SWWAF_TRAP_PATHS` names paths the app never serves and only scanners ask for,
such as `/wp-login.php` in front of an app that is not WordPress. This is the such as `/wp-login.php` in front of gitea. This is the short form of a `path`
short form of a `path` rule with the `ban` action, for deployments that mount rule with the `ban` action, for deployments that mount no rule files. A trap
no rule files. A trap path is matched whole, without the query, against the path is matched whole, without the query, against the path as sent and once
path as sent and once percent-decoded: `/wp-login.php` matches percent-decoded: `/wp-login.php` matches `/wp-login.php?x=1` and
`/wp-login.php?x=1` and `/%77p-login.php`, and does not match `/wp-login.php/` `/%77p-login.php`, and does not match `/wp-login.php/` or
or `/blog/wp-login.php`. `/blog/wp-login.php`.
- `SWWAF_ERROR_BURST_THRESHOLD` counts a client's refusals per minute of these - `SWWAF_ERROR_BURST_THRESHOLD` counts a client's refusals per minute of these
kinds, and of no other: kinds, and of no other:
- a rule file match; - a rule file match;
@@ -630,10 +609,10 @@ files, the limits and the bans still apply to the client that sends it.
More than this breaks a limit (see "Bans"). A client trying one attack after More than this breaks a limit (see "Bans"). A client trying one attack after
another is refused many times a minute; a person rarely more than a few another is refused many times a minute; a person rarely more than a few
times. Answers the app gives are not counted, since they do not tell a times. Answers the app gives are not counted, since they do not tell a
scanner from an ordinary client. In front of a code forge, registry and git scanner from an ordinary client. In front of gitea, registry and git clients
clients get `404` and `401` by design, hundreds of times a minute. get `404` and `401` by design, hundreds of times a minute.
`smallwebwaf` changes the Core Rule Set 4.25.0 in 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:
@@ -665,10 +644,9 @@ every app, and no setting undoes them:
or `docker-build`. In these parameters, what only these three rules would or `docker-build`. In these parameters, what only these three rules would
catch reaches the app, such as the command `|cat /etc/passwd`. Path catch reaches the app, such as the command `|cat /etc/passwd`. Path
traversal, SQL and script injection, and PHP, Java and Node.js code are traversal, SQL and script injection, and PHP, Java and Node.js code are
still refused there. Other parameters keep all three rules, except on the still refused there. Every other parameter keeps all three rules. An app
gitea paths the eighth change names. An app that uses one of these that uses one of these parameters as a file on the server, or passes it to a
parameters as a file on the server, or passes it to a shell, must check the shell, must check the value itself. A rule file can refuse it instead.
value itself. A rule file can refuse it instead.
5. Two cookies are left out, and `Referer` keeps fewer rules: 5. Two cookies are left out, and `Referer` keeps fewer rules:
- The Core Rule Set reads the request without the `gitea_flash` and - The Core Rule Set reads the request without the `gitea_flash` and
@@ -682,63 +660,15 @@ every app, and no setting undoes them:
only to its own pages. Every other cookie is read in full. An app that only to its own pages. Every other cookie is read in full. An app that
reads a cookie of either name must check it itself. reads a cookie of either name must check it itself.
- `Referer` is not checked for a Unix command given without arguments - `Referer` is not checked for a Unix command given without arguments
(932340), for Java starting a process (944110) or for a Java class name (932340) or for Java starting a process (944110). `Referer` names the page
(944130). `Referer` names the page a request comes from, on everything a request comes from, on everything that page loads and on every link
that page loads and on every link followed from it. The Core Rule Set followed from it. The Core Rule Set takes some page addresses for attacks,
takes some page addresses for attacks: such as a search for `env`, or OpenJDK's `Runtime.java`, whose path holds
- a search for `env`; both `runtime` and `java.`. All of those requests would be refused.
- OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`; `Referer` keeps the other rules. An app that passes `Referer` to a shell
- a file named `java.lang.String.md`. must check it itself.
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 6. Responses are not inspected. A raw file from a repository, such as a shell
script, looks to the response rules like source code leaking from the server. script, looks to the response rules like source code leaking from the server.
7. The `from` parameter of a container registry's upload path,
`/v2/<image>/blobs/uploads/`, is not checked for command names (932260). When
a push reuses a layer, `from` names the image the layer comes from. An image
such as `owner/python3-app` would have that push refused.
8. On gitea's paths, some fields skip the rules that refused ordinary gitea
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
it refused. The fields are:
- `q` on gitea's searches of code, of issues and pull requests, and of
commits. A code search holds code, such as `<script>`,
`SELECT * FROM users WHERE` or `../`.
- `Referer` of a page opened from such a search, for script and SQL
injection and Java code (941110, 941120, 941160, 942100, 944100).
- `q` on gitea's searches for a name, for the names of system files,
commands and Java classes (930120, 932120, 932260, 944130). Names such as
`.profile`, `python3-app` and `org.apache.commons.logging` were refused.
gitea searches for the names of:
- repositories;
- users;
- branches and tags;
- packages.
- The path of a file's page, raw file, download, blame, history, edit and
delete pages, and of the API's call for its contents. Names such as
`notes.txt~`, `process.env.d.ts` and `java.lang.String.md` were refused.
- A file name in a query parameter:
- `file` of a commit graph;
- `path` of the API's list of commits;
- `body` of the API's call for several files;
- `template` of a new issue;
- `redirect_to` of the sign-in page;
- `name` of an attachment uploaded through the API.
- A branch or tag name in a query parameter, for command names (932260).
gitea sends one:
- to delete or restore a branch;
- to start a release from a tag;
- to edit a branch protection rule;
- to list a directory's last commits.
- The `Content-Range` header of an artifact upload from
`actions/upload-artifact@v3` (920450).
Elsewhere these fields keep every rule. The paths are those of gitea at the
site root. An app other than gitea that serves one of them must check these
fields itself.
The parameter and cookie names in these changes are matched without regard to The parameter and cookie names in these changes are matched without regard to
case, as Coraza matches them. case, as Coraza matches them.
@@ -906,7 +836,7 @@ and the default file.
runs. It reads the files again once the directory has had no change for 2 runs. It reads the files again once the directory has had no change for 2
seconds, so that a file still being written is read whole. A file whose name seconds, so that a file still being written is read whole. A file whose name
starts with `.`, such as an editor's lock file, is not a rule file. starts with `.`, such as an editor's lock file, is not a rule file.
- Files are read in name order (`00-default.rules` before `50-site.rules`), - Files are read in name order (`00-default.rules` before `50-gitea.rules`),
rules in line order. rules in line order.
- A rule's regex is Go's RE2 syntax. It has no backreferences or lookaround; in - A rule's regex is Go's RE2 syntax. It has no backreferences or lookaround; in
exchange matching time is linear in the input, so no rule can be made to stall exchange matching time is linear in the input, so no rule can be made to stall
@@ -935,12 +865,14 @@ and the default file.
- The image ships a default file, kept short and limited to patterns that are - The image ships a default file, kept short and limited to patterns that are
wrong for every app, with its path rules anchored at the site root. In front wrong for every app, with its path rules anchored at the site root. In front
of a code forge, `/.env` is a probe, while of gitea, `/.env` is a probe, while
`/<owner>/<repo>/src/branch/main/.env.example` is a file in a repository that `/<owner>/<repo>/src/branch/main/.env.example` is a file in a repository that
any visitor may open. Anchor the paths of your own `ban` rules at the site any visitor may open. Anchor the paths of your own `ban` rules at the site
root too. A rule has one target, so a `path` rule applies on every site behind root too. Anything else, such as a rule banning WordPress probes in front of
that `servewaf`. A directory mounted over `SWWAF_RULES_DIR` replaces the gitea, belongs in a file the deployer adds to the image `servewaf` runs from,
default file; single files mounted into it add to it. or mounts. A rule has one target, so a `path` rule applies on every site
behind that `servewaf`. A directory mounted over `SWWAF_RULES_DIR` replaces
the default file; single files mounted into it add to it.
- Rules meant to be edited while `servewaf` runs belong in a mounted directory, - Rules meant to be edited while `servewaf` runs belong in a mounted directory,
not a mounted file. Many editors save a file by replacing it, and a container not a mounted file. Many editors save a file by replacing it, and a container
keeps seeing the old one when the file alone is mounted. The lookup database keeps seeing the old one when the file alone is mounted. The lookup database
@@ -1763,7 +1695,8 @@ with no change to upaas. upaas builds the app's Dockerfile and runs the one
container as it runs any app. The traefik labels route to port 8080, and the container as it runs any app. The traefik labels route to port 8080, and the
`SWRP_` settings go with the app's environment variables. `servewaf` runs in a `SWRP_` settings go with the app's environment variables. `servewaf` runs in a
container of its own, on a network the app's container can reach, with its state container of its own, on a network the app's container can reach, with its state
volume and the directory of any token file. volume and the directory of any token file. `docs/gitea.md` records what the
defaults do in front of gitea, and `script/gitea-requests` checks those notes.
## Planned, not built ## Planned, not built
+135
View File
@@ -0,0 +1,135 @@
# Gitea behind traefik, with rproxy and servewaf in front of it.
# docs/gitea.md says what each setting here does and why. Before starting it:
# - set SMALLWEBWAF_IMAGE to the smallwebwaf image, named by digest;
# - replace git.example.org with the site's host name;
# - replace each 10.99.0 with the first three numbers of a /24 subnet that
# no other network on the host uses. The /24 must lie inside 10.0.0.0/8,
# 172.16.0.0/12 or 192.168.0.0/16, because rproxy believes
# X-Forwarded-For only from those by default. Replace each fd10:99 with
# the first two hextets of an unused /64 subnet whose third and fourth
# hextets are 0, as in fd10:99::/64;
# - set GITEA_SECRET_KEY to the output of
# `docker run --rm <gitea's image below> gitea generate secret SECRET_KEY`,
# once per install, and keep it.
# Then run `docker compose up --detach` in this directory.
# script/gitea-setup checks this setup.
name: gitea
x-logging: &logging
driver: json-file
options:
max-size: 10m
max-file: "5"
services:
traefik:
# traefik v3.7.17, 2026-11-03
image: traefik@sha256:68ae5fe5b64ca166704b746a9e8afcd385e36b3f569a5691401bf652d57b3748
# traefik replaces the X-Forwarded-For header a visitor sends. Keep it
# so: otherwise a visitor could name an address on the shared network,
# and skip servewaf's checks.
command:
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.websecure.address=:443
# Until a certificate resolver is added here, as for your other
# sites, traefik answers with a certificate it made itself.
- --entrypoints.websecure.http.tls=true
# traefik's own limit of 60 seconds for a whole request would cut
# off an upload long before rproxy's limit of 30 minutes.
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=30m
ports:
- 80:80
- 443:443
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
shared:
# Containers on the shared network find traefik under the
# site's name, and so reach it from their own address.
aliases:
- git.example.org
restart: unless-stopped
gitea:
# gitea 1.27.3, 2026-08-29
image: gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef
environment:
GITEA__server__ROOT_URL: https://git.example.org/
# Gitea starts at its defaults, with SQLite, instead of showing its
# install page to whoever opens it first.
GITEA__security__INSTALL_LOCK: "true"
GITEA__security__SECRET_KEY: ${GITEA_SECRET_KEY:?set GITEA_SECRET_KEY, see docs/gitea.md}
# Accounts are made with `gitea admin user create`.
GITEA__service__DISABLE_REGISTRATION: "true"
# rproxy's addresses. Gitea then takes each visitor's address from
# the X-Real-Ip header traefik sets and rproxy passes on.
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.99.0.2,fd10:99::2
volumes:
- gitea-data:/data
networks:
- shared
restart: unless-stopped
rproxy:
image: ${SMALLWEBWAF_IMAGE:?set SMALLWEBWAF_IMAGE to the smallwebwaf image}
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
SWRP_UPSTREAM_URL: http://gitea:3000
# Pushes, LFS objects, packages and release attachments of up to
# 2 GiB, sent in up to 30 minutes.
SWRP_MAX_REQUEST_SIZE: 2G
SWRP_MAX_REQUEST_TIME_MS: "1800000"
# 15 minutes for a clone or a download, half the default.
SWRP_MAX_RESPONSE_TIME_MS: "900000"
labels:
traefik.enable: "true"
traefik.http.routers.gitea.rule: Host(`git.example.org`)
traefik.http.routers.gitea.entrypoints: websecure
traefik.http.services.gitea.loadbalancer.server.port: "8080"
networks:
shared:
ipv4_address: 10.99.0.2
ipv6_address: fd10:99::2
logging: *logging
restart: unless-stopped
servewaf:
image: ${SMALLWEBWAF_IMAGE:?set SMALLWEBWAF_IMAGE to the smallwebwaf image}
environment:
SMALLWEBWAF_COMMAND: servewaf
# The containers on the shared network skip the checks. Add the
# address of an automation fleet on another host here too; without
# that, it is counted like any client.
SWWAF_ALLOW_NETS: 10.99.0.128/25,fd10:99:0:0:8000::/65
# In block mode the Core Rule Set refuses everyday Gitea searches
# and file names, and 31 refusals in a minute ban the address.
SWWAF_WAF_MODE: detect
volumes:
- servewaf-state:/var/lib/smallwebwaf
networks:
- shared
logging: *logging
restart: unless-stopped
networks:
# The network traefik and Gitea share. Its containers get addresses in the
# upper half of each subnet, which servewaf lets skip the checks. Each
# subnet's gateway, the host's own address on it, stays out of that half:
# requests docker passes on from a published port can come from it.
shared:
enable_ipv6: true
ipam:
config:
- subnet: 10.99.0.0/24
ip_range: 10.99.0.128/25
gateway: 10.99.0.1
- subnet: fd10:99::/64
ip_range: fd10:99:0:0:8000::/65
gateway: fd10:99::1
volumes:
gitea-data:
servewaf-state:
+340
View File
@@ -0,0 +1,340 @@
# smallwebwaf in front of gitea
`deploy/gitea/compose.yaml` runs gitea behind traefik, with `rproxy` and
`servewaf` in front of it. This document says what that setup sets and why, and
what the defaults would do without it. [`SPEC.md`](../SPEC.md) says how
`smallwebwaf` behaves.
## The setup
Four containers share one Docker network, the shared network:
- traefik, which routes the site's HTTPS requests to `rproxy`, and redirects
plain HTTP to HTTPS;
- `rproxy`, in front of gitea;
- gitea 1.27.3, as its image comes;
- `servewaf`, with a volume for its state files.
To start it:
1. In `deploy/gitea/compose.yaml`, replace `git.example.org` with the site's
host name.
2. Replace each `10.99.0` with the first three numbers of a `/24` subnet that no
other network on the host uses. The `/24` must lie inside `10.0.0.0/8`,
`172.16.0.0/12` or `192.168.0.0/16`, because `rproxy` believes
`X-Forwarded-For` only from those by default. Replace each `fd10:99` with the
first two hextets of an unused `/64` subnet whose third and fourth hextets
are 0, as in `fd10:99::/64`.
3. Set `SMALLWEBWAF_IMAGE` to the `smallwebwaf` image, named by digest.
4. Set `GITEA_SECRET_KEY` to the output of
`docker run --rm <gitea's image> gitea generate secret SECRET_KEY`, with
gitea's image as `deploy/gitea/compose.yaml` names it. Do this once per
install, and keep the key.
5. Run `docker compose up --detach` in `deploy/gitea`.
6. Make gitea's first user with `gitea admin user create`, in gitea's container,
as the user `git`.
Sign-up is off: accounts are made with `gitea admin user create`. gitea starts
at its defaults, with SQLite. traefik answers with a certificate it made itself
until you add a certificate resolver, as for your other sites. SSH to gitea does
not pass through `smallwebwaf`; publish gitea's SSH port yourself if you use it.
`make gitea-setup` checks the setup. It builds the image, brings the setup up
under a name of its own, and checks:
- that a page load, a push and a clone sent through traefik pass through
`rproxy`, and that `servewaf` checks them;
- that gitea logs the visitor's address, not `rproxy`'s;
- that a request from a container on the shared network skips the checks;
- that with `servewaf` stopped, gitea still answers through `rproxy`.
## Uploads and downloads
An upload is a request:
- a git push;
- an LFS object;
- a container image layer;
- a package file;
- a release attachment.
The setup lets an upload of up to 2 GiB, sent in up to 30 minutes, through:
- `SWRP_MAX_REQUEST_SIZE` is `2G`. At the default, an upload over 100 MiB is cut
off.
- `SWRP_MAX_REQUEST_TIME_MS` is `1800000`, 30 minutes. At the default, an upload
that takes over 60 seconds is cut off.
- traefik's `readTimeout` is 30 minutes. traefik's own default of 60 seconds for
a whole request would cut off an upload first.
A clone or a download is a response:
- `SWRP_MAX_RESPONSE_TIME_MS` is `900000`, 15 minutes, half the default. A clone
or download that takes longer is cut off.
- `SWRP_MAX_RESPONSE_SIZE` stays at its default, 1 GiB. A repository whose clone
is larger needs it raised.
The byte limits stay at their defaults: no minute limit, 50 GiB an hour and 100
GiB a day.
## The checks
`SWWAF_WAF_MODE` is `detect`. The Core Rule Set then logs a match, with its rule
ids, and refuses nothing. In `block` mode, the default, it refuses everyday
gitea searches and file names. The section "What the Core Rule Set refuses in
`block` mode" below lists them. Each such refusal counts toward the error burst,
so 31 in one minute ban the address.
Every other check stays at its default. A client may make 120 requests a minute,
1,500 an hour and 6,000 a day. Archive downloads and blame and history pages are
what scrapers hammer, and these limits do most of the work there.
## The shared network
Containers on the shared network skip `servewaf`'s checks:
- The shared network's subnets are `10.99.0.0/24` and `fd10:99::/64`. Docker
gives its containers addresses in the upper half of each, `10.99.0.128/25` and
`fd10:99:0:0:8000::/65`, which `SWWAF_ALLOW_NETS` names.
- traefik is known on the shared network by the site's host name. So a container
there that asks for the site reaches traefik from its own address. An
automation fleet in such a container skips the checks.
- Each subnet's gateway, `10.99.0.1` and `fd10:99::1`, is the host's own address
on it. Requests docker passes on from a published port can come from the
gateway, so the gateways are left out of `SWWAF_ALLOW_NETS`. The network has
IPv6, so a visitor over IPv6 reaches traefik from its own address, and
`servewaf` checks it as itself.
- A container on the shared network that asks for the site at the host's public
address arrives from the gateway. It is checked like any visitor.
- A fleet on another host can have its address added to `SWWAF_ALLOW_NETS`.
Without that, it is counted like any client.
- traefik replaces the `X-Forwarded-For` a visitor sends, by default. Keep it
so: otherwise a visitor could name an address on the shared network, and skip
the checks.
- Anything on the shared network can reach gitea and `servewaf` around `rproxy`.
Put only containers you trust on it.
## gitea's settings
- `REVERSE_PROXY_TRUSTED_PROXIES` names `rproxy`'s fixed addresses, `10.99.0.2`
and `fd10:99::2`. gitea then takes each visitor's address from the `X-Real-Ip`
header, which traefik sets and `rproxy` passes on. Without it, gitea logs
every visitor as `rproxy`.
- `INSTALL_LOCK` is `true`, as "The setup" above says.
## Methods
`rproxy` passes on only these methods: `GET`, `HEAD`, `POST`, `OPTIONS`, `PUT`,
`DELETE`, `PATCH` and `QUERY`. gitea 1.27.3 needs no other. Its routes use:
- pages and the API: `GET`, `HEAD`, `POST`, `PUT`, `PATCH` and `DELETE`;
- git's push, clone and fetch over HTTP: `GET`, `POST` and `OPTIONS`;
- LFS: `GET`, `POST` and `PUT`;
- the container registry and the other package registries: `GET`, `HEAD`,
`POST`, `PUT`, `PATCH` and `DELETE`;
- Actions runners: `POST`.
## The log
`rproxy` and `servewaf` write each query string to the log whole. A token sent
in the query string, as `?token=`, is then in the log. Send it in the
`Authorization` header instead.
## Actions runners
A runner on the shared network skips the checks. One on another host is counted
like any client. A runner older than version 0.4 (April 2026) asks for work
every 2 seconds, 1,800 requests an hour. While it runs a job, it reports the
job's log and state every second instead, two requests a second.
So one idle runner makes 1,800 requests an hour, more than the hour limit of
1,500. A busy one, at two requests a second, passes the minute limit of 120
within two minutes. Its address is then banned, its running jobs fail, and each
repeat within a day makes the ban longer. Newer runners ask for work less often.
Put the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
## What the Core Rule Set refuses in `block` mode
These notes record what the Core Rule Set does in front of gitea in `block`
mode, at the other defaults. They are observations, not requirements.
`script/gitea-requests` runs gitea behind `rproxy` and `servewaf` at the
defaults. It sends through them a sample of gitea's ordinary use and the
requests named below. It reports each request the Core Rule Set refuses, so that
these notes can be checked again when gitea or the Core Rule Set changes
version. In `detect` mode, as the setup runs it, the same requests are logged
with their rule ids, and pass.
Each refusal answers only that request ("Bans" in `SPEC.md` says what it counts
toward). The request log names the rule in `waf_rule_ids`, which
`SWWAF_WAF_DISABLED_RULES` can switch off.
### What it lets through
The Core Rule Set lets gitea's ordinary use through, apart from the refusals in
the sections after this one:
- browsing and views of files in a repository, with their history, blame and the
file tree;
- diffs, including their hidden lines and large files, and pull request review;
- git's clone, fetch and push over HTTP;
- signing in, including the return to the page a visitor came from, and sign-in
with Git Credential Manager, git-credential-oauth or tea;
- the API's calls for a file and its commits;
- pushing and pulling container images and packages;
- Actions runners, and artifacts uploaded with `actions/upload-artifact@v4`;
- posting issues, pull requests, comments, wiki pages and files saved in the web
editor, code included, since no body is read;
- the page gitea shows after a change, with its message naming what was done.
### A query string that reads as an attack
Most often this is a search. A search term is a query parameter, which the Core
Rule Set checks against its lists of system files and commands (see the fourth
change under "Attack detection" in `SPEC.md`). A release attachment uploaded
through the API as `docker-compose.yml` is refused the same way. Examples:
- a name on those lists, such as `package.json`, `.gitignore` or
`docker-compose.yml`;
- text that starts with a command name, such as `python3` or `ssh key`, or has
one right after a `/` in its first word, such as `feat/docker-support`;
- a shell command with its options or a system path (`ls -la`, `sed -i`,
`/bin/sh`), or a command in backticks;
- script code (`fetch(`, `${VAR}`, `process.env`), HTML (`<img src=`) or an SQL
statement (`SELECT * FROM users WHERE`);
- a URL naming an IP address or `localhost`.
### A branch, tag or file name that starts with a command name
The Core Rule Set's list of commands (932260) holds names such as `docker-`,
`python3`, `ansible`, `base64` and `whoami`. A query parameter holding a branch,
tag or file name that starts with one is refused. So is one that has such a name
right after a `/`, such as `feat/docker-support`, `fix/docker-build` or
`renovate/docker-build-push-action-6.x`. gitea's own pages send the name in a
query parameter that 930120, 932160 and 932260 still check:
- `refSubUrl`, the branch or tag of a directory listing. A listing sends it when
it asks for its entries' last commits in a second request. It does so whenever
gitea takes more than a second to work them out;
- `name`, when a branch is deleted or restored on the branches page;
- `tag`, when a release is started from a tag;
- `template`, the file of an issue template, when an issue is opened from it;
- `rule_name`, when a branch protection rule is opened for editing in the
repository's settings or has just been saved.
What a visitor sees, for a branch named `docker-build`:
- A directory listing that makes that second request leaves those last commits
out and shows an error.
- The branches page shows an error instead of deleting or restoring the branch.
- A release started from such a tag gets the `403` answer of `smallwebwaf` in
place of its form. So does an issue opened from such a template, and such a
rule opened for editing.
- Saving such a rule stores it. gitea then returns to the branch settings page,
which gets the `403` answer, so the save looks as if it failed.
### A file name in a query parameter that trips the code rules
The Core Rule Set's rules for Java, script, PowerShell and SQL find what they
look for in some file names. This holds too in the ten query parameters that
930120, 932160 and 932260 do not check (see the fourth change under "Attack
detection" in `SPEC.md`). Examples:
- `runtime` in OpenJDK's `src/java.base/share/classes/java/lang/Runtime.java`
(944110);
- a Java class, as in `java.lang.String.md` (944130);
- `document.write.js` (941180);
- `process.env.d.ts` (934100);
- `__proto__` (934130);
- the PowerShell command `Install-Script`, as in `install-script.sh` (932120);
- a database's name, as in PostgreSQL's `information_schema.sql` (942140).
For such a file, these requests are refused:
- the API's list of its commits (`path`);
- the API's call for the contents of files that names it in its query (`body`);
- the commit graph of that file alone (`file`);
- the hidden lines of its diff (`path`), which stay hidden;
- the sign-in page, for a visitor who opens a page of the file that needs
signing in, such as its edit page (`redirect_to`). The visitor can still sign
in by opening the sign-in page itself.
### A path
- A file name ending in `~`, or an `.xhtml` file whose path holds a space.
- A path that holds what the Core Rule Set takes for a Java class (944130), as
`java.lang.String.md` and
`META-INF/services/org.apache.commons.logging.LogFactory` do.
- A path that holds what it takes for Node.js code (934100), as
`process.env.d.ts` does.
- A path with a directory named `...` (930100, 930110), such as `.../notes.txt`.
For such a file, these are refused:
- the page of the file;
- its raw file;
- its history and its blame;
- its edit page.
The directory that holds it lists it, and a clone carries it.
### A container image push that reuses a layer
A push can reuse a layer of an image docker has pushed to that gitea before.
docker then asks gitea to take the layer from that image, naming it in the query
parameter `from`, which 932260 checks. The request is refused when the image's
owner starts with an entry on the list of commands. It is refused too when the
image's name after the owner starts with one. Examples are `owner/python3-app`,
`owner/docker-base`, and any image of an owner named `ansible` or `docker-team`.
What happens next depends on docker's image store. A docker that keeps its
images in containerd's image store fails the push, and fails each new try the
same way. A new install of Docker Engine 29 does this. A docker that keeps them
in its own older image store uploads the layer instead. The push then succeeds
after one refusal for each layer it reuses, and each of those refusals counts
toward the error burst ("Attack detection" in `SPEC.md`).
### Artifact uploads from `actions/upload-artifact@v3`
This action sends the header `Content-Range`, which the Core Rule Set refuses
(920450). The action sends two files at a time, does not retry a `403`, and
sends no further file once one is refused. So an upload is refused once or
twice, however many files it holds, and the step fails. One upload does not ban
the runner. The error burst ("Attack detection" in `SPEC.md`) bans it after more
than 30 such refusals from its address in a minute, as when many jobs upload at
once. `actions/upload-artifact@v4` does not send the header.
### What body inspection refuses
With `SWWAF_WAF_BODY_LIMIT` set, the Core Rule Set reads request bodies too. In
front of gitea it then refuses these, until their rule ids, named in the request
log, are added to `SWWAF_WAF_DISABLED_RULES`:
- issue and comment text, wiki pages and files saved in the web editor that hold
shell commands or code (932125, 932235, 932250 and others);
- package descriptions that show code;
- PyPI uploads (922130);
- attachments named like `debug.log` or `config.yml` (932180).
### What `script/gitea-requests` sends
Of the requests above, the script sends:
- for each file named under "A file name in a query parameter that trips the
code rules", the API's list of its commits;
- for `Runtime.java` alone, the commit graph of that file, the hidden lines of
its diff, and the API's call for the contents of files that names it;
- the sign-in page for a visitor who opens the edit page of `document.write.js`;
- the pages of `java.lang.String.md`,
`META-INF/services/org.apache.commons.logging.LogFactory`, `process.env.d.ts`
and `.../notes.txt`;
- one container image push that reuses a layer, which it does not try again.
It does not send the raw file, history, blame or edit page of those four files,
or the pages of the directories `META-INF/services` and `...`. It also sends a
short sample of gitea's ordinary use:
- signing in;
- pages of files and commits, and a diff;
- the API;
- a push, and clone and fetch over HTTP;
- two container image pushes.
+3 -3
View File
@@ -55,10 +55,10 @@ empty-agent user_agent log ^$
## A file of your own ## A file of your own
`servewaf` reads the rule files in its own container. Rules of your own go in a `servewaf` reads the rule files in its own container. Rules of your own go in a
file named to sort after the default, such as `50-site.rules`: file named to sort after the default, such as `50-gitea.rules`:
``` ```
# 50-site.rules: WordPress probes, which this site never serves # 50-gitea.rules: WordPress probes, which gitea never serves
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/) wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
``` ```
@@ -66,7 +66,7 @@ A `Dockerfile` built on the `smallwebwaf` image, for `servewaf` to run from,
adds it: adds it:
```dockerfile ```dockerfile
COPY 50-site.rules /etc/smallwebwaf/rules.d/50-site.rules COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules
``` ```
Or mount it there. A `path` rule applies on every site behind that `servewaf`. Or mount it there. A `path` rule applies on every site behind that `servewaf`.
+7 -15
View File
@@ -101,18 +101,19 @@ chooses between the two (see "The image's command" below).
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a - `SWWAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
client may make in a minute. A count or `off`. The default leaves room for a client may make in a minute. A count or `off`. The default leaves room for a
person opening two pages at once with an empty cache, up to 60 files each. person opening two gitea pages at once with an empty cache, up to 60 files
each.
- `SWWAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may - `SWWAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
make in an hour. A count or `off`. It is the main limit against scrapers. A make in an hour. A count or `off`. It is the main limit against scrapers. A
person reading a site for an hour, a page every 20 seconds, makes about 900. A person reading code in gitea for an hour, a page every 20 seconds, makes
scraper making one request every 2 seconds makes 1,800 an hour, more than the about 900. A scraper making one request every 2 seconds makes 1,800 an hour,
limit. more than the limit.
- `SWWAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may - `SWWAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may
make in a day. A count or `off`. The default is four hours at the hour limit. make in a day. A count or `off`. The default is four hours at the hour limit.
A scraper making one request every 10 seconds makes 8,640 a day, more than the A scraper making one request every 10 seconds makes 8,640 a day, more than the
limit. limit.
- A CI runner asks its code forge for work all day and can pass the limits. Put - An Actions runner asks gitea for work all day and can pass the limits. Put the
the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
network where many people share one address, such as an office. network where many people share one address, such as an office.
- Counting cannot catch a client that stays under all three limits (see "Request - Counting cannot catch a client that stays under all three limits (see "Request
rate limits, per client" in [`SPEC.md`](../SPEC.md)). rate limits, per client" in [`SPEC.md`](../SPEC.md)).
@@ -191,15 +192,6 @@ chooses between the two (see "The image's command" below).
the Core Rule Set reads, a size of at most `64K`. A question carries no more the Core Rule Set reads, a size of at most `64K`. A question carries no more
of a body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one of a body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one
byte of it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)). byte of it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
exclusions, written as Coraza directives. It is loaded before the Core Rule
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
read or does not parse stops the start, whatever `SWWAF_WAF_MODE` is. So does
a file that names a number from 900000 to 900999 outside a comment, alone or
in a range. 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 yet. It has no `_FILE` form.
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client - `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client
in a minute (see "Attack detection" in `SPEC.md`). A count or `off`. in a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
+10 -10
View File
@@ -39,7 +39,7 @@ const (
ntfyURL = "https://ntfy.example/smallwebwaf-alerts" ntfyURL = "https://ntfy.example/smallwebwaf-alerts"
ntfyToken = "tk_0123456789abcdefghijklmnopq" ntfyToken = "tk_0123456789abcdefghijklmnopq"
// instance is the instance name every alert gives. // instance is the instance name every alert gives.
instance = "fsn1app1/blog" instance = "fsn1app1/gitea"
// started is when each test starts, as an alert gives it, and // started is when each test starts, as an alert gives it, and
// anHourOn an hour later. // anHourOn an hour later.
started = "2000-01-01T00:00:00Z" started = "2000-01-01T00:00:00Z"
@@ -965,8 +965,8 @@ func TestEachDestinationIsSentOnlyTheAlertsWaitingForIt(t *testing.T) {
synctest.Wait() synctest.Wait()
wantBodies(t, standIns[alerts.DestinationSlack], wantBodies(t, standIns[alerts.DestinationSlack],
`{"text":"*fsn1app1/blog: file_error*\nsecond"}`, `{"text":"*fsn1app1/gitea: file_error*\nsecond"}`,
`{"text":"*fsn1app1/blog: file_error*\nthird"}`) `{"text":"*fsn1app1/gitea: file_error*\nthird"}`)
wantBodies(t, standIns[alerts.DestinationNtfy], "fourth") wantBodies(t, standIns[alerts.DestinationNtfy], "fourth")
// alerts.json then lists Slack and ntfy alone, with no alert waiting. // alerts.json then lists Slack and ntfy alone, with no alert waiting.
@@ -1265,7 +1265,7 @@ func anAlertForEachEvent() []eventMessage {
Reason: "requests per hour over the limit of 10000", Reason: "requests per hour over the limit of 10000",
Detail: map[string]any{"mode": "observe"}, Detail: map[string]any{"mode": "observe"},
}, },
"fsn1app1/blog: ban", "default no_entry", "fsn1app1/gitea: ban", "default no_entry",
"requests per hour over the limit of 10000\nclient: 203.0.113.9\n" + "requests per hour over the limit of 10000\nclient: 203.0.113.9\n" +
"netblock: 203.0.113.0/24\ncountry: DE\nmode: observe", "netblock: 203.0.113.0/24\ncountry: DE\nmode: observe",
}, },
@@ -1275,7 +1275,7 @@ func anAlertForEachEvent() []eventMessage {
Netblock: netip.MustParsePrefix("198.51.100.7/32"), Country: "FR", Netblock: netip.MustParsePrefix("198.51.100.7/32"), Country: "FR",
Reason: "matched the rule env-file", Reason: "matched the rule env-file",
}, },
"fsn1app1/blog: permanent_ban", "high no_entry", "fsn1app1/gitea: permanent_ban", "high no_entry",
"matched the rule env-file\nclient: 198.51.100.7\n" + "matched the rule env-file\nclient: 198.51.100.7\n" +
"netblock: 198.51.100.7/32\ncountry: FR", "netblock: 198.51.100.7/32\ncountry: FR",
}, },
@@ -1286,7 +1286,7 @@ func anAlertForEachEvent() []eventMessage {
Reason: "refused by the Core Rule Set", Reason: "refused by the Core Rule Set",
Host: "app.example", Host: "app.example",
}, },
"fsn1app1/blog: waf_block", "default shield", "fsn1app1/gitea: waf_block", "default shield",
"refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" + "refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" +
"netblock: 192.0.2.1/32", "netblock: 192.0.2.1/32",
}, },
@@ -1295,7 +1295,7 @@ func anAlertForEachEvent() []eventMessage {
Event: alerts.EventAnomaly, Netblock: netip.MustParsePrefix("192.0.2.0/24"), Event: alerts.EventAnomaly, Netblock: netip.MustParsePrefix("192.0.2.0/24"),
Reason: "requests per minute over the threshold of 5000", Reason: "requests per minute over the threshold of 5000",
}, },
"fsn1app1/blog: anomaly", "high chart_with_upwards_trend", "fsn1app1/gitea: anomaly", "high chart_with_upwards_trend",
"requests per minute over the threshold of 5000\nnetblock: 192.0.2.0/24", "requests per minute over the threshold of 5000\nnetblock: 192.0.2.0/24",
}, },
{ {
@@ -1304,7 +1304,7 @@ func anAlertForEachEvent() []eventMessage {
Netblock: netip.MustParsePrefix("192.0.2.2/32"), Netblock: netip.MustParsePrefix("192.0.2.2/32"),
Reason: "listed by a DNS blocklist", Reason: "listed by a DNS blocklist",
}, },
"fsn1app1/blog: reputation_hit", "low label", "fsn1app1/gitea: reputation_hit", "low label",
"listed by a DNS blocklist\nclient: 192.0.2.2\nnetblock: 192.0.2.2/32", "listed by a DNS blocklist\nclient: 192.0.2.2\nnetblock: 192.0.2.2/32",
}, },
{ {
@@ -1312,7 +1312,7 @@ func anAlertForEachEvent() []eventMessage {
Event: alerts.EventSourceFailure, Reason: "asking GeoJS failed", Event: alerts.EventSourceFailure, Reason: "asking GeoJS failed",
Detail: map[string]any{"source": "geojs"}, Detail: map[string]any{"source": "geojs"},
}, },
"fsn1app1/blog: source_failure", "high warning", "fsn1app1/gitea: source_failure", "high warning",
"asking GeoJS failed\nsource: geojs", "asking GeoJS failed\nsource: geojs",
}, },
{ {
@@ -1323,7 +1323,7 @@ func anAlertForEachEvent() []eventMessage {
"file": "/etc/smallwebwaf/rules.d/50-app.rules", "error": "line 2: no action", "file": "/etc/smallwebwaf/rules.d/50-app.rules", "error": "line 2: no action",
}, },
}, },
"fsn1app1/blog: file_error", "high warning", "fsn1app1/gitea: file_error", "high warning",
"a rule file has an error, and the rules stay as they were\n" + "a rule file has an error, and the rules stay as they were\n" +
"file: /etc/smallwebwaf/rules.d/50-app.rules\nerror: line 2: no action", "file: /etc/smallwebwaf/rules.d/50-app.rules\nerror: line 2: no action",
}, },
+353 -159
View File
@@ -7,6 +7,9 @@
// the image runs. // the image runs.
package config package config
// FIXME: the rproxy config is in rproxy.go, the servewaf is in config.go -
// wrong and inconsistent.
import ( import (
"crypto/x509" "crypto/x509"
"errors" "errors"
@@ -221,15 +224,12 @@ type Config struct {
// WAFExemptPaths the path prefixes it does not inspect // WAFExemptPaths the path prefixes it does not inspect
// (SWWAF_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body // (SWWAF_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
// it reads (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none. // it reads (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none.
// CoreRuleSetExclusionsFile is the path of the file of its exclusions WAFMode string
// (SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none. WAFParanoiaLevel int
WAFMode string WAFAnomalyThreshold int
WAFParanoiaLevel int WAFDisabledRules []int
WAFAnomalyThreshold int WAFExemptPaths []string
WAFDisabledRules []int WAFBodyLimit int64
WAFExemptPaths []string
WAFBodyLimit int64
CoreRuleSetExclusionsFile string
// TrapPaths are the paths a request for which is a clear sign of // TrapPaths are the paths a request for which is a clear sign of
// attack (SWWAF_TRAP_PATHS), each starting with / and without a ?. // attack (SWWAF_TRAP_PATHS), each starting with / and without a ?.
TrapPaths []string TrapPaths []string
@@ -366,13 +366,16 @@ var (
"is not a header name such as accept-language") "is not a header name such as accept-language")
errHeaderTakenOut = errors.New( errHeaderTakenOut = errors.New(
"is taken out of every request by Go's HTTP server, so it can never " + "is taken out of every request by Go's HTTP server, so it can never " +
"be logged") "be logged",
)
errOnBothLists = errors.New("is in SWWAF_DENIED_COUNTRIES too") errOnBothLists = errors.New("is in SWWAF_DENIED_COUNTRIES too")
errNotLookupSource = errors.New("is not geojs, file or off") errNotLookupSource = errors.New("is not geojs, file or off")
errNeedsLookups = errors.New("it needs each client looked up") errNeedsLookups = errors.New("it needs each client looked up")
errNeedsDBPath = errors.New("it names the file to look clients up in") errNeedsDBPath = errors.New(
errDBPathUnused = errors.New("only file reads it") "it names the file to look clients up in",
errOver64K = errors.New( )
errDBPathUnused = errors.New("only file reads it")
errOver64K = errors.New(
"is more than 64K, the most servewaf gets of a body") "is more than 64K, the most servewaf gets of a body")
errNotCommand = errors.New("is not rproxy or servewaf") errNotCommand = errors.New("is not rproxy or servewaf")
errNotDurationAboveZero = errors.New( errNotDurationAboveZero = errors.New(
@@ -397,22 +400,29 @@ var (
errNotPathPrefix = errors.New( errNotPathPrefix = errors.New(
"is not a path prefix starting with /, such as /assets/") "is not a path prefix starting with /, such as /assets/")
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",
)
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(
"is not the id of a Core Rule Set rule, a whole number such as 942100") "is not the id of a Core Rule Set rule, a whole number such as 942100",
)
errSetupRuleID = errors.New( errSetupRuleID = errors.New(
"is from 900000 to 900999, the ids of the rules that set the Core Rule Set " + "is from 900000 to 900999, the ids of the rules that set the Core Rule Set " +
"up and of smallwebwaf's own, which cannot be switched off") "up and of smallwebwaf's own, which cannot be switched off",
)
errNotBoolean = errors.New("is not true or false") errNotBoolean = errors.New("is not true or false")
errNotLogRemoteURL = errors.New( errNotLogRemoteURL = errors.New(
"is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " + "is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " +
"and nothing more, such as syslog+tls://logs.example:6514") "and nothing more, such as syslog+tls://logs.example:6514",
)
errNoCertificate = errors.New("holds no PEM certificate") errNoCertificate = errors.New("holds no PEM certificate")
errNotFacility = errors.New("is not a syslog facility such as local0 or daemon") errNotFacility = errors.New(
errNotAppName = errors.New( "is not a syslog facility such as local0 or daemon",
"is not 1 to 48 printable ASCII characters without a space, such as blog") )
errNotAppName = errors.New(
"is not 1 to 48 printable ASCII characters without a space, such as gitea",
)
errSetTwice = errors.New("set only one of them") errSetTwice = errors.New("set only one of them")
errNotWebhookURL = errors.New( errNotWebhookURL = 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, " +
@@ -421,35 +431,50 @@ var (
"is not a header name followed by : and the header's value, " + "is not a header name followed by : and the header's value, " +
"such as Authorization:Bearer <token>") "such as Authorization:Bearer <token>")
errControlCharacter = errors.New( errControlCharacter = errors.New(
"holds a control character, such as the carriage return of a Windows line end") "holds a control character, such as the carriage return of a Windows line end",
)
errNotAlertEvent = errors.New( errNotAlertEvent = errors.New(
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " + "is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
"source_failure or file_error") "source_failure or file_error")
errNotNumberOrOff = errors.New("is not a whole number above zero, such as 60, or off") errNotNumberOrOff = errors.New(
"is not a whole number above zero, such as 60, or off",
)
errNotUTF8 = errors.New("is not valid UTF-8") errNotUTF8 = errors.New("is not valid UTF-8")
errNotASN = errors.New("is not an AS number such as AS64496") errNotASN = errors.New("is not an AS number such as AS64496")
errNotPercentItem = errors.New( errNotPercentItem = errors.New(
"is not a code, : and a percentage, such as AS64496:50 or cn:25") "is not a code, : and a percentage, such as AS64496:50 or cn:25")
errNotPercent = errors.New("is not a percentage, a whole number from 0 to 100") errNotPercent = errors.New(
"is not a percentage, a whole number from 0 to 100",
)
errListedTwice = errors.New("is listed twice") errListedTwice = errors.New("is listed twice")
errNotListURL = errors.New( errNotListURL = 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 https://www.spamhaus.org/drop/drop.txt") "such as https://www.spamhaus.org/drop/drop.txt")
errInBlocklistURLs = errors.New("is in SWWAF_BLOCKLIST_URLS too") errInBlocklistURLs = errors.New("is in SWWAF_BLOCKLIST_URLS too")
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h") errNotAnHourOrMore = errors.New(
errNotAction = errors.New( "is not a duration of 1h or more, such as 24h",
)
errNotAction = errors.New(
"is not deny, limit:<percent> such as limit:25, or log") "is not deny, limit:<percent> such as limit:25, or log")
errNotZone = errors.New("is not a DNS zone such as dnsbl.dronebl.org") errNotZone = errors.New(
errZoneTooLong = errors.New("is longer than 189 characters, too long for the " + "is not a DNS zone such as dnsbl.dronebl.org",
"names IPv6 clients are asked about by") )
errNotResolver = errors.New("is not an IP address with an optional port, " + errZoneTooLong = errors.New(
"such as 192.0.2.53 or [2001:db8::53]:5353") "is longer than 189 characters, too long for the " +
"names IPv6 clients are asked about by",
)
errNotResolver = errors.New(
"is not an IP address with an optional port, " +
"such as 192.0.2.53 or [2001:db8::53]:5353",
)
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")
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(
errAnotherList = errors.New( "it is sent only to the engine at that URL",
)
errAnotherList = errors.New(
"is in SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too") "is in SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too")
) )
@@ -460,99 +485,208 @@ var (
// setting that is set but invalid is an error that names it. // setting that is set but invalid is an error that names it.
// //
//nolint:funlen // one line for each setting, a list that grows with them //nolint:funlen // one line for each setting, a list that grows with them
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) { func FromEnvironment(
lookupEnv func(string) (string, bool),
) (*Config, error) {
env := &environment{lookupEnv: lookupEnv} env := &environment{lookupEnv: lookupEnv}
cfg := &Config{ cfg := &Config{
ListenAddr: env.address("SWWAF_LISTEN_ADDR"), ListenAddr: env.address("SWWAF_LISTEN_ADDR"),
InstanceName: env.instanceName(), InstanceName: env.instanceName(),
Observe: env.observe("SWWAF_MODE", "enforce"), Observe: env.observe("SWWAF_MODE", "enforce"),
IPv6GroupPrefix: env.ipv6GroupPrefix("SWWAF_IPV6_GROUP_PREFIX", "64"), IPv6GroupPrefix: env.ipv6GroupPrefix(
MaxTrackedClients: env.numberNotOff("SWWAF_MAX_TRACKED_CLIENTS", "20000"), "SWWAF_IPV6_GROUP_PREFIX",
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""), "64",
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""), ),
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""), MaxTrackedClients: env.numberNotOff(
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "120"), "SWWAF_MAX_TRACKED_CLIENTS",
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "1500"), "20000",
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "6000"), ),
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""), AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "off"), RateLimitExemptNets: env.netblocks(
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "50G"), "SWWAF_RATE_LIMIT_EXEMPT_NETS",
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "100G"), "",
BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"), ),
LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"), DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""), RateLimitPerMinute: env.count(
LookupTimeout: env.durationNotOff("SWWAF_LOOKUP_TIMEOUT", "800ms"), "SWWAF_RATE_LIMIT_PER_MINUTE",
AddLookupHeaders: env.boolean("SWWAF_ADD_LOOKUP_HEADERS", "false"), "120",
DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""), ),
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "1500"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "6000"),
RateLimitExemptPaths: env.pathPrefixes(
"SWWAF_RATE_LIMIT_EXEMPT_PATHS",
"",
),
BytesLimitPerMinute: env.size(
"SWWAF_BYTES_LIMIT_PER_MINUTE",
"off",
),
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "50G"),
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "100G"),
BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"),
LookupSource: env.lookupSource(
"SWWAF_LOOKUP_SOURCE",
"geojs",
),
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""),
LookupTimeout: env.durationNotOff(
"SWWAF_LOOKUP_TIMEOUT",
"800ms",
),
AddLookupHeaders: env.boolean(
"SWWAF_ADD_LOOKUP_HEADERS",
"false",
),
DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""),
ExclusivelyAllowedCountries: env.countries( ExclusivelyAllowedCountries: env.countries(
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""), "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
ASNLimitPercent: env.percents("SWWAF_ASN_LIMIT_PERCENT", ParseASN), ASNLimitPercent: env.percents(
CountryLimitPercent: env.percents("SWWAF_COUNTRY_LIMIT_PERCENT", parseCountry), "SWWAF_ASN_LIMIT_PERCENT",
ASNBytesPercent: env.percents("SWWAF_ASN_BYTES_PERCENT", ParseASN), ParseASN,
CountryBytesPercent: env.percents("SWWAF_COUNTRY_BYTES_PERCENT", parseCountry), ),
UnknownLimitPercent: env.percent("SWWAF_UNKNOWN_LIMIT_PERCENT", "100"), CountryLimitPercent: env.percents(
ASNLimitPercentURL: env.listURL("SWWAF_ASN_LIMIT_PERCENT_URL"), "SWWAF_COUNTRY_LIMIT_PERCENT",
BlocklistURLs: env.listURLs("SWWAF_BLOCKLIST_URLS"), parseCountry,
BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"), ),
DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"), ASNBytesPercent: env.percents(
DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"), "SWWAF_ASN_BYTES_PERCENT",
AbuseIPDBKey: env.secret("SWWAF_ABUSEIPDB_KEY"), ParseASN,
AbuseIPDBMinScore: env.percent("SWWAF_ABUSEIPDB_MIN_SCORE", "75"), ),
AbuseIPDBDailyBudget: env.numberNotOff("SWWAF_ABUSEIPDB_DAILY_BUDGET", "900"), CountryBytesPercent: env.percents(
ReputationCacheTTL: env.durationNotOff("SWWAF_REPUTATION_CACHE_TTL", "24h"), "SWWAF_COUNTRY_BYTES_PERCENT",
ReputationTimeout: env.durationNotOff("SWWAF_REPUTATION_TIMEOUT", "2s"), parseCountry,
CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWWAF_CROWDSEC_LAPI_URL"), ),
CrowdSecKey: env.secret("SWWAF_CROWDSEC_LAPI_KEY"), UnknownLimitPercent: env.percent(
BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"), "SWWAF_UNKNOWN_LIMIT_PERCENT",
LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"), "100",
LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"), ),
MaxBanDuration: env.durationNotOff("SWWAF_MAX_BAN_DURATION", "7d"), ASNLimitPercentURL: env.listURL("SWWAF_ASN_LIMIT_PERCENT_URL"),
AttackBanDuration: env.durationNotOff("SWWAF_ATTACK_BAN_DURATION", "7d"), BlocklistURLs: env.listURLs("SWWAF_BLOCKLIST_URLS"),
MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"), BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"),
BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"), DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"),
StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/smallwebwaf"), DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"),
StateWriteDelay: env.durationNotOff("SWWAF_STATE_WRITE_DELAY", "10s"), AbuseIPDBKey: env.secret("SWWAF_ABUSEIPDB_KEY"),
StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"), AbuseIPDBMinScore: env.percent("SWWAF_ABUSEIPDB_MIN_SCORE", "75"),
LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS", AbuseIPDBDailyBudget: env.numberNotOff(
"accept,accept-language,accept-encoding,content-type,origin,range"), "SWWAF_ABUSEIPDB_DAILY_BUDGET",
LogLevel: env.logLevel("SWWAF_LOG_LEVEL", "info"), "900",
AdminToken: env.token("SWWAF_ADMIN_TOKEN"), ),
MetricsToken: env.token("SWWAF_METRICS_TOKEN"), ReputationCacheTTL: env.durationNotOff(
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"), "SWWAF_REPUTATION_CACHE_TTL",
RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"), "24h",
RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"), ),
WAFMode: env.wafMode("SWWAF_WAF_MODE", WAFModeBlock), ReputationTimeout: env.durationNotOff(
WAFParanoiaLevel: env.paranoiaLevel("SWWAF_WAF_PARANOIA_LEVEL", "1"), "SWWAF_REPUTATION_TIMEOUT",
WAFAnomalyThreshold: env.numberOrOff("SWWAF_WAF_ANOMALY_THRESHOLD", "5"), "2s",
),
CrowdSecDecisionsURL: env.crowdSecDecisionsURL(
"SWWAF_CROWDSEC_LAPI_URL",
),
CrowdSecKey: env.secret("SWWAF_CROWDSEC_LAPI_KEY"),
BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"),
LimitBanDuration: env.durationNotOff(
"SWWAF_LIMIT_BAN_DURATION",
"1h",
),
LimitBanRepeatWindow: env.durationNotOff(
"SWWAF_LIMIT_BAN_REPEAT_WINDOW",
"24h",
),
MaxBanDuration: env.durationNotOff(
"SWWAF_MAX_BAN_DURATION",
"7d",
),
AttackBanDuration: env.durationNotOff(
"SWWAF_ATTACK_BAN_DURATION",
"7d",
),
MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"),
BanScopeV4Prefix: env.v4Prefix(
"SWWAF_BAN_SCOPE_V4_PREFIX",
"32",
),
StateDir: env.absolutePath(
"SWWAF_STATE_DIR",
"/var/lib/smallwebwaf",
),
StateWriteDelay: env.durationNotOff(
"SWWAF_STATE_WRITE_DELAY",
"10s",
),
StateCounterInterval: env.durationNotOff(
"SWWAF_STATE_COUNTER_INTERVAL",
"15m",
),
LogRequestHeaders: env.headerNames(
"SWWAF_LOG_REQUEST_HEADERS",
"accept,accept-language,accept-encoding,content-type,origin,range",
),
LogLevel: env.logLevel("SWWAF_LOG_LEVEL", "info"),
AdminToken: env.token("SWWAF_ADMIN_TOKEN"),
MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
RulesDir: env.value(
"SWWAF_RULES_DIR",
"/etc/smallwebwaf/rules.d",
),
RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"),
WAFMode: env.wafMode("SWWAF_WAF_MODE", WAFModeBlock),
WAFParanoiaLevel: env.paranoiaLevel(
"SWWAF_WAF_PARANOIA_LEVEL",
"1",
),
WAFAnomalyThreshold: env.numberOrOff(
"SWWAF_WAF_ANOMALY_THRESHOLD",
"5",
),
WAFDisabledRules: env.ruleIDs("SWWAF_WAF_DISABLED_RULES", WAFDisabledRules: env.ruleIDs("SWWAF_WAF_DISABLED_RULES",
"920340,920420,920440,920640,930130,930140"), "920340,920420,920440,920640,930130,930140"),
WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""), WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""),
WAFBodyLimit: env.wafBodyLimit("SWWAF_WAF_BODY_LIMIT", off), WAFBodyLimit: env.wafBodyLimit("SWWAF_WAF_BODY_LIMIT", off),
CoreRuleSetExclusionsFile: env.filePath( TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"),
"SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE"), ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"),
TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"), LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"), LogRemoteTLSCAs: env.certificates(
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"), "SWWAF_LOG_REMOTE_TLS_CA_FILE",
LogRemoteTLSCAs: env.certificates("SWWAF_LOG_REMOTE_TLS_CA_FILE"), ),
LogRemoteBuffer: env.numberNotOff("SWWAF_LOG_REMOTE_BUFFER", "10000"), LogRemoteBuffer: env.numberNotOff(
LogRemoteFacility: env.facility("SWWAF_LOG_REMOTE_FACILITY", "local0"), "SWWAF_LOG_REMOTE_BUFFER",
AlertWebhookURL: env.webhookURL("SWWAF_ALERT_WEBHOOK_URL"), "10000",
AlertWebhookHeaders: env.webhookHeaders("SWWAF_ALERT_WEBHOOK_HEADERS"), ),
AlertSlackWebhookURL: env.webhookURL("SWWAF_ALERT_SLACK_WEBHOOK_URL"), LogRemoteFacility: env.facility(
AlertNtfyURL: env.webhookURL("SWWAF_ALERT_NTFY_URL"), "SWWAF_LOG_REMOTE_FACILITY",
AlertNtfyToken: env.secret("SWWAF_ALERT_NTFY_TOKEN"), "local0",
),
AlertWebhookURL: env.webhookURL("SWWAF_ALERT_WEBHOOK_URL"),
AlertWebhookHeaders: env.webhookHeaders(
"SWWAF_ALERT_WEBHOOK_HEADERS",
),
AlertSlackWebhookURL: env.webhookURL(
"SWWAF_ALERT_SLACK_WEBHOOK_URL",
),
AlertNtfyURL: env.webhookURL("SWWAF_ALERT_NTFY_URL"),
AlertNtfyToken: env.secret("SWWAF_ALERT_NTFY_TOKEN"),
AlertEvents: env.alertEvents("SWWAF_ALERT_EVENTS", AlertEvents: env.alertEvents("SWWAF_ALERT_EVENTS",
strings.Join(alerts.Events(), ",")), strings.Join(alerts.Events(), ",")),
AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"), AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"),
AlertMaxPerHour: env.numberOrOff("SWWAF_ALERT_MAX_PER_HOUR", "60"), AlertMaxPerHour: env.numberOrOff(
AnomalyClient: env.thresholds("SWWAF_ANOMALY_CLIENT_"), "SWWAF_ALERT_MAX_PER_HOUR",
AnomalyNet: env.thresholds("SWWAF_ANOMALY_NET_"), "60",
AnomalyASN: env.thresholds("SWWAF_ANOMALY_ASN_"), ),
AnomalyTotal: env.thresholds("SWWAF_ANOMALY_TOTAL_"), AnomalyClient: env.thresholds("SWWAF_ANOMALY_CLIENT_"),
AnomalyWatch: env.thresholds("SWWAF_WATCH_"), AnomalyNet: env.thresholds("SWWAF_ANOMALY_NET_"),
AnomalyNetV4Prefix: env.v4Prefix("SWWAF_ANOMALY_NET_V4_PREFIX", "24"), AnomalyASN: env.thresholds("SWWAF_ANOMALY_ASN_"),
AnomalyNetV6Prefix: env.v6Prefix("SWWAF_ANOMALY_NET_V6_PREFIX", "48"), AnomalyTotal: env.thresholds("SWWAF_ANOMALY_TOTAL_"),
WatchNets: env.namedNetblocks("SWWAF_WATCH_NETS"), AnomalyWatch: env.thresholds("SWWAF_WATCH_"),
AnomalyNetV4Prefix: env.v4Prefix(
"SWWAF_ANOMALY_NET_V4_PREFIX",
"24",
),
AnomalyNetV6Prefix: env.v6Prefix(
"SWWAF_ANOMALY_NET_V6_PREFIX",
"48",
),
WatchNets: env.namedNetblocks("SWWAF_WATCH_NETS"),
} }
cfg.LogRemoteAppName = env.appName("SWWAF_LOG_REMOTE_APP_NAME", cfg.LogRemoteAppName = env.appName("SWWAF_LOG_REMOTE_APP_NAME",
@@ -589,14 +723,20 @@ func Command(lookupEnv func(string) (string, bool)) (string, error) {
case "servewaf": case "servewaf":
return command, nil return command, nil
default: default:
return "", fmt.Errorf("SMALLWEBWAF_COMMAND: %q %w", command, errNotCommand) return "", fmt.Errorf(
"SMALLWEBWAF_COMMAND: %q %w",
command,
errNotCommand,
)
} }
} }
// ServeWAFListenAddr reads only SWWAF_LISTEN_ADDR, which may be given as a // ServeWAFListenAddr reads only SWWAF_LISTEN_ADDR, which may be given as a
// file, as FromEnvironment does. The health check in servewaf's container needs // file, as FromEnvironment does. The health check in servewaf's container needs
// no other setting, so it reads no other, nor a file that another names. // no other setting, so it reads no other, nor a file that another names.
func ServeWAFListenAddr(lookupEnv func(string) (string, bool)) (string, error) { func ServeWAFListenAddr(
lookupEnv func(string) (string, bool),
) (string, error) {
env := &environment{lookupEnv: lookupEnv} env := &environment{lookupEnv: lookupEnv}
listenAddr := env.address("SWWAF_LISTEN_ADDR") listenAddr := env.address("SWWAF_LISTEN_ADDR")
@@ -658,14 +798,19 @@ func (e *environment) lookup(name string) (string, bool) {
} }
if set { if set {
e.check(name, fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice)) e.check(
name,
fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice),
)
return value, set return value, set
} }
e.settings = append(e.settings, slog.String(fileName, path)) e.settings = append(e.settings, slog.String(fileName, path))
contents, err := os.ReadFile(path) //nolint:gosec // a file the admin names contents, err := os.ReadFile(
path,
) //nolint:gosec // a file the admin names
if err != nil { if err != nil {
e.check(fileName, fmt.Errorf("cannot be read: %w", err)) e.check(fileName, fmt.Errorf("cannot be read: %w", err))
@@ -916,7 +1061,10 @@ func (e *environment) zones(name string) []string {
logged[i] = MaskZoneKey(zone) logged[i] = MaskZoneKey(zone)
} }
e.settings = append(e.settings, slog.String(name, strings.Join(logged, ","))) e.settings = append(
e.settings,
slog.String(name, strings.Join(logged, ",")),
)
return zones return zones
} }
@@ -968,10 +1116,15 @@ func (e *environment) checkLookupDBPath(cfg *Config) {
switch { switch {
case cfg.LookupSource == fileSource && cfg.LookupDBPath == "": case cfg.LookupSource == fileSource && cfg.LookupDBPath == "":
e.check("SWWAF_LOOKUP_SOURCE", fmt.Errorf( e.check("SWWAF_LOOKUP_SOURCE", fmt.Errorf(
"is file while SWWAF_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath)) "is file while SWWAF_LOOKUP_DB_PATH is unset; %w",
errNeedsDBPath,
))
case cfg.LookupSource != fileSource && cfg.LookupDBPath != "": case cfg.LookupSource != fileSource && cfg.LookupDBPath != "":
e.check("SWWAF_LOOKUP_DB_PATH", fmt.Errorf( e.check("SWWAF_LOOKUP_DB_PATH", fmt.Errorf(
"is set while SWWAF_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused)) "is set while SWWAF_LOOKUP_SOURCE is %s; %w",
cfg.LookupSource,
errDBPathUnused,
))
} }
} }
@@ -1012,8 +1165,11 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
{"SWWAF_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0}, {"SWWAF_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0},
} { } {
if setting.set { if setting.set {
e.check(setting.name, fmt.Errorf("is set while SWWAF_LOOKUP_SOURCE is off; %w", e.check(
errNeedsLookups)) setting.name,
fmt.Errorf("is set while SWWAF_LOOKUP_SOURCE is off; %w",
errNeedsLookups),
)
} }
} }
} }
@@ -1036,14 +1192,21 @@ func (e *environment) checkCrowdSec(cfg *Config) {
switch { switch {
case decisionsURL != "" && cfg.CrowdSecKey == "": case decisionsURL != "" && cfg.CrowdSecKey == "":
e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf( e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf(
"is set while SWWAF_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey)) "is set while SWWAF_CROWDSEC_LAPI_KEY is unset; %w",
errNeedsLAPIKey,
))
case decisionsURL == "" && cfg.CrowdSecKey != "": case decisionsURL == "" && cfg.CrowdSecKey != "":
e.check("SWWAF_CROWDSEC_LAPI_KEY", fmt.Errorf( e.check("SWWAF_CROWDSEC_LAPI_KEY", fmt.Errorf(
"is set while SWWAF_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused)) "is set while SWWAF_CROWDSEC_LAPI_URL is unset; %w",
errLAPIKeyUnused,
))
case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) || case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) ||
decisionsURL == cfg.ASNLimitPercentURL): decisionsURL == cfg.ASNLimitPercentURL):
e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w", e.check(
decisionsURL, errAnotherList)) "SWWAF_CROWDSEC_LAPI_URL",
fmt.Errorf("gives the decision list %q, which %w",
decisionsURL, errAnotherList),
)
} }
} }
@@ -1058,7 +1221,9 @@ func (e *environment) headerNames(name, defaultValue string) []string {
// durationNotOff reads a setting that is a duration and, unlike a // durationNotOff reads a setting that is a duration and, unlike a
// timeout, cannot be off. // timeout, cannot be off.
func (e *environment) durationNotOff(name, defaultValue string) time.Duration { func (e *environment) durationNotOff(
name, defaultValue string,
) time.Duration {
duration, err := ParseDurationNotOff(e.value(name, defaultValue)) duration, err := ParseDurationNotOff(e.value(name, defaultValue))
e.check(name, err) e.check(name, err)
@@ -1194,16 +1359,6 @@ func (e *environment) logRemoteURL(name string) *url.URL {
return remote return remote
} }
// filePath reads a setting that is the path of a file, "" while it is
// unset. Its value names a file already, so, unlike the other settings,
// it has no _FILE form. Whoever uses the file reads it.
func (e *environment) filePath(name string) string {
path, _ := e.lookupEnv(name)
e.settings = append(e.settings, slog.String(name, path))
return path
}
// certificates reads a setting that is the path of a file of PEM // certificates reads a setting that is the path of a file of PEM
// certificates. Unset or empty, it is nil. Its value names a file // certificates. Unset or empty, it is nil. Its value names a file
// already, so, unlike the other settings, it has no _FILE form. // already, so, unlike the other settings, it has no _FILE form.
@@ -1248,7 +1403,10 @@ func (e *environment) instanceName() string {
value := e.value("SWWAF_INSTANCE_NAME", hostname) value := e.value("SWWAF_INSTANCE_NAME", hostname)
if !utf8.ValidString(value) { if !utf8.ValidString(value) {
e.check("SWWAF_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8)) e.check(
"SWWAF_INSTANCE_NAME",
fmt.Errorf("%q %w", value, errNotUTF8),
)
} }
return value return value
@@ -1257,7 +1415,10 @@ func (e *environment) instanceName() string {
// appName reads the setting that is the APP-NAME of the records the log // appName reads the setting that is the APP-NAME of the records the log
// lines are sent in, by default the instance name. Its value is checked // lines are sent in, by default the instance name. Its value is checked
// when it is set, and, while lines are sent, when it is the instance name. // when it is set, and, while lines are sent, when it is the instance name.
func (e *environment) appName(name, instanceName string, sending bool) string { func (e *environment) appName(
name, instanceName string,
sending bool,
) string {
value, set := e.lookup(name) value, set := e.lookup(name)
if !set { if !set {
value = instanceName value = instanceName
@@ -1270,8 +1431,14 @@ func (e *environment) appName(name, instanceName string, sending bool) string {
case set: case set:
e.check(name, fmt.Errorf("%q %w", value, errNotAppName)) e.check(name, fmt.Errorf("%q %w", value, errNotAppName))
case sending: case sending:
e.check(name, fmt.Errorf("is unset, and SWWAF_INSTANCE_NAME %q, its default, %w", e.check(
value, errNotAppName)) name,
fmt.Errorf(
"is unset, and SWWAF_INSTANCE_NAME %q, its default, %w",
value,
errNotAppName,
),
)
} }
return value return value
@@ -1280,11 +1447,16 @@ func (e *environment) appName(name, instanceName string, sending bool) string {
// checkInstanceNameForNtfy refuses an instance name that holds a control // checkInstanceNameForNtfy refuses an instance name that holds a control
// character while ntfySet, SWWAF_ALERT_NTFY_URL being set: ntfy is sent // character while ntfySet, SWWAF_ALERT_NTFY_URL being set: ntfy is sent
// the instance name in a header, which cannot hold one. // the instance name in a header, which cannot hold one.
func (e *environment) checkInstanceNameForNtfy(instanceName string, ntfySet bool) { func (e *environment) checkInstanceNameForNtfy(
instanceName string,
ntfySet bool,
) {
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) { if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
e.check("SWWAF_INSTANCE_NAME", fmt.Errorf( e.check("SWWAF_INSTANCE_NAME", fmt.Errorf(
"%q %w, and is sent to ntfy in a header while SWWAF_ALERT_NTFY_URL is set", "%q %w, and is sent to ntfy in a header while SWWAF_ALERT_NTFY_URL is set",
instanceName, errControlCharacter)) instanceName,
errControlCharacter,
))
} }
} }
@@ -1604,7 +1776,10 @@ func parseNamedNetblocks(value string) ([]anomaly.NamedNetblock, error) {
return nil, fmt.Errorf("%q %w", name, errListedTwice) return nil, fmt.Errorf("%q %w", name, errListedTwice)
} }
named = append(named, anomaly.NamedNetblock{Name: name, Netblock: netblock}) named = append(
named,
anomaly.NamedNetblock{Name: name, Netblock: netblock},
)
} }
return named, nil return named, nil
@@ -1843,8 +2018,11 @@ func parseHeaderNames(value string) ([]string, error) {
header := strings.ToLower(item) header := strings.ToLower(item)
switch header { switch header {
case "host": case "host":
return nil, fmt.Errorf("%q %w; the request's host is the field host", return nil, fmt.Errorf(
item, errHeaderTakenOut) "%q %w; the request's host is the field host",
item,
errHeaderTakenOut,
)
case "transfer-encoding": case "transfer-encoding":
return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut) return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut)
} }
@@ -1881,9 +2059,11 @@ func parseUpstreamURL(value string) (*url.URL, error) {
} }
onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") && onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") &&
upstream.Hostname() != "" && upstream.User == nil && upstream.Opaque == "" && upstream.Hostname() != "" && upstream.User == nil &&
upstream.Opaque == "" &&
(upstream.Path == "" || upstream.Path == "/") && (upstream.Path == "" || upstream.Path == "/") &&
upstream.RawQuery == "" && upstream.Fragment == "" upstream.RawQuery == "" &&
upstream.Fragment == ""
if !onlySchemeAndHost { if !onlySchemeAndHost {
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL) return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
} }
@@ -1907,7 +2087,11 @@ func parseLogRemoteURL(value string) (*url.URL, error) {
return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL) return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL)
} }
schemes := []string{remotelog.SchemeUDP, remotelog.SchemeTCP, remotelog.SchemeTLS} schemes := []string{
remotelog.SchemeUDP,
remotelog.SchemeTCP,
remotelog.SchemeTLS,
}
port, err := strconv.ParseUint(remote.Port(), 10, 16) port, err := strconv.ParseUint(remote.Port(), 10, 16)
onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) && onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) &&
@@ -1970,7 +2154,9 @@ func isHTTPURL(u *url.URL) bool {
return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" && return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" &&
(u.Port() == "" || (err == nil && port != 0)) && (u.Port() == "" || (err == nil && port != 0)) &&
u.User == nil && u.Opaque == "" && u.Fragment == "" u.User == nil &&
u.Opaque == "" &&
u.Fragment == ""
} }
// isListURL reports whether value is a URL a list can be fetched from, as // isListURL reports whether value is a URL a list can be fetched from, as
@@ -2037,9 +2223,12 @@ func parseZones(value string) ([]string, error) {
for i, zone := range zones { for i, zone := range zones {
shown := MaskZoneKey(zone) shown := MaskZoneKey(zone)
listedBefore := slices.ContainsFunc(zones[:i], func(earlier string) bool { listedBefore := slices.ContainsFunc(
return strings.EqualFold(MaskZoneKey(earlier), shown) zones[:i],
}) func(earlier string) bool {
return strings.EqualFold(MaskZoneKey(earlier), shown)
},
)
switch { switch {
case len(zone) > maxZoneLength: case len(zone) > maxZoneLength:
@@ -2126,8 +2315,13 @@ func parseWebhookHeaders(value string) (http.Header, string, error) {
name, headerValue, found := strings.Cut(item, ":") name, headerValue, found := strings.Cut(item, ":")
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if !found || !IsHeaderName(name) || strings.ContainsAny(headerValue, "\r\n\x00") { if !found || !IsHeaderName(name) ||
return nil, "", fmt.Errorf("item %d %w", i+1, errNotWebhookHeader) strings.ContainsAny(headerValue, "\r\n\x00") {
return nil, "", fmt.Errorf(
"item %d %w",
i+1,
errNotWebhookHeader,
)
} }
headers.Add(name, strings.TrimSpace(headerValue)) headers.Add(name, strings.TrimSpace(headerValue))
+11 -15
View File
@@ -87,7 +87,6 @@ const (
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES" wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS" wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT" wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
exclusionsFile = "SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE"
trapPaths = "SWWAF_TRAP_PATHS" trapPaths = "SWWAF_TRAP_PATHS"
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD" errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
logRemoteURL = "SWWAF_LOG_REMOTE_URL" logRemoteURL = "SWWAF_LOG_REMOTE_URL"
@@ -198,7 +197,7 @@ const (
// instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and // instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and
// remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines. // remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines.
const ( const (
instance = "fsn1app1/blog" instance = "fsn1app1/gitea"
remoteURL = "syslog+udp://192.0.2.1:514" remoteURL = "syslog+udp://192.0.2.1:514"
) )
@@ -540,14 +539,13 @@ func TestCoreRuleSetSettings(t *testing.T) {
environment{ environment{
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10", wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/", wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
wafBodyLimit: "32K", exclusionsFile: "/etc/smallwebwaf/crs-exclusions.conf", wafBodyLimit: "32K",
}, },
config.Config{ config.Config{
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10, WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
WAFDisabledRules: []int{942100, 920350}, WAFDisabledRules: []int{942100, 920350},
WAFExemptPaths: []string{"/api/", "/static/"}, WAFExemptPaths: []string{"/api/", "/static/"},
WAFBodyLimit: 32 << 10, WAFBodyLimit: 32 << 10,
CoreRuleSetExclusionsFile: "/etc/smallwebwaf/crs-exclusions.conf",
}, },
}, },
{ {
@@ -567,8 +565,7 @@ func TestCoreRuleSetSettings(t *testing.T) {
WAFMode: cfg.WAFMode, WAFParanoiaLevel: cfg.WAFParanoiaLevel, WAFMode: cfg.WAFMode, WAFParanoiaLevel: cfg.WAFParanoiaLevel,
WAFAnomalyThreshold: cfg.WAFAnomalyThreshold, WAFAnomalyThreshold: cfg.WAFAnomalyThreshold,
WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths, WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths,
WAFBodyLimit: cfg.WAFBodyLimit, WAFBodyLimit: cfg.WAFBodyLimit,
CoreRuleSetExclusionsFile: cfg.CoreRuleSetExclusionsFile,
} }
if !reflect.DeepEqual(got, tc.want) { if !reflect.DeepEqual(got, tc.want) {
t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want) t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want)
@@ -653,11 +650,11 @@ func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
instanceName: "fsn1app1/blog", instanceName: "fsn1app1/gitea",
logRequestHeaders: " Accept , X-Custom", logRequestHeaders: " Accept , X-Custom",
}) })
if cfg.InstanceName != "fsn1app1/blog" || if cfg.InstanceName != "fsn1app1/gitea" ||
!slices.Equal(cfg.LogRequestHeaders, []string{"accept", "x-custom"}) { !slices.Equal(cfg.LogRequestHeaders, []string{"accept", "x-custom"}) {
t.Errorf("%s is %q and %s gives %v", instanceName, cfg.InstanceName, t.Errorf("%s is %q and %s gives %v", instanceName, cfg.InstanceName,
logRequestHeaders, cfg.LogRequestHeaders) logRequestHeaders, cfg.LogRequestHeaders)
@@ -800,7 +797,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) {
want := logRemoteAppName + `: is unset, and ` + instanceName + want := logRemoteAppName + `: is unset, and ` + instanceName +
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` + ` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
`without a space, such as blog` `without a space, such as gitea`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -829,7 +826,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
}.lookupEnv) }.lookupEnv)
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` + want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
`characters without a space, such as blog` `characters without a space, such as gitea`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -2169,7 +2166,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
}.lookupEnv) }.lookupEnv)
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` + want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
`characters without a space, such as blog` `characters without a space, such as gitea`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -2330,7 +2327,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
wafDisabledRules: defaultWAFDisabledRules, wafDisabledRules: defaultWAFDisabledRules,
wafExemptPaths: "", wafExemptPaths: "",
wafBodyLimit: off, wafBodyLimit: off,
exclusionsFile: "",
trapPaths: "", trapPaths: "",
errorBurstThreshold: "30", errorBurstThreshold: "30",
logRemoteURL: "", logRemoteURL: "",
+28 -6
View File
@@ -16,6 +16,11 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
) )
// FIXME: you can't call things just "File" - that's super vague.
// FIXME: "lookup database" - in memory on disk? you must not be so
// ambiguous when describing things.
// quietTime is how long the lookup database must go without a change // quietTime is how long the lookup database must go without a change
// before it is read again, so that a file still being copied in is read // before it is read again, so that a file still being copied in is read
// only once whole. // only once whole.
@@ -133,8 +138,11 @@ func (f *File) Watch(ctx context.Context) {
} }
if err != nil { if err != nil {
f.params.ProcessLog.Error("cannot watch the lookup database for replacements", f.params.ProcessLog.Error(
"error", err.Error()) "cannot watch the lookup database for replacements",
"error",
err.Error(),
)
return return
} }
@@ -194,7 +202,10 @@ func (f *File) readAgain() {
f.params.Alerts.Raise(alerts.Alert{ f.params.Alerts.Raise(alerts.Alert{
Event: alerts.EventFileError, Event: alerts.EventFileError,
Reason: kept, Reason: kept,
Detail: map[string]any{"file": f.params.Path, "error": err.Error()}, Detail: map[string]any{
"file": f.params.Path,
"error": err.Error(),
},
}) })
f.params.ProcessLog.Error(kept, "error", err.Error()) f.params.ProcessLog.Error(kept, "error", err.Error())
@@ -212,7 +223,11 @@ func (f *File) use(reader *maxminddb.Reader) {
f.lastRead = f.params.Now() f.lastRead = f.params.Now()
f.mu.Unlock() f.mu.Unlock()
f.params.ProcessLog.Info("read the lookup database", "file", f.params.Path) f.params.ProcessLog.Info(
"read the lookup database",
"file",
f.params.Path,
)
} }
// read reads the lookup database at path. The whole file is read into // read reads the lookup database at path. The whole file is read into
@@ -221,12 +236,19 @@ func (f *File) use(reader *maxminddb.Reader) {
func read(path string) (*maxminddb.Reader, error) { func read(path string) (*maxminddb.Reader, error) {
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
if err != nil { if err != nil {
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH cannot be read: %w", err) return nil, fmt.Errorf(
"SWWAF_LOOKUP_DB_PATH cannot be read: %w",
err,
)
} }
reader, err := maxminddb.OpenBytes(data) reader, err := maxminddb.OpenBytes(data)
if err != nil { if err != nil {
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err) return nil, fmt.Errorf(
"SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w",
path,
err,
)
} }
return reader, nil return reader, nil
+4 -4
View File
@@ -44,7 +44,7 @@ import (
const ( const (
// started is the time a record made as a test starts gives. // started is the time a record made as a test starts gives.
started = "2000-01-01T00:00:00.000Z" started = "2000-01-01T00:00:00.000Z"
appName = "fsn1app1/blog" appName = "fsn1app1/gitea"
// local0 is the number of the default facility, and local0Info the // local0 is the number of the default facility, and local0Info the
// priority of its records. // priority of its records.
local0 = 16 local0 = 16
@@ -94,7 +94,7 @@ func TestRecordsOverTCPAreOctetCountedWithTheirFacility(t *testing.T) {
endpoint := listen(t) endpoint := listen(t)
endpointParams := params(remotelog.SchemeTCP, endpoint.Addr()) endpointParams := params(remotelog.SchemeTCP, endpoint.Addr())
endpointParams.Facility = 19 // local3 endpointParams.Facility = 19 // local3
endpointParams.AppName = "blog" endpointParams.AppName = "gitea"
sender, _, _ := run(t, endpointParams) sender, _, _ := run(t, endpointParams)
_, _ = sender.Write([]byte("first\nsecond\n")) _, _ = sender.Write([]byte("first\nsecond\n"))
@@ -103,8 +103,8 @@ func TestRecordsOverTCPAreOctetCountedWithTheirFacility(t *testing.T) {
wantCounts(t, sender, 2, 0, 0) wantCounts(t, sender, 2, 0, 0)
frames := bufio.NewReader(accept(t, endpoint)) frames := bufio.NewReader(accept(t, endpoint))
wantFrame(t, frames, record(t, "<158>", "blog", "first")) wantFrame(t, frames, record(t, "<158>", "gitea", "first"))
wantFrame(t, frames, record(t, "<158>", "blog", "second")) wantFrame(t, frames, record(t, "<158>", "gitea", "second"))
}) })
} }
+38 -4
View File
@@ -19,29 +19,51 @@ const (
// ActionForward is a request servewaf allowed, which rproxy passes to the // ActionForward is a request servewaf allowed, which rproxy passes to the
// app. // app.
ActionForward = "forward" ActionForward = "forward"
//FIXME: "too_large" doesn't tell a reader what that means.
//request_header_too_large does. you must not use ambiguous terms.
// ActionTooLarge is a request over the header limits. // ActionTooLarge is a request over the header limits.
ActionTooLarge = "too_large" ActionTooLarge = "too_large"
// ActionRateLimited is a request refused because it took its client // ActionRateLimited is a request refused because it took its client
// over a rate limit, which bans the client. // over a rate limit, which bans the client.
//FIXME: better would be "client_is_rate_limited"
ActionRateLimited = "rate_limited" ActionRateLimited = "rate_limited"
// ActionBanned is a request refused because a ban covers its client, // ActionBanned is a request refused because a ban covers its client,
// or because it matched a ban rule, asked for a trap path or the // or because it matched a ban rule, asked for a trap path or the
// CrowdSec decision list lists its client, each of which bans the // CrowdSec decision list lists its client, each of which bans the
// client. // client.
// FIXME: if I saw this, i would assume they are permanently banned. is that
// true? if not, there should be TemporarilyBanned and
// PermanentlyBanned
ActionBanned = "banned" ActionBanned = "banned"
// ActionRuleBlocked is a request refused because it matched a block // ActionRuleBlocked is a request refused because it matched a block
// rule. // rule.
// FIXME: "blocked_by_block_rule"
ActionRuleBlocked = "rule_blocked" ActionRuleBlocked = "rule_blocked"
// ActionWAFBlocked is a request refused because the Core Rule Set // ActionWAFBlocked is a request refused because the Core Rule Set
// scored it at or over SWWAF_WAF_ANOMALY_THRESHOLD. // scored it at or over SWWAF_WAF_ANOMALY_THRESHOLD.
ActionWAFBlocked = "waf_blocked" ActionWAFBlocked = "waf_blocked"
// ActionDenied is a request refused because its client is in // ActionDenied is a request refused because its client is in
// SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny, // SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny,
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while // or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
// SWWAF_REPUTATION_ACTION is deny. // SWWAF_REPUTATION_ACTION is deny.
ActionDenied = "denied" ActionDenied = "denied"
// ActionCountryDenied is a request refused for its client's country. // ActionCountryDenied is a request refused for its client's country.
ActionCountryDenied = "country_denied" ActionCountryDenied = "country_denied"
// FIXME: you have country_denied and denied. why isn't it
// client_denied? ALWAYS BE CONSISTENT AND UNAMBIGUOUS
// ActionAdmin is a request servewaf answered at one of its own endpoints, // ActionAdmin is a request servewaf answered at one of its own endpoints,
// or a question about a path under /_smallwebwaf/. // or a question about a path under /_smallwebwaf/.
ActionAdmin = "admin" ActionAdmin = "admin"
@@ -245,7 +267,11 @@ func FormatTime(t time.Time) string {
// Milliseconds is d in milliseconds, to the microsecond. // Milliseconds is d in milliseconds, to the microsecond.
func Milliseconds(d time.Duration) float64 { func Milliseconds(d time.Duration) float64 {
return float64(d.Microseconds()) / float64(time.Millisecond/time.Microsecond) return float64(
d.Microseconds(),
) / float64(
time.Millisecond/time.Microsecond,
)
} }
// NewProcessLogger returns the logger for the process's own messages: // NewProcessLogger returns the logger for the process's own messages:
@@ -253,17 +279,25 @@ func Milliseconds(d time.Duration) float64 {
// as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance. // as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance.
// It writes only the messages at level, SWWAF_LOG_LEVEL, or more severe; // It writes only the messages at level, SWWAF_LOG_LEVEL, or more severe;
// the request lines Write writes are never held back. // the request lines Write writes are never held back.
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger { func NewProcessLogger(
w io.Writer,
instanceName string,
level slog.Level,
) *slog.Logger {
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{ handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: level, Level: level,
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr { ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
if attr.Key == slog.TimeKey && len(groups) == 0 { if attr.Key == slog.TimeKey && len(groups) == 0 {
return slog.String(slog.TimeKey, FormatTime(attr.Value.Time())) return slog.String(
slog.TimeKey,
FormatTime(attr.Value.Time()),
)
} }
return attr return attr
}, },
}) })
return slog.New(handler).With("type", "process", "instance", instanceName) return slog.New(handler).
With("type", "process", "instance", instanceName)
} }
+3 -3
View File
@@ -94,7 +94,7 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
requestlog.NewProcessLogger(&out, "fsn1app1/blog", slog.LevelInfo).Info("starting", requestlog.NewProcessLogger(&out, "fsn1app1/gitea", slog.LevelInfo).Info("starting",
"version", "v1") "version", "v1")
var fields map[string]any var fields map[string]any
@@ -104,7 +104,7 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
t.Fatalf("decode %q: %v", out.String(), err) t.Fatalf("decode %q: %v", out.String(), err)
} }
if fields["type"] != "process" || fields["instance"] != "fsn1app1/blog" || if fields["type"] != "process" || fields["instance"] != "fsn1app1/gitea" ||
fields["msg"] != "starting" || fields["level"] != "INFO" || fields["msg"] != "starting" || fields["level"] != "INFO" ||
fields["version"] != "v1" { fields["version"] != "v1" {
t.Errorf("process line %v", fields) t.Errorf("process line %v", fields)
@@ -133,7 +133,7 @@ func TestProcessLoggerWritesTheMessagesAtItsLevelOrMoreSevere(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
processLog := requestlog.NewProcessLogger(&out, "fsn1app1/blog", level) processLog := requestlog.NewProcessLogger(&out, "fsn1app1/gitea", level)
for _, at := range levels { for _, at := range levels {
processLog.Log(t.Context(), at, "message") processLog.Log(t.Context(), at, "message")
} }
+1 -1
View File
@@ -18,7 +18,7 @@ const (
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL" alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR" alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
// alertInstance is the instance every alert of these tests gives. // alertInstance is the instance every alert of these tests gives.
alertInstance = "fsn1app1/blog" alertInstance = "fsn1app1/gitea"
) )
func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) { func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
+14 -23
View File
@@ -4,7 +4,6 @@
package servewaf package servewaf
import ( import (
"fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -61,9 +60,6 @@ type Params struct {
// LookupFile is the lookup database they are looked up in while // LookupFile is the lookup database they are looked up in while
// SWWAF_LOOKUP_SOURCE is file, and nil otherwise. // SWWAF_LOOKUP_SOURCE is file, and nil otherwise.
LookupFile *lookup.File LookupFile *lookup.File
// CoreRuleSet is what NewCoreRuleSet returns: what inspects requests
// while SWWAF_WAF_MODE is not off, and nil while it is.
CoreRuleSet *waf.CoreRuleSet
// Now tells the time by which requests are counted for the rate // Now tells the time by which requests are counted for the rate
// limits, bans are made and run out, and GeoJS's answers are kept, // limits, bans are made and run out, and GeoJS's answers are kept,
// normally time.Now in UTC, the time the state files give. // normally time.Now in UTC, the time the state files give.
@@ -148,7 +144,7 @@ func newHandler(params Params) *handler {
dnsbl: dnsbl, dnsbl: dnsbl,
abuseIPDB: abuseIPDB, abuseIPDB: abuseIPDB,
rules: params.Rules, rules: params.Rules,
coreRuleSet: params.CoreRuleSet, coreRuleSet: newCoreRuleSet(params.Config),
alerts: params.Alerts, alerts: params.Alerts,
} }
h.geojs = lookup.New(lookup.Params{ h.geojs = lookup.New(lookup.Params{
@@ -206,34 +202,29 @@ func newReputation(
return lists, dnsbl, abuseIPDB return lists, dnsbl, abuseIPDB
} }
// NewCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL, // newCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL,
// without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies // without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies
// up to SWWAF_WAF_BODY_LIMIT, with the exclusions in // up to SWWAF_WAF_BODY_LIMIT, or nil while SWWAF_WAF_MODE is off. It reads at
// SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWWAF_WAF_MODE is off. It // 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 error in every mode. While the mode is off, a set file is loaded with the func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet {
// Core Rule Set only to check it. if cfg.WAFMode == config.WAFModeOff {
func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) { return nil
if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
} }
coreRuleSet, err := waf.New(waf.Params{ coreRuleSet, err := waf.New(waf.Params{
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules, ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1), BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err) // The Core Rule Set is built in, and the settings cannot break it:
// the paranoia level is from 1 to 4, and the id of no rule switches
// nothing off.
panic(err)
} }
if cfg.WAFMode == config.WAFModeOff { return coreRuleSet
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
}
return coreRuleSet, nil
} }
// handler is servewaf's handler. It holds what every request shares; what // handler is servewaf's handler. It holds what every request shares; what
-6
View File
@@ -463,11 +463,6 @@ func newServeWAFWith(
} }
} }
coreRuleSet, err := servewaf.NewCoreRuleSet(cfg)
if err != nil {
t.Fatalf("Core Rule Set: %v", err)
}
server := build(servewaf.Params{ server := build(servewaf.Params{
Config: cfg, Config: cfg,
RequestLog: out, RequestLog: out,
@@ -475,7 +470,6 @@ func newServeWAFWith(
GeoJSURL: geojsURL, GeoJSURL: geojsURL,
AbuseIPDBURL: abuseIPDBURL, AbuseIPDBURL: abuseIPDBURL,
LookupFile: lookupFile, LookupFile: lookupFile,
CoreRuleSet: coreRuleSet,
Now: now, Now: now,
Rules: ruleFiles, Rules: ruleFiles,
Alerts: alertQueue, Alerts: alertQueue,
+1 -1
View File
@@ -19,7 +19,7 @@ const (
// requestIDHeader carries the request's id. // requestIDHeader carries the request's id.
requestIDHeader = "X-Request-ID" requestIDHeader = "X-Request-ID"
// instance is the SWWAF_INSTANCE_NAME a test sets. // instance is the SWWAF_INSTANCE_NAME a test sets.
instance = "fsn1app1/blog" instance = "fsn1app1/gitea"
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate // ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
// limits count it as. // limits count it as.
ipv6Client = "2001:db8::7" ipv6Client = "2001:db8::7"
+3 -12
View File
@@ -29,7 +29,6 @@ import (
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/servewaf" "sneak.berlin/go/smallwebwaf/internal/servewaf"
"sneak.berlin/go/smallwebwaf/internal/state" "sneak.berlin/go/smallwebwaf/internal/state"
"sneak.berlin/go/smallwebwaf/internal/waf"
) )
// shutdownTimeout is how long requests in progress may take to finish // shutdownTimeout is how long requests in progress may take to finish
@@ -151,14 +150,7 @@ func RunServeWAF(ctx context.Context, params Params) int {
return 1 return 1
} }
crs, err := servewaf.NewCoreRuleSet(cfg) server, err := newServer(cfg, stdout, processLog, now, ruleFiles, alertQueue)
if err != nil {
processLog.Error("cannot use the Core Rule Set", "error", err.Error())
return 1
}
server, err := newServer(cfg, stdout, processLog, now, ruleFiles, alertQueue, crs)
if err != nil { if err != nil {
processLog.Error("cannot use the lookup database", "error", err.Error()) processLog.Error("cannot use the lookup database", "error", err.Error())
@@ -178,7 +170,8 @@ func RunServeWAF(ctx context.Context, params Params) int {
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr) listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
if err != nil { if err != nil {
processLog.Error("cannot listen on SWWAF_LISTEN_ADDR", "error", err.Error()) processLog.Error("cannot listen on SWWAF_LISTEN_ADDR",
"error", err.Error())
return 1 return 1
} }
@@ -197,7 +190,6 @@ func RunServeWAF(ctx context.Context, params Params) int {
func newServer( func newServer(
cfg *config.Config, stdout io.Writer, processLog *slog.Logger, cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue, now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
crs *waf.CoreRuleSet,
) (*servewaf.Server, error) { ) (*servewaf.Server, error) {
var lookupFile *lookup.File var lookupFile *lookup.File
@@ -222,7 +214,6 @@ func newServer(
GeoJSURL: lookup.URL, GeoJSURL: lookup.URL,
AbuseIPDBURL: reputation.AbuseIPDBURL, AbuseIPDBURL: reputation.AbuseIPDBURL,
LookupFile: lookupFile, LookupFile: lookupFile,
CoreRuleSet: crs,
Now: now, Now: now,
Rules: ruleFiles, Rules: ruleFiles,
Alerts: alertQueue, Alerts: alertQueue,
+14 -65
View File
@@ -19,7 +19,6 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest" "sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
"sneak.berlin/go/smallwebwaf/internal/servewaf" "sneak.berlin/go/smallwebwaf/internal/servewaf"
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf" "sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
@@ -50,7 +49,7 @@ const (
adminSecret = "fedcba9876543210fedcba9876543210" adminSecret = "fedcba9876543210fedcba9876543210"
// instance is the SWWAF_INSTANCE_NAME the tests set where they look at // instance is the SWWAF_INSTANCE_NAME the tests set where they look at
// it. // it.
instance = "fsn1app1/blog" instance = "fsn1app1/gitea"
// greeting is what the tests' app answers. // greeting is what the tests' app answers.
greeting = "hello from the app" greeting = "hello from the app"
// placed is the client the tests' lookup databases place. // placed is the client the tests' lookup databases place.
@@ -342,8 +341,8 @@ func TestEveryLogLineAndMetricCarriesTheInstanceName(t *testing.T) {
// the comments. // the comments.
for line := range strings.Lines(metrics) { for line := range strings.Lines(metrics) {
if !strings.HasPrefix(line, "#") && if !strings.HasPrefix(line, "#") &&
!strings.Contains(line, `instance="fsn1app1/blog"`) { !strings.Contains(line, `instance="fsn1app1/gitea"`) {
t.Errorf("series %q, without instance=\"fsn1app1/blog\"", line) t.Errorf("series %q, without instance=\"fsn1app1/gitea\"", line)
} }
} }
} }
@@ -552,56 +551,6 @@ func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
": no such file or directory") ": no such file or directory")
} }
func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "crs-exclusions.conf")
out := &output{}
status := run(t.Context(), map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
"SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
}, out)
if status != 1 {
t.Fatalf("exit status %d, want 1", status)
}
line := out.line(t, "msg", "cannot use the Core Rule Set")
want := "SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " +
path + ": no such file or directory"
if line["error"] != want {
t.Errorf("start refused with %v, want the error %q", line, want)
}
}
func TestCoreRuleSetExclusionsFileThatCannotBeUsedStopsTheStartWhileTheCoreRuleSetIsOff(
t *testing.T,
) {
t.Parallel()
broken := filepath.Join(t.TempDir(), "broken.conf")
err := os.WriteFile(broken, []byte("SecNoSuchDirective On\n"), 0o600)
if err != nil {
t.Fatal(err)
}
for _, path := range []string{filepath.Join(t.TempDir(), "missing.conf"), broken} {
out := &output{}
status := run(t.Context(), map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
"SWWAF_WAF_MODE": config.WAFModeOff, "SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
}, out)
if status != 1 {
t.Fatalf("%s: exit status %d, want 1", path, status)
}
out.line(t, "msg", "cannot use the Core Rule Set")
}
}
func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) { func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
t.Parallel() t.Parallel()
@@ -661,9 +610,9 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
rename(t, replacement, path) rename(t, replacement, path)
metrics := metricsWith(t, url+"_smallwebwaf/metrics", token, metrics := metricsWith(t, url+"_smallwebwaf/metrics", token,
`smallwebwaf_lookup_database_read_failures_total{instance="fsn1app1/blog"} 1`) `smallwebwaf_lookup_database_read_failures_total{instance="fsn1app1/gitea"} 1`)
lastRead = seriesValue(t, metrics, lastRead = seriesValue(t, metrics,
`smallwebwaf_lookup_database_last_read_timestamp_seconds{instance="fsn1app1/blog"}`) `smallwebwaf_lookup_database_last_read_timestamp_seconds{instance="fsn1app1/gitea"}`)
wantStatus(t, url, placed, http.StatusOK) wantStatus(t, url, placed, http.StatusOK)
}) })
@@ -719,7 +668,7 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
// too. // too.
rateLimitExemptNets: placed + "," + localhost, rateLimitExemptNets: placed + "," + localhost,
} }
failures := `smallwebwaf_reputation_failures_total{instance="fsn1app1/blog",` + failures := `smallwebwaf_reputation_failures_total{instance="fsn1app1/gitea",` +
`source="` + torURL + `"} ` `source="` + torURL + `"} `
// tor.txt cannot be fetched at first, which is counted. // tor.txt cannot be fetched at first, which is counted.
@@ -960,8 +909,8 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
// last. // last.
metrics := metricsText(t, url+"_smallwebwaf/metrics", token) metrics := metricsText(t, url+"_smallwebwaf/metrics", token)
for _, series := range []string{ for _, series := range []string{
`smallwebwaf_remote_log_lines_sent_total{instance="fsn1app1/blog"} 0`, `smallwebwaf_remote_log_lines_sent_total{instance="fsn1app1/gitea"} 0`,
`smallwebwaf_remote_log_buffer_depth{instance="fsn1app1/blog"} 1`, `smallwebwaf_remote_log_buffer_depth{instance="fsn1app1/gitea"} 1`,
} { } {
if !strings.Contains(metrics, "\n"+series+"\n") { if !strings.Contains(metrics, "\n"+series+"\n") {
t.Errorf("no %q in the metrics:\n%s", series, metrics) t.Errorf("no %q in the metrics:\n%s", series, metrics)
@@ -969,7 +918,7 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
} }
const dropped = "\nsmallwebwaf_remote_log_lines_dropped_total" + const dropped = "\nsmallwebwaf_remote_log_lines_dropped_total" +
`{instance="fsn1app1/blog"} ` `{instance="fsn1app1/gitea"} `
if strings.Contains(metrics, dropped+"0\n") || !strings.Contains(metrics, dropped) { if strings.Contains(metrics, dropped+"0\n") || !strings.Contains(metrics, dropped) {
t.Errorf("no line dropped in the metrics:\n%s", metrics) t.Errorf("no line dropped in the metrics:\n%s", metrics)
} }
@@ -1062,7 +1011,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
runUntilStopped(t, env, func(url string) { runUntilStopped(t, env, func(url string) {
webhook.waitFor(t, "permanent_ban", true) webhook.waitFor(t, "permanent_ban", true)
const ofWebhook = `{destination="webhook",instance="fsn1app1/blog"}` const ofWebhook = `{destination="webhook",instance="fsn1app1/gitea"}`
metrics := metricsWith(t, url+"_smallwebwaf/metrics", token, metrics := metricsWith(t, url+"_smallwebwaf/metrics", token,
"\nsmallwebwaf_alerts_sent_total"+ofWebhook+" 1\n") "\nsmallwebwaf_alerts_sent_total"+ofWebhook+" 1\n")
@@ -1113,13 +1062,13 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
slackPost := slack.firstPost(t) slackPost := slack.firstPost(t)
err := json.Unmarshal([]byte(slackPost.body), &message) err := json.Unmarshal([]byte(slackPost.body), &message)
if err != nil || !strings.HasPrefix(message.Text, "*fsn1app1/blog: ban*\n") || if err != nil || !strings.HasPrefix(message.Text, "*fsn1app1/gitea: ban*\n") ||
!strings.Contains(message.Text, "\nclient: "+client+"\n") { !strings.Contains(message.Text, "\nclient: "+client+"\n") {
t.Errorf("Slack was sent %s", slackPost.body) t.Errorf("Slack was sent %s", slackPost.body)
} }
ntfyPost := ntfy.firstPost(t) ntfyPost := ntfy.firstPost(t)
if ntfyPost.header.Get("Title") != "fsn1app1/blog: ban" || if ntfyPost.header.Get("Title") != "fsn1app1/gitea: ban" ||
ntfyPost.header.Get("Authorization") != "Bearer "+ntfyToken || ntfyPost.header.Get("Authorization") != "Bearer "+ntfyToken ||
!strings.Contains(ntfyPost.body, "\nclient: "+client+"\n") { !strings.Contains(ntfyPost.body, "\nclient: "+client+"\n") {
t.Errorf("ntfy was sent %s, with the headers %v", ntfyPost.body, t.Errorf("ntfy was sent %s, with the headers %v", ntfyPost.body,
@@ -1131,9 +1080,9 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
// test process cannot fail the test. // test process cannot fail the test.
sent := []string{ sent := []string{
"\nsmallwebwaf_alerts_sent_total{destination=\"slack\"," + "\nsmallwebwaf_alerts_sent_total{destination=\"slack\"," +
"instance=\"fsn1app1/blog\"} 1\n", "instance=\"fsn1app1/gitea\"} 1\n",
"\nsmallwebwaf_alerts_sent_total{destination=\"ntfy\"," + "\nsmallwebwaf_alerts_sent_total{destination=\"ntfy\"," +
"instance=\"fsn1app1/blog\"} 1\n", "instance=\"fsn1app1/gitea\"} 1\n",
} }
metrics := metricsWith(t, url+"_smallwebwaf/metrics", token, sent...) metrics := metricsWith(t, url+"_smallwebwaf/metrics", token, sent...)
+3 -3
View File
@@ -143,7 +143,7 @@ const filledAlertsJSON = `{
"waiting": { "waiting": {
"webhook": [ "webhook": [
{ {
"instance": "fsn1app1/blog", "instance": "fsn1app1/gitea",
"time": "2026-10-06T00:00:00Z", "time": "2026-10-06T00:00:00Z",
"event": "ban", "event": "ban",
"client": "203.0.113.9", "client": "203.0.113.9",
@@ -158,7 +158,7 @@ const filledAlertsJSON = `{
"suppressed_repeats": 0 "suppressed_repeats": 0
}, },
{ {
"instance": "fsn1app1/blog", "instance": "fsn1app1/gitea",
"time": "2026-10-06T00:00:00Z", "time": "2026-10-06T00:00:00Z",
"event": "file_error", "event": "file_error",
"client": "", "client": "",
@@ -1721,7 +1721,7 @@ func newParams(dir string) state.Params {
Events: alerts.Events(), Events: alerts.Events(),
Cooldown: 15 * time.Minute, Cooldown: 15 * time.Minute,
MaxPerHour: 2, MaxPerHour: 2,
Instance: "fsn1app1/blog", Instance: "fsn1app1/gitea",
Now: midnight, Now: midnight,
}) })
-249
View File
@@ -1,249 +0,0 @@
# The eighth change: 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 or a branch name. Each exclusion runs with the request, before the
# rules it switches off, those of phase 1 included, so this file is loaded before
# the Core Rule Set's rules. Each comment names a request the rule refused.
# gitea's searches of code, of issues and pull requests, and of commits send the
# text searched for in q, which skips these rules there:
# 930110: ../
# 930120: .gitignore
# 931100: http://127.0.0.1:8080
# 932120: Invoke-WebRequest
# 932130: $(pwd)
# 932140: if (a == b) {
# 932160: /bin/sh
# 932235: `make check`
# 932250: ls -la
# 932260: python3
# 932380: `whoami`
# 933100: <?php echo
# 933130: $_GET[
# 933135: ${VAR}
# 934100: process.env
# 934110: http://127.0.0.1:8080
# 934130: __proto__
# 941100: <script>
# 941110: <script>
# 941160: <img src=
# 941180: document.cookie
# 941390: fetch(
# 942100: SELECT * FROM users WHERE
# 942140: information_schema
# 942190: union select
# 942230: if (a == b) {
# 944100: java.lang.Runtime
# 944110: java.lang.Runtime
# 944130: java.lang.Runtime
SecRule REQUEST_FILENAME "@rx ^/(?:explore/code|issues(?:/search)?|pulls|\
[^/]+/-/code|\
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
commits/(?:branch|tag|commit)/.+/search)|\
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))$" \
"id:900600,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930110;ARGS:q,\
ctl:ruleRemoveTargetById=930120;ARGS:q,\
ctl:ruleRemoveTargetById=931100;ARGS:q,\
ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932130;ARGS:q,\
ctl:ruleRemoveTargetById=932140;ARGS:q,\
ctl:ruleRemoveTargetById=932160;ARGS:q,\
ctl:ruleRemoveTargetById=932235;ARGS:q,\
ctl:ruleRemoveTargetById=932250;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q,\
ctl:ruleRemoveTargetById=932380;ARGS:q,\
ctl:ruleRemoveTargetById=933100;ARGS:q,\
ctl:ruleRemoveTargetById=933130;ARGS:q,\
ctl:ruleRemoveTargetById=933135;ARGS:q,\
ctl:ruleRemoveTargetById=934100;ARGS:q,\
ctl:ruleRemoveTargetById=934110;ARGS:q,\
ctl:ruleRemoveTargetById=934130;ARGS:q,\
ctl:ruleRemoveTargetById=941100;ARGS:q,\
ctl:ruleRemoveTargetById=941110;ARGS:q,\
ctl:ruleRemoveTargetById=941160;ARGS:q,\
ctl:ruleRemoveTargetById=941180;ARGS:q,\
ctl:ruleRemoveTargetById=941390;ARGS:q,\
ctl:ruleRemoveTargetById=942100;ARGS:q,\
ctl:ruleRemoveTargetById=942140;ARGS:q,\
ctl:ruleRemoveTargetById=942190;ARGS:q,\
ctl:ruleRemoveTargetById=942230;ARGS:q,\
ctl:ruleRemoveTargetById=944100;ARGS:q,\
ctl:ruleRemoveTargetById=944110;ARGS:q,\
ctl:ruleRemoveTargetById=944130;ARGS:q"
# A page opened from the results of one of those searches has the search's
# address in Referer, which skips these rules then:
# 941110: <script>
# 941120: onClick={
# 941160: <img src=
# 942100: LIKE '%foo%'
# 944100: java.lang.Runtime
SecRule REQUEST_HEADERS:Referer "@rx ^https?://[^/]+/(?:explore/code|\
issues(?:/search)?|pulls|[^/]+/-/code|\
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
commits/(?:branch|tag|commit)/.+/search)|\
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))\?" \
"id:900601,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=941110;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=941120;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=941160;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=942100;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=944100;REQUEST_HEADERS:Referer"
# gitea's searches of repositories, users, branches, tags and packages send a
# name in q, which skips these rules there:
# 930120: .profile
# 932120: powershell-scripts
# 932260: python3-app
# 944130: org.apache.commons.logging
SecRule REQUEST_FILENAME "@rx ^/(?:explore/repos|user/search_candidates|\
[^/]+/-/packages|[^/]+/[^/]+/(?:branches|tags)|api/v1/repos/search)$" \
"id:900602,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:q,\
ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q,\
ctl:ruleRemoveTargetById=944130;ARGS:q"
# A file's page, raw file and download, its blame, history, edit and delete
# pages, and the API's call for its contents name the file in the path, which
# skips these rules there:
# 920500: notes.txt~
# 934100: process.env.d.ts
# 941130: docs/a b.xhtml
# 944130: java.lang.String.md
SecRule REQUEST_FILENAME \
"@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_delete|contents)/" \
"id:900610,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME,\
ctl:ruleRemoveTargetById=934100;REQUEST_FILENAME,\
ctl:ruleRemoveTargetById=941130;REQUEST_FILENAME,\
ctl:ruleRemoveTargetById=944130;REQUEST_FILENAME"
# The requests below name a file in a query parameter. These rules refused
# them for the file named:
# 930120: .gitignore
# 932120: install-script.sh
# 932130: [draft].md
# 932160: bin/docker-entrypoint
# 932260: docker-compose.yml
# 934100: process.env.d.ts
# 934130: __proto__.json
# 941130: index.xhtml
# 941180: document.write.js
# 942140: information_schema.sql
# 944110: java/lang/Runtime.java
# 944130: java.lang.String.md
# Each request skips the rules that refused it. The fourth change already has
# path and redirect_to skip 930120, 932160 and 932260 on every path.
# A file's commit graph, in file.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
"id:900620,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:file,\
ctl:ruleRemoveTargetById=932120;ARGS:file,\
ctl:ruleRemoveTargetById=932160;ARGS:file,\
ctl:ruleRemoveTargetById=932260;ARGS:file,\
ctl:ruleRemoveTargetById=934100;ARGS:file,\
ctl:ruleRemoveTargetById=934130;ARGS:file,\
ctl:ruleRemoveTargetById=941130;ARGS:file,\
ctl:ruleRemoveTargetById=941180;ARGS:file,\
ctl:ruleRemoveTargetById=942140;ARGS:file,\
ctl:ruleRemoveTargetById=944110;ARGS:file,\
ctl:ruleRemoveTargetById=944130;ARGS:file"
# The API's list of a file's commits, in path.
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/commits$" \
"id:900621,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=932120;ARGS:path,\
ctl:ruleRemoveTargetById=934100;ARGS:path,\
ctl:ruleRemoveTargetById=934130;ARGS:path,\
ctl:ruleRemoveTargetById=941130;ARGS:path,\
ctl:ruleRemoveTargetById=941180;ARGS:path,\
ctl:ruleRemoveTargetById=942140;ARGS:path,\
ctl:ruleRemoveTargetById=944110;ARGS:path,\
ctl:ruleRemoveTargetById=944130;ARGS:path"
# The API's call for the contents of several files, in body, as JSON.
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/file-contents$" \
"id:900622,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:body,\
ctl:ruleRemoveTargetById=932120;ARGS:body,\
ctl:ruleRemoveTargetById=932160;ARGS:body,\
ctl:ruleRemoveTargetById=934100;ARGS:body,\
ctl:ruleRemoveTargetById=934130;ARGS:body,\
ctl:ruleRemoveTargetById=941130;ARGS:body,\
ctl:ruleRemoveTargetById=941180;ARGS:body,\
ctl:ruleRemoveTargetById=942140;ARGS:body,\
ctl:ruleRemoveTargetById=944110;ARGS:body,\
ctl:ruleRemoveTargetById=944130;ARGS:body"
# An issue opened from a template, in template.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/issues/new$" \
"id:900623,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:template,\
ctl:ruleRemoveTargetById=932120;ARGS:template,\
ctl:ruleRemoveTargetById=932130;ARGS:template,\
ctl:ruleRemoveTargetById=932160;ARGS:template,\
ctl:ruleRemoveTargetById=932260;ARGS:template,\
ctl:ruleRemoveTargetById=934100;ARGS:template,\
ctl:ruleRemoveTargetById=934130;ARGS:template,\
ctl:ruleRemoveTargetById=941130;ARGS:template,\
ctl:ruleRemoveTargetById=941180;ARGS:template,\
ctl:ruleRemoveTargetById=942140;ARGS:template,\
ctl:ruleRemoveTargetById=944110;ARGS:template,\
ctl:ruleRemoveTargetById=944130;ARGS:template"
# The sign-in page on the way to a file's edit page, in redirect_to.
SecRule REQUEST_FILENAME "@rx ^/user/login$" \
"id:900624,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=932120;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=932130;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=934100;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=934130;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=941130;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=941180;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=942140;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=944110;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=944130;ARGS:redirect_to"
# An attachment uploaded through the API to a release, an issue or a comment,
# in name.
SecRule REQUEST_FILENAME \
"@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
"id:900625,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:name,\
ctl:ruleRemoveTargetById=932120;ARGS:name,\
ctl:ruleRemoveTargetById=932260;ARGS:name,\
ctl:ruleRemoveTargetById=934100;ARGS:name,\
ctl:ruleRemoveTargetById=934130;ARGS:name,\
ctl:ruleRemoveTargetById=941130;ARGS:name,\
ctl:ruleRemoveTargetById=941180;ARGS:name,\
ctl:ruleRemoveTargetById=942140;ARGS:name,\
ctl:ruleRemoveTargetById=944130;ARGS:name"
# The requests below name a branch or tag in a query parameter, which skips
# the list of command names (932260) there. It refused names such as
# docker-build, python3-migration and feat/docker-support.
# Deleting or restoring a branch, in name.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/branches/(?:delete|restore)$" \
"id:900630,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:name"
# A release started from a tag, in tag.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/releases/new$" \
"id:900631,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:tag"
# A branch protection rule opened for editing, in rule_name.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/settings/branches/edit$" \
"id:900632,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:rule_name"
# A directory listing's last commits, in refSubUrl.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/lastcommit/" \
"id:900633,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:refSubUrl"
# An artifact upload from actions/upload-artifact@v3 sends the header
# Content-Range, which the Core Rule Set refused (920450).
SecRule REQUEST_FILENAME \
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
"id:900640,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
-366
View File
@@ -1,366 +0,0 @@
# Ordinary gitea 1.27 requests that the Core Rule Set must let through at the
# defaults. Each line is a path and query, and may end with one header after a
# space. The tests of internal/waf inspect each as a GET. The Core Rule Set reads
# the query and the headers the same way for any method.
# Searches with ordinary words and symbols.
/owner/repo/search?q=package.json
/owner/repo/search?q=.gitignore
/owner/repo/search?q=docker-compose.yml
/owner/repo/search?q=python3
/owner/repo/search?q=ssh%20key
/owner/repo/search?q=feat%2Fdocker-support
/owner/repo/search?q=ls%20-la
/owner/repo/search?q=sed%20-i
/owner/repo/search?q=%2Fbin%2Fsh
/owner/repo/search?q=%60make%20check%60
/owner/repo/search?q=%60whoami%60
/owner/repo/search?q=fetch%28
/owner/repo/search?q=%24%7BVAR%7D
/owner/repo/search?q=%24%28pwd%29
/owner/repo/search?q=process.env
/owner/repo/search?q=%3Cimg%20src%3D
/owner/repo/search?q=%3Cscript%3E
/owner/repo/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/search?q=union%20select
/owner/repo/search?q=http%3A%2F%2F127.0.0.1%3A8080
/owner/repo/search?q=..%2F
/owner/repo/search?q=%2Fetc%2Fpasswd
/owner/repo/search?q=eval%28
/owner/repo/search?q=chmod%20%2Bx
/owner/repo/search?q=echo%20%24PATH
/owner/repo/search?q=java.lang.Runtime
/owner/repo/search?q=document.cookie
/owner/repo/search?q=alert%281%29
/owner/repo/search?q=__proto__
/owner/repo/search?q=C%3A%5CWindows%5CSystem32
/owner/repo/search?q=Invoke-WebRequest
/owner/repo/search?q=if%20%28a%20%3D%3D%20b%29%20%7B
/owner/repo/search?q=sudo%20apt%20install
/owner/repo/search?q=~%2F.ssh%2Fconfig
/owner/repo/search?q=.htaccess
/owner/repo/search?q=%24_GET%5B
/owner/repo/search?q=%24_POST
/owner/repo/search?q=%24_SERVER%5B%27REQUEST_URI%27%5D
/owner/repo/search?q=%3C%3Fphp%20echo
/owner/repo/search?q=information_schema
/owner/repo/search?q=LIKE%20%27%25foo%25%27
/owner/repo/search?q=onClick%3D%7B
/explore/code?q=SELECT%20%2A%20FROM%20users%20WHERE
/explore/code?q=%3Cimg%20src%3D
/owner/-/code?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/-/code?q=%3Cimg%20src%3D
/owner/repo/issues?type=all&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/issues?type=all&state=open&q=%3Cimg%20src%3D
/owner/repo/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/issues/search?q=%3Cimg%20src%3D
/owner/repo/pulls?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/pulls?q=%3Cimg%20src%3D
/issues?type=your_repositories&q=SELECT%20%2A%20FROM%20users%20WHERE
/issues?type=your_repositories&q=%3Cimg%20src%3D
/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/issues/search?q=%3Cimg%20src%3D
/pulls?type=your_repositories&q=SELECT%20%2A%20FROM%20users%20WHERE
/pulls?type=your_repositories&q=%3Cimg%20src%3D
/owner/repo/commits/branch/main/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/commits/branch/main/search?q=%3Cimg%20src%3D
/api/v1/repos/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/api/v1/repos/issues/search?q=%3Cimg%20src%3D
/api/v1/repos/owner/repo/issues?q=SELECT%20%2A%20FROM%20users%20WHERE
/api/v1/repos/owner/repo/issues?q=%3Cimg%20src%3D
# Searches for the names of repositories, users, branches, tags and packages.
/explore/repos?q=python3-app
/explore/repos?q=.profile
/explore/repos?q=powershell-scripts
/api/v1/repos/search?q=docker-build
/user/search_candidates?q=docker-ci
/owner/repo/branches?q=feat%2Fdocker-support
/owner/repo/tags?q=docker-v1.0
/owner/-/packages?q=python3-requests
/owner/-/packages?q=org.apache.commons.logging
# Pages, raw files and edit pages of files, and the API's call for their
# contents.
/owner/repo/src/branch/main/package.json
/owner/repo/raw/branch/main/package.json
/owner/repo/_edit/main/package.json
/api/v1/repos/owner/repo/contents/package.json
/owner/repo/src/branch/main/.gitignore
/owner/repo/raw/branch/main/.gitignore
/owner/repo/_edit/main/.gitignore
/api/v1/repos/owner/repo/contents/.gitignore
/owner/repo/src/branch/main/docker-compose.yml
/owner/repo/raw/branch/main/docker-compose.yml
/owner/repo/_edit/main/docker-compose.yml
/api/v1/repos/owner/repo/contents/docker-compose.yml
/owner/repo/src/branch/main/Dockerfile
/owner/repo/raw/branch/main/Dockerfile
/owner/repo/_edit/main/Dockerfile
/api/v1/repos/owner/repo/contents/Dockerfile
/owner/repo/src/branch/main/.htaccess
/owner/repo/raw/branch/main/.htaccess
/owner/repo/_edit/main/.htaccess
/api/v1/repos/owner/repo/contents/.htaccess
/owner/repo/src/branch/main/rootfs/etc/passwd
/owner/repo/raw/branch/main/rootfs/etc/passwd
/owner/repo/_edit/main/rootfs/etc/passwd
/api/v1/repos/owner/repo/contents/rootfs/etc/passwd
/owner/repo/src/branch/main/bin/docker-entrypoint
/owner/repo/raw/branch/main/bin/docker-entrypoint
/owner/repo/_edit/main/bin/docker-entrypoint
/api/v1/repos/owner/repo/contents/bin/docker-entrypoint
/owner/repo/src/branch/main/scripts/python3-setup.sh
/owner/repo/raw/branch/main/scripts/python3-setup.sh
/owner/repo/_edit/main/scripts/python3-setup.sh
/api/v1/repos/owner/repo/contents/scripts/python3-setup.sh
/owner/repo/src/branch/main/src/java.base/share/classes/java/lang/Runtime.java
/owner/repo/raw/branch/main/src/java.base/share/classes/java/lang/Runtime.java
/owner/repo/_edit/main/src/java.base/share/classes/java/lang/Runtime.java
/api/v1/repos/owner/repo/contents/src/java.base/share/classes/java/lang/Runtime.java
/owner/repo/src/branch/main/java.lang.String.md
/owner/repo/raw/branch/main/java.lang.String.md
/owner/repo/_edit/main/java.lang.String.md
/api/v1/repos/owner/repo/contents/java.lang.String.md
/owner/repo/src/branch/main/META-INF/services/org.apache.commons.logging.LogFactory
/owner/repo/raw/branch/main/META-INF/services/org.apache.commons.logging.LogFactory
/owner/repo/_edit/main/META-INF/services/org.apache.commons.logging.LogFactory
/api/v1/repos/owner/repo/contents/META-INF/services/org.apache.commons.logging.LogFactory
/owner/repo/src/branch/main/process.env.d.ts
/owner/repo/raw/branch/main/process.env.d.ts
/owner/repo/_edit/main/process.env.d.ts
/api/v1/repos/owner/repo/contents/process.env.d.ts
/owner/repo/src/branch/main/document.write.js
/owner/repo/raw/branch/main/document.write.js
/owner/repo/_edit/main/document.write.js
/api/v1/repos/owner/repo/contents/document.write.js
/owner/repo/src/branch/main/test/fixtures/__proto__.json
/owner/repo/raw/branch/main/test/fixtures/__proto__.json
/owner/repo/_edit/main/test/fixtures/__proto__.json
/api/v1/repos/owner/repo/contents/test/fixtures/__proto__.json
/owner/repo/src/branch/main/install-script.sh
/owner/repo/raw/branch/main/install-script.sh
/owner/repo/_edit/main/install-script.sh
/api/v1/repos/owner/repo/contents/install-script.sh
/owner/repo/src/branch/main/src/backend/catalog/information_schema.sql
/owner/repo/raw/branch/main/src/backend/catalog/information_schema.sql
/owner/repo/_edit/main/src/backend/catalog/information_schema.sql
/api/v1/repos/owner/repo/contents/src/backend/catalog/information_schema.sql
/owner/repo/src/branch/main/docs/a%20b.xhtml
/owner/repo/raw/branch/main/docs/a%20b.xhtml
/owner/repo/_edit/main/docs/a%20b.xhtml
/api/v1/repos/owner/repo/contents/docs/a%20b.xhtml
/owner/repo/src/branch/main/%5Bdraft%5D.md
/owner/repo/raw/branch/main/%5Bdraft%5D.md
/owner/repo/_edit/main/%5Bdraft%5D.md
/api/v1/repos/owner/repo/contents/%5Bdraft%5D.md
/owner/repo/src/branch/main/notes.txt~
/owner/repo/raw/branch/main/notes.txt~
/owner/repo/_edit/main/notes.txt~
/api/v1/repos/owner/repo/contents/notes.txt~
# The download, blame, history and delete pages of the files above whose names
# the Core Rule Set checks in the path.
/owner/repo/media/branch/main/notes.txt~
/owner/repo/blame/branch/main/notes.txt~
/owner/repo/commits/branch/main/notes.txt~
/owner/repo/_delete/main/notes.txt~
/owner/repo/media/branch/main/process.env.d.ts
/owner/repo/blame/branch/main/process.env.d.ts
/owner/repo/commits/branch/main/process.env.d.ts
/owner/repo/_delete/main/process.env.d.ts
/owner/repo/media/branch/main/docs/a%20b.xhtml
/owner/repo/blame/branch/main/docs/a%20b.xhtml
/owner/repo/commits/branch/main/docs/a%20b.xhtml
/owner/repo/_delete/main/docs/a%20b.xhtml
/owner/repo/media/branch/main/java.lang.String.md
/owner/repo/blame/branch/main/java.lang.String.md
/owner/repo/commits/branch/main/java.lang.String.md
/owner/repo/_delete/main/java.lang.String.md
# File names in the query string: the commit graph of a file, the API's lists
# of its commits and of its contents, an issue opened from a template, and the
# sign-in page on the way to its edit page.
/owner/repo/graph?file=package.json
/api/v1/repos/owner/repo/commits?path=package.json
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22package.json%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fpackage.json
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fpackage.json
/owner/repo/graph?file=.gitignore
/api/v1/repos/owner/repo/commits?path=.gitignore
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22.gitignore%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F.gitignore
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F.gitignore
/owner/repo/graph?file=docker-compose.yml
/api/v1/repos/owner/repo/commits?path=docker-compose.yml
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22docker-compose.yml%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fdocker-compose.yml
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fdocker-compose.yml
/owner/repo/graph?file=.htaccess
/api/v1/repos/owner/repo/commits?path=.htaccess
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22.htaccess%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F.htaccess
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F.htaccess
/owner/repo/graph?file=rootfs%2Fetc%2Fpasswd
/api/v1/repos/owner/repo/commits?path=rootfs%2Fetc%2Fpasswd
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22rootfs%2Fetc%2Fpasswd%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Frootfs%2Fetc%2Fpasswd
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Frootfs%2Fetc%2Fpasswd
/owner/repo/graph?file=bin%2Fdocker-entrypoint
/api/v1/repos/owner/repo/commits?path=bin%2Fdocker-entrypoint
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22bin%2Fdocker-entrypoint%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fbin%2Fdocker-entrypoint
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fbin%2Fdocker-entrypoint
/owner/repo/graph?file=scripts%2Fpython3-setup.sh
/api/v1/repos/owner/repo/commits?path=scripts%2Fpython3-setup.sh
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22scripts%2Fpython3-setup.sh%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fscripts%2Fpython3-setup.sh
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fscripts%2Fpython3-setup.sh
/owner/repo/graph?file=src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
/api/v1/repos/owner/repo/commits?path=src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fsrc%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fsrc%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
/owner/repo/graph?file=java.lang.String.md
/api/v1/repos/owner/repo/commits?path=java.lang.String.md
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22java.lang.String.md%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fjava.lang.String.md
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fjava.lang.String.md
/owner/repo/graph?file=META-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
/api/v1/repos/owner/repo/commits?path=META-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22META-INF%2Fservices%2Forg.apache.commons.logging.LogFactory%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2FMETA-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2FMETA-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
/owner/repo/graph?file=process.env.d.ts
/api/v1/repos/owner/repo/commits?path=process.env.d.ts
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22process.env.d.ts%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fprocess.env.d.ts
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fprocess.env.d.ts
/owner/repo/graph?file=document.write.js
/api/v1/repos/owner/repo/commits?path=document.write.js
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22document.write.js%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fdocument.write.js
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fdocument.write.js
/owner/repo/graph?file=test%2Ffixtures%2F__proto__.json
/api/v1/repos/owner/repo/commits?path=test%2Ffixtures%2F__proto__.json
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22test%2Ffixtures%2F__proto__.json%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Ftest%2Ffixtures%2F__proto__.json
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Ftest%2Ffixtures%2F__proto__.json
/owner/repo/graph?file=install-script.sh
/api/v1/repos/owner/repo/commits?path=install-script.sh
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22install-script.sh%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Finstall-script.sh
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Finstall-script.sh
/owner/repo/graph?file=src%2Fbackend%2Fcatalog%2Finformation_schema.sql
/api/v1/repos/owner/repo/commits?path=src%2Fbackend%2Fcatalog%2Finformation_schema.sql
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22src%2Fbackend%2Fcatalog%2Finformation_schema.sql%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fsrc%2Fbackend%2Fcatalog%2Finformation_schema.sql
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fsrc%2Fbackend%2Fcatalog%2Finformation_schema.sql
/owner/repo/graph?file=docs%2Fa%20b.xhtml
/api/v1/repos/owner/repo/commits?path=docs%2Fa%20b.xhtml
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22docs%2Fa%20b.xhtml%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fdocs%2Fa%20b.xhtml
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fdocs%2Fa%20b.xhtml
/owner/repo/graph?file=%5Bdraft%5D.md
/api/v1/repos/owner/repo/commits?path=%5Bdraft%5D.md
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22%5Bdraft%5D.md%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F%5Bdraft%5D.md
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F%5Bdraft%5D.md
# File names of attachments uploaded through the API, to a release, an issue and
# a comment.
/api/v1/repos/owner/repo/releases/1/assets?name=package.json
/api/v1/repos/owner/repo/releases/1/assets?name=.gitignore
/api/v1/repos/owner/repo/releases/1/assets?name=docker-compose.yml
/api/v1/repos/owner/repo/releases/1/assets?name=.htaccess
/api/v1/repos/owner/repo/releases/1/assets?name=docker-entrypoint
/api/v1/repos/owner/repo/releases/1/assets?name=python3-setup.sh
/api/v1/repos/owner/repo/releases/1/assets?name=Runtime.java
/api/v1/repos/owner/repo/releases/1/assets?name=org.apache.commons.logging.LogFactory
/api/v1/repos/owner/repo/releases/1/assets?name=%5Bdraft%5D.md
/api/v1/repos/owner/repo/releases/1/assets?name=notes.txt~
/api/v1/repos/owner/repo/releases/1/assets?name=install-script.sh
/api/v1/repos/owner/repo/issues/1/assets?name=install-script.sh
/api/v1/repos/owner/repo/issues/comments/1/assets?name=install-script.sh
/api/v1/repos/owner/repo/releases/1/assets?name=process.env.d.ts
/api/v1/repos/owner/repo/issues/1/assets?name=process.env.d.ts
/api/v1/repos/owner/repo/issues/comments/1/assets?name=process.env.d.ts
/api/v1/repos/owner/repo/releases/1/assets?name=java.lang.String.md
/api/v1/repos/owner/repo/issues/1/assets?name=java.lang.String.md
/api/v1/repos/owner/repo/issues/comments/1/assets?name=java.lang.String.md
/api/v1/repos/owner/repo/releases/1/assets?name=information_schema.sql
/api/v1/repos/owner/repo/issues/1/assets?name=information_schema.sql
/api/v1/repos/owner/repo/issues/comments/1/assets?name=information_schema.sql
/api/v1/repos/owner/repo/releases/1/assets?name=document.write.js
/api/v1/repos/owner/repo/issues/1/assets?name=document.write.js
/api/v1/repos/owner/repo/issues/comments/1/assets?name=document.write.js
/api/v1/repos/owner/repo/releases/1/assets?name=__proto__.json
/api/v1/repos/owner/repo/issues/1/assets?name=__proto__.json
/api/v1/repos/owner/repo/issues/comments/1/assets?name=__proto__.json
/api/v1/repos/owner/repo/releases/1/assets?name=index.xhtml
/api/v1/repos/owner/repo/issues/1/assets?name=index.xhtml
/api/v1/repos/owner/repo/issues/comments/1/assets?name=index.xhtml
# An artifact upload from actions/upload-artifact@v3, which sends Content-Range.
/api/actions_pipeline/_apis/pipelines/workflows/1/artifacts/9b7c1f3a/upload?itemPath=artifact%2Freport.txt Content-Range: bytes 0-9/10
# Branch and tag names in the query string: deleting and restoring a branch,
# starting a release from a tag, a branch protection rule, a directory listing's
# last commits, and a container image push that reuses a layer.
/owner/repo/branches/delete?name=docker-build&page=1
/owner/repo/branches/restore?branch_id=1&name=docker-build&page=1
/owner/repo/releases/new?tag=docker-build
/owner/repo/settings/branches/edit?rule_name=docker-build
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fdocker-build
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fdocker-build
/owner/repo/branches/delete?name=feat%2Fdocker-support&page=1
/owner/repo/branches/restore?branch_id=1&name=feat%2Fdocker-support&page=1
/owner/repo/releases/new?tag=feat%2Fdocker-support
/owner/repo/settings/branches/edit?rule_name=feat%2Fdocker-support
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Ffeat%2Fdocker-support
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Ffeat%2Fdocker-support
/owner/repo/branches/delete?name=renovate%2Fdocker-build-push-action-6.x&page=1
/owner/repo/branches/restore?branch_id=1&name=renovate%2Fdocker-build-push-action-6.x&page=1
/owner/repo/releases/new?tag=renovate%2Fdocker-build-push-action-6.x
/owner/repo/settings/branches/edit?rule_name=renovate%2Fdocker-build-push-action-6.x
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Frenovate%2Fdocker-build-push-action-6.x
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Frenovate%2Fdocker-build-push-action-6.x
/owner/repo/branches/delete?name=python3-migration&page=1
/owner/repo/branches/restore?branch_id=1&name=python3-migration&page=1
/owner/repo/releases/new?tag=python3-migration
/owner/repo/settings/branches/edit?rule_name=python3-migration
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fpython3-migration
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fpython3-migration
/owner/repo/branches/delete?name=ansible-roles&page=1
/owner/repo/branches/restore?branch_id=1&name=ansible-roles&page=1
/owner/repo/releases/new?tag=ansible-roles
/owner/repo/settings/branches/edit?rule_name=ansible-roles
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fansible-roles
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fansible-roles
/owner/repo/branches/delete?name=whoami&page=1
/owner/repo/branches/restore?branch_id=1&name=whoami&page=1
/owner/repo/releases/new?tag=whoami
/owner/repo/settings/branches/edit?rule_name=whoami
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fwhoami
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fwhoami
# Pages a signed-in user opens.
/
/owner/repo
/owner/repo/issues
/owner/repo/pulls
/owner/repo/releases
/owner/repo/wiki
/owner/repo/activity
/owner/repo/branches
/owner/repo/tags
/owner/repo/settings
/owner/repo/actions
/owner/repo/compare/main...main
/owner/repo/commits/branch/main
/owner/repo/archive/main.zip
/notifications
/user/settings
/user/settings/applications
/explore/repos
/-/admin
+15 -153
View File
@@ -1,9 +1,8 @@
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the // Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
// method, the URL with its query and the headers of a request, and on its // method, the URL with its query and the headers of a request, and on its
// body while SWWAF_WAF_BODY_LIMIT is set, with the eight changes smallwebwaf // body while SWWAF_WAF_BODY_LIMIT is set, with the six changes smallwebwaf
// makes to it and the exclusions in SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE, as // makes to it, as "Attack detection" under "Settings" in SPEC.md describes
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no // them. It reads no response.
// response.
// //
// smallwebwaf writes only to its state directory, so Coraza is built with // smallwebwaf writes only to its state directory, so Coraza is built with
// its no_fs_access tag, as the Dockerfile and script/build build it: of a // its no_fs_access tag, as the Dockerfile and script/build build it: of a
@@ -12,14 +11,10 @@
package waf package waf
import ( import (
_ "embed"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/netip" "net/netip"
"os"
"regexp"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -31,14 +26,12 @@ import (
) )
// directives are the Core Rule Set as smallwebwaf runs it, with the // directives are the Core Rule Set as smallwebwaf runs it, with the
// paranoia level for %d, bodyDirectives for the first %s while // paranoia level for %d, and bodyDirectives for %s while
// SWWAF_WAF_BODY_LIMIT is set, the exclusions file for the second, and // SWWAF_WAF_BODY_LIMIT is set. Each rule smallwebwaf adds has an id from
// giteaExclusions for the third. // 900000 to 900999, the ids the Core Rule Set keeps for the rules that set
// Each rule smallwebwaf adds has an id from 900000 to 900999, the ids the // it up, which SWWAF_WAF_DISABLED_RULES refuses, so that no setting
// Core Rule Set keeps for the rules that set it up, which // switches one off. Coraza joins a line ending in \ to the next, without
// SWWAF_WAF_DISABLED_RULES and the exclusions file refuse, so that no // the spaces at the start of the next.
// setting switches one off. Coraza joins a line ending in \ to the next,
// without the spaces at the start of the next.
const directives = ` const directives = `
# The engine only detects. smallwebwaf compares the request's anomaly # The engine only detects. smallwebwaf compares the request's anomaly
# score with SWWAF_WAF_ANOMALY_THRESHOLD itself, in block and detect mode # score with SWWAF_WAF_ANOMALY_THRESHOLD itself, in block and detect mode
@@ -76,14 +69,6 @@ SecArgumentsLimit 1000
SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'" severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
# The exclusions file comes before the rules, where the Core Rule Set puts
# its own file of exclusions that run before the rules, so that they run
# before the rules of phase 1 too.
%s
# The eighth change comes before the rules for the same reason.
%s
# The sixth: only the rules for requests are loaded, and no response is # The sixth: only the rules for requests are loaded, and no response is
# inspected. # inspected.
Include @owasp_crs/REQUEST-*.conf Include @owasp_crs/REQUEST-*.conf
@@ -111,19 +96,10 @@ SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:artifactName|!ARGS:redirect_to" !ARGS:artifactName|!ARGS:redirect_to"
# The fifth, for Referer: it is not checked for a Unix command without # The fifth, for Referer: it is not checked for a Unix command without
# arguments, for Java starting a process, or for a Java class name. 944130 # arguments, or for Java starting a process. The cookies are left out in
# refused every page opened from the page of java.lang.String.md. The # Inspect.
# cookies are left out in Inspect.
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
# The seventh: a container image push that reuses a layer names the image
# it takes the layer from in the query parameter from, which is not
# checked for command names. 932260 refused the push of a layer from
# owner/python3-app.
SecRule REQUEST_FILENAME "@rx ^/v2/.+/blobs/uploads/$" \
"id:900500,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:from"
` `
// bodyDirectives have the Core Rule Set read the part of a request body // bodyDirectives have the Core Rule Set read the part of a request body
@@ -168,13 +144,6 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'" severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
` `
// giteaExclusions are the eighth change: the fields of gitea's own
// requests that skip the rules that refused them there, as Coraza
// directives.
//
//go:embed gitea.conf
var giteaExclusions string
// cookiesNotRead are the cookies the Core Rule Set reads a request // cookiesNotRead are the cookies the Core Rule Set reads a request
// without, the rest of the fifth change. // without, the rest of the fifth change.
// //
@@ -191,94 +160,6 @@ type Params struct {
// BodyLimit is the most of a request body the Core Rule Set reads // BodyLimit is the most of a request body the Core Rule Set reads
// (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none. // (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none.
BodyLimit int64 BodyLimit int64
// ExclusionsFile is the path of a file of exclusions loaded before the
// Core Rule Set's rules (SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
// there is none.
ExclusionsFile string
}
// The ids of smallwebwaf's own rules, which an exclusions file may not
// name.
const (
firstOwnRuleID = 900000
lastOwnRuleID = 900999
)
// numberOrRange matches a number, or a range of numbers such as
// 900000-900999. Coraza reads a number with a + before it too, so the
// end of a range may have one.
var numberOrRange = regexp.MustCompile(`(\d+)(?:-\+?(\d+))?`)
// errSmallwebwafRuleID is the error of an exclusions file that names an
// id of smallwebwaf's own rules.
var errSmallwebwafRuleID = errors.New("an id of smallwebwaf's own rules")
// errChangesNoRule is the error of an exclusions file that holds a
// directive that removes or changes rules as it loads, such as
// SecRuleRemoveById. The Core Rule Set's rules load after the file, so
// the directive finds none of them.
var errChangesNoRule = errors.New("the Core Rule Set's rules load after the file; " +
"switch a rule off with SWWAF_WAF_DISABLED_RULES, or on some requests " +
"with a ctl exclusion")
// directivesIn returns the directives in text as Coraza reads them: it
// skips blank lines and comment lines, and joins a line ending in \ to
// the next, without the spaces around either.
func directivesIn(text string) []string {
var directives []string
joined := ""
for line := range strings.Lines(text) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if start, found := strings.CutSuffix(line, `\`); found {
joined += start
continue
}
directives = append(directives, joined+line)
joined = ""
}
return directives
}
// checkExclusions returns an error if exclusions, the text of an
// exclusions file, names an id of smallwebwaf's own rules, alone or in a
// range, or holds a directive whose name starts with SecRuleRemove or
// SecRuleUpdate.
func checkExclusions(exclusions string) error {
for _, directive := range directivesIn(exclusions) {
for _, match := range numberOrRange.FindAllStringSubmatch(directive, -1) {
first, _ := strconv.Atoi(match[1])
last := first
if match[2] != "" {
last, _ = strconv.Atoi(match[2])
}
if first <= lastOwnRuleID && last >= firstOwnRuleID {
return fmt.Errorf("the exclusions file names %s: %w", match[0],
errSmallwebwafRuleID)
}
}
// Coraza takes the directive's name up to the first space, without
// regard to case.
name, _, _ := strings.Cut(directive, " ")
if lower := strings.ToLower(name); strings.HasPrefix(lower, "secruleremove") ||
strings.HasPrefix(lower, "secruleupdate") {
return fmt.Errorf("%s in the exclusions file changes no rule: %w", name,
errChangesNoRule)
}
}
return nil
} }
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe // CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
@@ -288,35 +169,16 @@ type CoreRuleSet struct {
bodyLimit int64 bodyLimit int64
} }
// New returns the Core Rule Set with the eight changes, at params' // New returns the Core Rule Set with the six changes, at params'
// paranoia level, without the rules it switches off, reading request // paranoia level, without the rules it switches off, and reading request
// bodies up to params' limit, and with params' exclusions file loaded // bodies up to params' limit.
// before the Core Rule Set's rules. An exclusions file is an error 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 {
body = fmt.Sprintf(bodyDirectives, params.BodyLimit) body = fmt.Sprintf(bodyDirectives, params.BodyLimit)
} }
exclusions := "" text := fmt.Sprintf(directives, params.ParanoiaLevel, body)
if params.ExclusionsFile != "" {
file, err := os.ReadFile(params.ExclusionsFile)
if err != nil {
return nil, fmt.Errorf("read the exclusions file: %w", err)
}
exclusions = string(file)
err = checkExclusions(exclusions)
if err != nil {
return nil, err
}
}
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
giteaExclusions)
if len(params.DisabledRules) > 0 { if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules)) ids := make([]string, len(params.DisabledRules))
+2 -190
View File
@@ -5,7 +5,6 @@ import (
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"net/url" "net/url"
"os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strconv" "strconv"
@@ -120,7 +119,7 @@ func atDefaults(t *testing.T) *waf.CoreRuleSet {
} }
// wantChange checks that crs lets through passes, a gitea request one of // wantChange checks that crs lets through passes, a gitea request one of
// the eight changes is for, and still finds result in refused, a request // the six changes is for, and still finds result in refused, a request
// like it that the change is not for. // like it that the change is not for.
func wantChange( func wantChange(
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result, t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
@@ -309,14 +308,13 @@ func TestCookiesGiteaFlashAndRedirectToAreNotRead(t *testing.T) {
matched(930120)) matched(930120))
} }
func TestRefererIsNotCheckedForACommandOrJava(t *testing.T) { func TestRefererIsNotCheckedForACommandOrJavaStartingAProcess(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
search = "https://git.example/explore/repos?q=env" search = "https://git.example/explore/repos?q=env"
runtimeJava = "https://git.example/openjdk/jdk/src/branch/master/src/" + runtimeJava = "https://git.example/openjdk/jdk/src/branch/master/src/" +
"java.base/share/classes/java/lang/Runtime.java" "java.base/share/classes/java/lang/Runtime.java"
stringMD = "https://git.example/owner/repo/src/branch/main/java.lang.String.md"
) )
crs := atDefaults(t) crs := atDefaults(t)
@@ -325,8 +323,6 @@ func TestRefererIsNotCheckedForACommandOrJava(t *testing.T) {
matched(932340)) matched(932340))
wantChange(t, crs, get("/", "Referer: "+runtimeJava), wantChange(t, crs, get("/", "Referer: "+runtimeJava),
get("/", "X-Page: "+runtimeJava), matched(944110)) get("/", "X-Page: "+runtimeJava), matched(944110))
wantChange(t, crs, get("/", "Referer: "+stringMD), get("/", "X-Page: "+stringMD),
matched(944130))
// It is still checked for script and SQL injection. // It is still checked for script and SQL injection.
wantResult(t, crs, wantResult(t, crs,
@@ -366,190 +362,6 @@ func TestEachDisabledRuleIsSwitchedOff(t *testing.T) {
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{}) wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{})
} }
func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
reuse := "?mount=sha256:0000&from=owner/python3-app"
wantChange(t, crs, get("/v2/owner/app/blobs/uploads/"+reuse), get("/upload/"+reuse),
matched(932260))
// A command after the image's name is still refused.
wantResult(t, crs,
get("/v2/owner/app/blobs/uploads/?from=owner/app;cat%20/etc/passwd"),
matched(930120, 932160))
}
// withExclusions returns the Core Rule Set at the defaults, with the
// exclusions file at path.
func withExclusions(path string) (*waf.CoreRuleSet, error) {
return waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path,
})
}
// exclusionsFile writes text to a file of exclusions and returns its path.
func exclusionsFile(t *testing.T, text string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "exclusions.conf")
err := os.WriteFile(path, []byte(text), 0o600)
if err != nil {
t.Fatal(err)
}
return path
}
// backupFileExclusion is an exclusions file that switches 920500, a rule
// of phase 1, off for the path of a backup file under /files/.
const backupFileExclusion = `SecRule REQUEST_FILENAME "@beginsWith /files/" ` +
`"id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME"` + "\n"
func TestExclusionsFileIsLoadedBeforeTheRules(t *testing.T) {
t.Parallel()
crs, err := withExclusions(exclusionsFile(t, backupFileExclusion))
if err != nil {
t.Fatalf("load the exclusions: %v", err)
}
wantChange(t, crs, get("/files/notes.txt~"), get("/notes.txt~"), matched(920500))
}
func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
t.Parallel()
crs, err := waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: []int{920500},
ExclusionsFile: exclusionsFile(t, backupFileExclusion),
})
if err != nil {
t.Fatalf("load the exclusions: %v", err)
}
wantResult(t, crs, get("/notes.txt~"), waf.Result{})
}
func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) {
t.Parallel()
_, err := withExclusions(exclusionsFile(t,
"# 942100: GET /issues/900123\n"+backupFileExclusion))
if err != nil {
t.Errorf("the exclusions did not load: %v", err)
}
}
func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) {
t.Parallel()
missing := filepath.Join(t.TempDir(), "missing.conf")
_, err := withExclusions(missing)
if err == nil {
t.Errorf("%s loaded", missing)
}
for _, tc := range []struct{ text, want string }{
{"SecNoSuchDirective On\n", "unknown directive"},
// smallwebwaf's own rules may not be switched off or changed, by
// id or in a range, on one line or split over several.
{"SecRuleRemoveById 899999-901000\n", "names 899999-901000"},
{
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-901000\"\n",
"names 899999-901000",
},
{
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-+901000\"\n",
"names 899999-+901000",
},
{
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=900500;ARGS:from\"\n",
"names 900500",
},
{"SecRuleRemoveById 899000-\\\n 901000\n", "names 899000-901000"},
{
"SecAction \"id:1,phase:1,pass,nolog,\\\n" +
" ctl:ruleRemoveById=9005\\\n 00\"\n",
"names 900500",
},
// The Core Rule Set's rules load after the file.
{"SecRuleRemoveById 942100\n", "SecRuleRemoveById in the exclusions file"},
{
"SecRuleUpdateTargetById 942100 \"!ARGS:id\"\n",
"SecRuleUpdateTargetById in the exclusions file",
},
} {
_, err := withExclusions(exclusionsFile(t, tc.text))
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%q gave the error %v, want one with %q", tc.text, err, tc.want)
}
}
}
// giteaRequests returns the recorded ordinary gitea requests in testdata.
// Each has the Referer of a page that names the same, as a page opened
// from it has.
func giteaRequests(t *testing.T) []request {
t.Helper()
text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt"))
if err != nil {
t.Fatal(err)
}
var requests []request
for line := range strings.Lines(string(text)) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
target, header, _ := strings.Cut(line, " ")
headers := []string{"Referer: http://git.example" + target}
if header != "" {
headers = append(headers, header)
}
requests = append(requests, get(target, headers...))
}
return requests
}
func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWWAF_WAF_ANOMALY_THRESHOLD's default
crs := atDefaults(t)
for _, r := range giteaRequests(t) {
if got := inspect(t, crs, r); got.Score >= threshold {
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
}
}
// In q too, off gitea's search pages.
for _, parameter := range []string{"id", "q"} {
for _, attack := range []string{
"1'%20OR%20'1'='1",
"..%2Fcustom%2Fconf%2Fapp.ini",
"%3Cscript%3Ealert(1)%3C%2Fscript%3E",
} {
target := "/owner/repo?" + parameter + "=" + attack
if got := inspect(t, crs, get(target)); got.Score < threshold {
t.Errorf("%s let through: %+v", target, got)
}
}
}
}
// bodyLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies. // bodyLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies.
const bodyLimit = 8 << 10 const bodyLimit = 8 << 10
+350
View File
@@ -0,0 +1,350 @@
#!/bin/sh
# script/gitea-requests: build the image and run gitea 1.27.3 behind rproxy
# and servewaf, three containers on one network. Every setting is at its
# default but these: SWRP_UPSTREAM_URL names gitea's container, SWRP_WAF_BACKEND
# names servewaf's, and SWWAF_LOG_REQUEST_HEADERS adds the header that labels a
# request. Then send through rproxy the gitea requests that reading gitea's
# source found the Core Rule Set refuses, which docs/gitea.md records, and a
# short sample of gitea's ordinary use: signing in, pages of files and commits,
# a diff, the API, a push, clone and fetch over HTTP, and two container image
# pushes. Last, print each request servewaf logged, with what it did with it and
# the ids of the Core Rule Set's rules that matched it.
# Every request comes from one client, docker's gateway, so more than
# SWWAF_ERROR_BURST_THRESHOLD, 30, refusals in a minute would ban it, and
# the requests after would show as banned. The containers, the network,
# the images and the files the script makes are removed however it ends;
# gitea's own image stays, as the base images of a build do. It needs
# network access, and a docker daemon that pushes to a registry on
# localhost over plain HTTP, as docker does by default. script/check does
# not run this.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
# gitea 1.27.3, 2026-08-29
GITEA_IMAGE="gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef"
# Named after this run, so that runs in other clones on the same host
# never touch each other's.
NAME="$("$SCRIPT_DIR/projectname")-gitea-$$"
IMAGE="$NAME-image"
NETWORK="$NAME"
RPROXY="$NAME-rproxy"
SERVEWAF="$NAME-servewaf"
GITEA="$NAME-gitea"
# gitea's one user, who owns the repository and the container images.
PASSWORD="owner-password"
BROWSER="Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0"
# The header that labels a request in the report. rproxy makes every request's
# id itself, so a label cannot be the X-Request-ID.
LABEL_HEADER="X-Case"
# The default of SWWAF_LOG_REQUEST_HEADERS, and the header above.
LOGGED_HEADERS="accept,accept-language,accept-encoding,content-type,origin,range,x-case"
# The files the script makes, and rproxy's address once it runs, localhost and
# the port docker publishes it on: a host name, since the Core Rule Set takes a
# Host that is an IP address for a warning (920350).
WORK=""
address=""
cleanup() {
# So that a signal cannot stop it partway through.
trap '' HUP INT TERM
docker rm --force --volumes "$RPROXY" "$SERVEWAF" "$GITEA" >/dev/null 2>&1 || true
docker network rm "$NETWORK" >/dev/null 2>&1 || true
docker rmi --force "$IMAGE" >/dev/null 2>&1 || true
if [ -n "$address" ]; then
docker rmi --force "$address/owner/python3-app:1" "$address/owner/app:1" \
>/dev/null 2>&1 || true
fi
rm -rf "$WORK"
}
fail() {
echo "gitea-requests: $*" >&2
report
exit 1
}
# wait_for <what fails> <command>...: run the command every second until
# it succeeds, for at most a minute.
wait_for() {
failure="$1"
shift
tries=0
until "$@"; do
tries=$((tries + 1))
[ "$tries" -lt 60 ] || fail "$failure"
sleep 1
done
}
# healthy <container>: docker says the container's health check passes.
healthy() {
status="$(docker inspect --format '{{.State.Health.Status}}' "$1")"
[ "$status" = healthy ]
}
# logged <text>...: a line of servewaf's output holds every text, in any order.
logged() {
lines="$(docker logs "$SERVEWAF" 2>&1)"
for text in "$@"; do
lines="$(printf '%s\n' "$lines" | grep -F "$text")" || return 1
done
}
# send <label> <path> <curl option>...: send a request for path to rproxy, with
# label in LABEL_HEADER, which the report gives, and keep the body of the answer
# in $WORK/answer. The Core Rule Set reads that header too, so a label names no
# file the cases name.
send() {
label="$1"
path="$2"
shift 2
curl --silent --show-error --max-time 30 --output "$WORK/answer" \
--header "$LABEL_HEADER: $label" "$@" "http://$address$path"
}
# page <label> <path> <curl option>...: send the request as a browser
# does, with the cookies gitea has set. Its Referer is the repository's
# page, which links to the others, and never a page the Core Rule Set
# refused: its address would have the next request refused too.
page() {
label="$1"
path="$2"
shift 2
send "$label" "$path" --compressed --user-agent "$BROWSER" \
--header "Accept: text/html,application/xhtml+xml,*/*;q=0.8" \
--header "Accept-Language: en-US,en;q=0.5" \
--cookie "$WORK/cookies" --cookie-jar "$WORK/cookies" \
--referer "http://$address/owner/repo" "$@"
}
# api <label> <path> <curl option>...: send the request as a client of
# gitea's API does, as the owner.
api() {
label="$1"
path="$2"
shift 2
send "$label" "$path" --user "owner:$PASSWORD" \
--header "Accept: application/json" "$@"
}
# repo_git <git argument>...: run git as the owner, without the host's
# own git settings, so that nothing it does is kept outside $WORK.
repo_git() {
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 git \
-c user.name=owner -c user.email=owner@example.invalid "$@"
}
# lines: forty lines of text, enough for a diff to hide some of them.
lines() {
i=1
while [ "$i" -le 40 ]; do
echo "line $i"
i=$((i + 1))
done
}
# make_repository: create the repository owner/repo, holding a file of
# each name the cases name, and push it, which is the push over HTTP of
# the sample. The second commit adds a line at the end of Runtime.java,
# so that its diff hides the lines above.
make_repository() {
status="$(api create-repository /api/v1/user/repos \
--header "Content-Type: application/json" --data '{"name":"repo"}' \
--write-out '%{http_code}')"
[ "$status" = 201 ] || fail "creating the repository was answered $status"
dir="$WORK/repo"
repo_git init --quiet --initial-branch=main "$dir"
for file in \
src/java.base/share/classes/java/lang/Runtime.java \
java.lang.String.md \
META-INF/services/org.apache.commons.logging.LogFactory \
document.write.js \
process.env.d.ts \
test/fixtures/__proto__.json \
install-script.sh \
src/backend/catalog/information_schema.sql \
.../notes.txt \
README.md; do
mkdir -p "$dir/$(dirname "$file")"
lines >"$dir/$file"
repo_git -C "$dir" add "$file"
done
repo_git -C "$dir" commit --quiet --message "Add the files"
echo "line 41" >>"$dir/src/java.base/share/classes/java/lang/Runtime.java"
repo_git -C "$dir" commit --quiet --all --message "Add a line"
repo_git -C "$dir" -c "http.extraHeader=$LABEL_HEADER: git-push" push --quiet \
"http://owner:$PASSWORD@$address/owner/repo.git" main ||
fail "the push of the repository failed"
}
# browse: sign in, then open pages as a person does. The cases are those
# of https://git.eeqj.de/sneak/smallwebwaf/issues/30.
browse() {
# Case: gitea sends a visitor who is not signed in to the sign-in
# page, with the page asked for in redirect_to, when that page needs
# signing in, as a file's edit page does.
page issue-30-case /owner/repo/_edit/main/document.write.js --location
page sign-in /user/login
status="$(page sign-in /user/login --header "Origin: http://$address" \
--data-urlencode user_name=owner --data-urlencode "password=$PASSWORD" \
--write-out '%{http_code}')"
[ "$status" = 303 ] || fail "signing in was answered $status"
page browse /
page browse /owner/repo
page browse /owner/repo/src/branch/main/README.md
page browse /owner/repo/commits/branch/main
page browse "/owner/repo/commit/$(repo_git -C "$WORK/repo" rev-parse HEAD)"
# Case: expanding the lines the diff hides, by the link the diff's
# page gives for it.
excerpt="$(sed -n 's/.*data-fetch-url="\([^"]*blob_excerpt[^"]*\)".*/\1/p' \
"$WORK/answer" | head -n 1 | sed 's/&amp;/\&/g')"
[ -n "$excerpt" ] || fail "the diff's page has no link to its hidden lines"
page issue-30-case "$excerpt"
# Cases: the commit graph of one file, and the pages of files whose
# path the Core Rule Set takes for an attack.
page issue-30-case "/owner/repo/graph?file=src/java.base/share/classes/java/lang/Runtime.java"
page issue-30-case /owner/repo/src/branch/main/META-INF/services/org.apache.commons.logging.LogFactory
page issue-30-case /owner/repo/src/branch/main/java.lang.String.md
page issue-30-case /owner/repo/src/branch/main/process.env.d.ts
page issue-30-case /owner/repo/src/branch/main/.../notes.txt
}
# call_api: the API's calls the cases name: the commits of a file, for a
# file of each name the Core Rule Set takes for an attack in the query
# parameter path, and the file-contents call, which takes its list of
# files as JSON in the query parameter body.
call_api() {
for file in \
src/java.base/share/classes/java/lang/Runtime.java \
java.lang.String.md \
document.write.js \
process.env.d.ts \
test/fixtures/__proto__.json \
install-script.sh \
src/backend/catalog/information_schema.sql; do
api issue-30-case /api/v1/repos/owner/repo/commits --get --data-urlencode "path=$file"
done
api issue-30-case /api/v1/repos/owner/repo/file-contents --get \
--data-urlencode 'body={"files":["src/java.base/share/classes/java/lang/Runtime.java"]}'
}
# push_images: push a container image to owner/python3-app, then the same
# image to owner/app. For the second, the case, docker asks gitea to take
# its layer from owner/python3-app, naming it in the query parameter
# from. docker sends no label, so the report gives its requests none. The image
# is one small file, imported rather than built.
push_images() {
echo "$NAME" >"$WORK/layer.txt"
tar -cf - -C "$WORK" layer.txt |
docker import - "$address/owner/python3-app:1" >/dev/null
docker tag "$address/owner/python3-app:1" "$address/owner/app:1"
# The login is written here rather than made with docker login, which
# would keep it in the host's credential helper when it has one.
mkdir "$WORK/docker"
printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$address" \
"$(printf 'owner:%s' "$PASSWORD" | base64)" >"$WORK/docker/config.json"
for image in "$address/owner/python3-app:1" "$address/owner/app:1"; do
DOCKER_CONFIG="$WORK/docker" docker push --quiet "$image" >/dev/null ||
echo "gitea-requests: the push of $image failed" >&2
done
}
# clone_and_fetch: the clone and fetch over HTTP of the sample.
clone_and_fetch() {
repo_git -c "http.extraHeader=$LABEL_HEADER: git-clone" clone --quiet \
"http://owner:$PASSWORD@$address/owner/repo.git" "$WORK/clone" ||
fail "the clone failed"
repo_git -C "$WORK/clone" -c "http.extraHeader=$LABEL_HEADER: git-fetch" fetch --quiet ||
fail "the fetch failed"
}
# field <name> <line>: the text of the field name in line, a line of the
# request log, or nothing when it has none.
field() {
printf '%s\n' "$2" | sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p"
}
# report: print a line for each request rproxy reported to servewaf: what
# servewaf did with the request, the ids of the Core Rule Set's rules that
# matched it, its label, and the request. waf_blocked is a refusal by the Core
# Rule Set, and forward a request passed on to gitea, whatever gitea answered.
report() {
docker logs "$SERVEWAF" 2>/dev/null | grep '"servewaf_line":"report"' |
sed 's/\\u0026/\&/g' >"$WORK/requests"
while IFS= read -r line; do
ids="$(printf '%s\n' "$line" | sed -n 's/.*"waf_rule_ids":\[\([0-9,]*\)\].*/\1/p')"
query="$(field query "$line")"
label="$(field x-case "$line")"
printf '%-11s %-13s %-17s %s %s%s\n' "$(field action "$line")" "${ids:--}" \
"${label:--}" "$(field method "$line")" \
"$(field path "$line")" "${query:+?$query}"
done <"$WORK/requests"
echo "gitea-requests: $(grep -c . "$WORK/requests") requests," \
"$(grep -c '"action":"waf_blocked"' "$WORK/requests") refused by the Core Rule Set"
}
main() {
cd "$ROOT"
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
WORK="$(mktemp -d)"
docker build --no-cache -t "$IMAGE" .
# rproxy first, so that gitea can be given the address it is reached at,
# and gitea's image pulled before, so that gitea starts within the minute
# rproxy's health check waits for it.
docker pull --quiet "$GITEA_IMAGE" >/dev/null
docker network create "$NETWORK" >/dev/null
docker run --detach --name "$SERVEWAF" --network "$NETWORK" \
--env SMALLWEBWAF_COMMAND=servewaf \
--env SWWAF_LOG_REQUEST_HEADERS="$LOGGED_HEADERS" \
"$IMAGE" >/dev/null
wait_for "servewaf's health check did not pass" healthy "$SERVEWAF"
docker run --detach --name "$RPROXY" --network "$NETWORK" \
--publish 127.0.0.1::8080 \
--env SWRP_UPSTREAM_URL="http://$GITEA:3000" \
--env SWRP_WAF_BACKEND="http://$SERVEWAF:8080" \
"$IMAGE" >/dev/null
published="$(docker port "$RPROXY" 8080/tcp)"
address="localhost:${published##*:}"
docker run --detach --name "$GITEA" --network "$NETWORK" \
--env GITEA__security__INSTALL_LOCK=true \
--env GITEA__server__ROOT_URL="http://$address/" \
"$GITEA_IMAGE" >/dev/null
# rproxy's health check passes once gitea takes connections.
wait_for "rproxy's health check did not pass" healthy "$RPROXY"
docker exec --user git "$GITEA" gitea admin user create --admin \
--username owner --password "$PASSWORD" --email owner@example.invalid \
--must-change-password=false >/dev/null
make_repository
browse
call_api
push_images
clone_and_fetch
# The fetch's last request is the last of all; once it is logged, so
# is every other.
wait_for "servewaf logged no report of the fetch" \
logged '"x-case":"git-fetch"' '"method":"POST"' '"servewaf_line":"report"'
report
}
main "$@"
+216
View File
@@ -0,0 +1,216 @@
#!/bin/sh
# script/gitea-setup: build the image, bring up deploy/gitea under a project
# name of its own, and check it:
# - that a page load, a push and a clone sent through traefik pass through
# rproxy, and that servewaf checks them;
# - that gitea logs the visitor's address, not rproxy's;
# - that a request from a container on the shared network skips the checks;
# - that with servewaf stopped, gitea still answers through rproxy.
# It publishes no port of traefik's, since this host's ports 80 and 443 may be
# taken. It reaches traefik at its address on the shared network instead, so
# its own requests come from the network's gateway, which servewaf checks like
# any visitor. Two runs at once on one docker host cannot both start, since
# both would use the subnet compose.yaml names. The containers, the network,
# the volumes, the image and the files the script makes are removed however it
# ends; the gitea and traefik images stay, as the base images of a build do.
# It needs network access. script/check does not run this.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
# Named after this run, so that runs in other clones on the same host
# never touch each other's.
NAME="$("$SCRIPT_DIR/projectname")-gitea-setup-$$"
IMAGE="$NAME-image"
NETWORK="${NAME}_shared"
VISITOR="$NAME-visitor"
# The site's host name and gitea's image, as deploy/gitea/compose.yaml names
# them. gitea's image also runs the container on the shared network.
SITE="git.example.org"
# gitea 1.27.3, 2026-08-29
GITEA_IMAGE="gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef"
# gitea's one user, who owns the repository.
PASSWORD="owner-password"
# The files the script makes, gitea's secret key for this run, and traefik's
# and the gateway's addresses on the shared network once the setup runs.
WORK=""
secret_key=""
traefik=""
gateway=""
cleanup() {
# So that a signal cannot stop it partway through.
trap '' HUP INT TERM
docker rm --force "$VISITOR" >/dev/null 2>&1 || true
if [ -n "$WORK" ]; then
compose down --volumes --remove-orphans --timeout 5 >/dev/null 2>&1 || true
rm -rf "$WORK"
fi
docker rmi --force "$IMAGE" >/dev/null 2>&1 || true
}
# compose <argument>...: docker compose on the setup, under this run's project
# name, with the image built here, this run's secret key and no port of
# traefik's published.
compose() {
SMALLWEBWAF_IMAGE="$IMAGE" GITEA_SECRET_KEY="$secret_key" \
docker compose --project-name "$NAME" \
--file "$ROOT/deploy/gitea/compose.yaml" --file "$WORK/no-ports.yaml" "$@"
}
fail() {
echo "gitea-setup: $*; the last lines of each container's output:" >&2
compose logs --no-color --tail 30 >&2 || true
exit 1
}
# wait_for <what fails> <command>...: run the command every second until
# it succeeds, for at most a minute.
wait_for() {
failure="$1"
shift
tries=0
until "$@"; do
tries=$((tries + 1))
[ "$tries" -lt 60 ] || fail "$failure"
sleep 1
done
}
# logged <service> <text>...: a line of the service's output holds every text,
# in any order.
logged() {
lines="$(compose logs --no-color --no-log-prefix "$1" 2>&1)"
shift
for text in "$@"; do
lines="$(printf '%s\n' "$lines" | grep -F -e "$text")" || return 1
done
}
# send <path> <curl option>...: send a request for path to the site, at
# traefik's address, and print the status of the answer, 000 for none. traefik
# answers with a certificate it made itself, since the setup names no
# certificate resolver.
send() {
path="$1"
shift
curl --silent --show-error --insecure --max-time 30 --output /dev/null \
--write-out '%{http_code}' --resolve "$SITE:443:$traefik" "$@" \
"https://$SITE$path" || true
}
# routed: traefik passes a request for the site's front page on to gitea.
routed() {
[ "$(send / 2>/dev/null)" = 200 ]
}
# site_git <git argument>...: run git as the owner, without the host's own git
# settings, reaching the site at traefik's address. git gives up on a transfer
# slower than 1000 bytes a second for 30 seconds.
site_git() {
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 git \
-c user.name=owner -c user.email=owner@example.invalid \
-c http.sslVerify=false -c "http.curloptResolve=$SITE:443:$traefik" \
-c http.lowSpeedLimit=1000 -c http.lowSpeedTime=30 "$@"
}
# address_of <service>: print the service's address on the shared network.
address_of() {
docker inspect --format "{{(index .NetworkSettings.Networks \"$NETWORK\").IPAddress}}" \
"$(compose ps --quiet "$1")"
}
# passed_through <method> <path>: rproxy passed a request from the gateway for
# path to gitea on servewaf's allow, and servewaf checked it: its report line
# gives the client's counts and the Core Rule Set's score.
passed_through() {
wait_for "rproxy logged no $1 $2 that servewaf allowed" logged rproxy \
"\"method\":\"$1\"" "\"path\":\"$2\"" "\"client_ip\":\"$gateway\"" \
'"disposition":"allow"'
wait_for "servewaf logged no check of $1 $2" logged servewaf \
'"servewaf_line":"report"' "\"method\":\"$1\"" "\"path\":\"$2\"" \
"\"client_ip\":\"$gateway\"" '"action":"forward"' '"counts":' '"waf_score":'
}
main() {
cd "$ROOT"
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
WORK="$(mktemp -d)"
printf 'services:\n traefik:\n ports: !reset []\n' >"$WORK/no-ports.yaml"
docker build --no-cache -t "$IMAGE" .
secret_key="$(docker run --rm "$GITEA_IMAGE" gitea generate secret SECRET_KEY)"
# rproxy is healthy once gitea takes connections.
compose up --detach --quiet-pull --wait --wait-timeout 120 ||
fail "the setup did not start"
traefik="$(address_of traefik)"
gateway="$(docker network inspect --format '{{(index .IPAM.Config 0).Gateway}}' "$NETWORK")"
wait_for "traefik did not route the site to rproxy" routed
compose exec --user git gitea gitea admin user create --admin \
--username owner --password "$PASSWORD" --email owner@example.invalid \
--must-change-password=false >/dev/null || fail "creating gitea's user failed"
status="$(send /api/v1/user/repos --user "owner:$PASSWORD" \
--header "Content-Type: application/json" --data '{"name":"repo"}')"
[ "$status" = 201 ] || fail "creating the repository was answered $status"
status="$(send /owner/repo)"
[ "$status" = 200 ] || fail "the repository's page was answered $status"
passed_through GET /owner/repo
echo "gitea-setup: a page load passes through rproxy, and servewaf checks it"
wait_for "gitea did not log the visitor's address" logged gitea \
"GET /owner/repo for $gateway:"
echo "gitea-setup: gitea logs the visitor's address, not rproxy's"
site_git init --quiet --initial-branch=main "$WORK/repo"
echo "hello" >"$WORK/repo/README.md"
site_git -C "$WORK/repo" add README.md
site_git -C "$WORK/repo" commit --quiet --message "Add a README"
site_git -C "$WORK/repo" push --quiet \
"https://owner:$PASSWORD@$SITE/owner/repo.git" main ||
fail "the push failed"
passed_through POST /owner/repo.git/git-receive-pack
echo "gitea-setup: a push passes through rproxy, and servewaf checks it"
site_git clone --quiet "https://owner:$PASSWORD@$SITE/owner/repo.git" \
"$WORK/clone" || fail "the clone failed"
[ "$(cat "$WORK/clone/README.md")" = hello ] || fail "the clone lacks the README"
passed_through POST /owner/repo.git/git-upload-pack
echo "gitea-setup: a clone passes through rproxy, and servewaf checks it"
# A container on the shared network, as sandcastle is, asks for the site
# by its name, and so reaches traefik from its own address.
status="$(docker run --rm --name "$VISITOR" --network "$NETWORK" \
--entrypoint curl "$GITEA_IMAGE" --silent --insecure --max-time 30 \
--output /dev/null --write-out '%{http_code}' "https://$SITE/api/v1/version" ||
true)"
[ "$status" = 200 ] || fail "the request from the shared network was answered $status"
wait_for "servewaf logged no request from the shared network" logged servewaf \
'"servewaf_line":"report"' '"path":"/api/v1/version"' '"action":"forward"'
line="$(compose logs --no-color --no-log-prefix servewaf |
grep -F '"servewaf_line":"report"' | grep -F '"path":"/api/v1/version"')"
case "$line" in
*"\"client_ip\":\"$gateway\""*)
fail "the request from the shared network came from the gateway" ;;
*'"counts":'* | *'"waf_score":'*)
fail "servewaf checked the request from the shared network" ;;
esac
echo "gitea-setup: a request from the shared network skips the checks"
compose stop servewaf >/dev/null 2>&1 || fail "servewaf did not stop"
status="$(send /owner/repo)"
[ "$status" = 200 ] ||
fail "with servewaf stopped, the repository's page was answered $status"
wait_for "rproxy logged no request SWRP_DEFAULT_DISPOSITION decided" \
logged rproxy '"path":"/owner/repo"' '"default_disposition":true'
echo "gitea-setup: with servewaf stopped, gitea answers through rproxy"
}
main "$@"
Executable
+51
View File
@@ -0,0 +1,51 @@
#!/bin/sh
# script/publish: build the image on the Docker host and push it to the
# registry under four tags: the UTC date (YYYY-MM-DD), the short commit
# hash, the branch name and latest. The build is script/docker's, so the
# tests and the linter run first. The host and the registry name are
# environment variables with defaults, for running it against another.
#
# The working tree must be clean: the hash tag names a commit, and an
# image built from uncommitted changes would carry a hash that does not
# describe it.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
: "${DOCKER_HOST:=ssh://root@fsn1app1}"
: "${PUBLISH_IMAGE:=git.eeqj.de/sneak/smallwebwaf}"
export DOCKER_HOST
main() {
cd "$ROOT"
if [ -n "$(git status --porcelain)" ]; then
echo "script/publish: the working tree is not clean; commit or stash first" >&2
exit 1
fi
date="$(date -u +%Y-%m-%d)"
hash="$(git rev-parse --short HEAD)"
branch="$(git rev-parse --abbrev-ref HEAD)"
if [ "$branch" = "HEAD" ]; then
echo "script/publish: detached HEAD; check out a branch first" >&2
exit 1
fi
# A tag is [A-Za-z0-9_.-]; a branch name such as feature/x is not.
branch="$(printf '%s' "$branch" | tr -c 'A-Za-z0-9_.-' '-')"
local_tag="$("$SCRIPT_DIR/projectname")"
"$SCRIPT_DIR/docker"
for tag in "$date" "$hash" "$branch" latest; do
docker tag "$local_tag" "$PUBLISH_IMAGE:$tag"
done
for tag in "$date" "$hash" "$branch" latest; do
docker push "$PUBLISH_IMAGE:$tag"
done
echo "pushed $PUBLISH_IMAGE with tags: $date $hash $branch latest"
}
main "$@"