Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8249a1110c | ||
|
|
c402926bad |
No files matched your search
+3
-3
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -338,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 publishes `rproxy`'s
|
own, where each reaches the others by service name. Only "In front of gitea"
|
||||||
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
|
||||||
|
|
||||||
@@ -425,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.
|
||||||
@@ -660,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`.
|
||||||
@@ -688,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
|
||||||
|
|
||||||
@@ -711,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
|
||||||
|
|
||||||
|
|||||||
@@ -402,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.
|
||||||
@@ -417,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.
|
||||||
|
|
||||||
@@ -550,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
|
||||||
@@ -590,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;
|
||||||
@@ -607,8 +609,8 @@ 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 six 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
|
||||||
@@ -834,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
|
||||||
@@ -863,15 +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. Anything else, such as a rule banning WordPress probes in front of
|
root too. Anything else, such as a rule banning WordPress probes in front of
|
||||||
an app that is not WordPress, belongs in a file the deployer adds to the image
|
gitea, belongs in a file the deployer adds to the image `servewaf` runs from,
|
||||||
`servewaf` runs from, or mounts. A rule has one target, so a `path` rule
|
or mounts. A rule has one target, so a `path` rule applies on every site
|
||||||
applies on every site behind that `servewaf`. A directory mounted over
|
behind that `servewaf`. A directory mounted over `SWWAF_RULES_DIR` replaces
|
||||||
`SWWAF_RULES_DIR` replaces the default file; single files mounted into it add
|
the default file; single files mounted into it add to it.
|
||||||
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
|
||||||
@@ -1694,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
-6
@@ -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)).
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
+347
-138
@@ -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"
|
||||||
@@ -363,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(
|
||||||
@@ -394,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, " +
|
||||||
@@ -418,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")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -457,97 +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),
|
||||||
TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"),
|
TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"),
|
||||||
ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"),
|
ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"),
|
||||||
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
|
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
|
||||||
LogRemoteTLSCAs: env.certificates("SWWAF_LOG_REMOTE_TLS_CA_FILE"),
|
LogRemoteTLSCAs: env.certificates(
|
||||||
LogRemoteBuffer: env.numberNotOff("SWWAF_LOG_REMOTE_BUFFER", "10000"),
|
"SWWAF_LOG_REMOTE_TLS_CA_FILE",
|
||||||
LogRemoteFacility: env.facility("SWWAF_LOG_REMOTE_FACILITY", "local0"),
|
),
|
||||||
AlertWebhookURL: env.webhookURL("SWWAF_ALERT_WEBHOOK_URL"),
|
LogRemoteBuffer: env.numberNotOff(
|
||||||
AlertWebhookHeaders: env.webhookHeaders("SWWAF_ALERT_WEBHOOK_HEADERS"),
|
"SWWAF_LOG_REMOTE_BUFFER",
|
||||||
AlertSlackWebhookURL: env.webhookURL("SWWAF_ALERT_SLACK_WEBHOOK_URL"),
|
"10000",
|
||||||
AlertNtfyURL: env.webhookURL("SWWAF_ALERT_NTFY_URL"),
|
),
|
||||||
AlertNtfyToken: env.secret("SWWAF_ALERT_NTFY_TOKEN"),
|
LogRemoteFacility: env.facility(
|
||||||
|
"SWWAF_LOG_REMOTE_FACILITY",
|
||||||
|
"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",
|
||||||
@@ -584,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")
|
||||||
|
|
||||||
@@ -653,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))
|
||||||
|
|
||||||
@@ -911,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
|
||||||
}
|
}
|
||||||
@@ -963,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,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1031,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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1053,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)
|
||||||
|
|
||||||
@@ -1233,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
|
||||||
@@ -1242,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
|
||||||
@@ -1255,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
|
||||||
@@ -1265,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,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1589,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
|
||||||
@@ -1828,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)
|
||||||
}
|
}
|
||||||
@@ -1866,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)
|
||||||
}
|
}
|
||||||
@@ -1892,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) &&
|
||||||
@@ -1955,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
|
||||||
@@ -2022,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:
|
||||||
@@ -2111,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))
|
||||||
|
|||||||
@@ -197,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -650,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)
|
||||||
@@ -797,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)
|
||||||
}
|
}
|
||||||
@@ -826,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)
|
||||||
}
|
}
|
||||||
@@ -2166,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)
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-6
@@ -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
|
||||||
|
|||||||
@@ -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"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -49,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.
|
||||||
@@ -341,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -610,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)
|
||||||
})
|
})
|
||||||
@@ -668,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.
|
||||||
@@ -909,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)
|
||||||
@@ -918,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)
|
||||||
}
|
}
|
||||||
@@ -1011,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")
|
||||||
@@ -1062,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,
|
||||||
@@ -1080,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...)
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Executable
+350
@@ -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/&/\&/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 "$@"
|
||||||
Executable
+216
@@ -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
@@ -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 "$@"
|
||||||
Reference in new issue
Block a user