Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8249a1110c | ||
|
|
c402926bad | ||
|
|
f8676debda | ||
|
|
a41d61e9ad | ||
|
|
21cac70b50 | ||
|
|
2ab57eb278 | ||
|
|
a63b7e4d2d | ||
|
|
3cb41f27a8 | ||
|
|
84a9190d2b | ||
|
|
1bdf66cd21 |
No files matched your search
+4
-25
@@ -133,32 +133,11 @@ RUN mkdir /etc/sysusers.d \
|
||||
&& echo 'u _runit-log - "runit svlogd user" /nonexistent /sbin/nologin' \
|
||||
> /etc/sysusers.d/runit.conf
|
||||
|
||||
# ca-certificates, nix-bin and runit, from Ubuntu's archive as it was at
|
||||
# the snapshot moment, which is never earlier than the Ubuntu image above.
|
||||
# apt checks every package against the snapshot's InRelease files, and
|
||||
# this step checks those against the hashes named here, which are those
|
||||
# of the amd64 archive: other architectures use Ubuntu's ports archive.
|
||||
# apt also fetches the live archive's InRelease files, which change daily
|
||||
# and which the install does not use. The snapshot service is HTTPS only
|
||||
# and this image has no CA certificates yet, so this step uses the Go
|
||||
# image's. The snapshot service answers 503 at times; apt retries a
|
||||
# failed download, waiting twice as long before each retry, and this
|
||||
# step gives it five retries, about half a minute in all, where its
|
||||
# default of three gives up after seven seconds.
|
||||
RUN --mount=type=bind,from=builder,source=/etc/ssl/certs/ca-certificates.crt,target=/tmp/go-image-ca.crt \
|
||||
apt-get update --snapshot 20261001T000000Z \
|
||||
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
|
||||
-o Acquire::Retries=5 \
|
||||
&& printf '%s\n' \
|
||||
'45f95ce276cdba3e41870516a130e03c58b8b7a79e9546b0efe9e526d255740c snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute_InRelease' \
|
||||
'802e675dd9de4c7f3916434a95e7c1d8eec0e82886622d7805ab19a2c6fe0365 snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-updates_InRelease' \
|
||||
'64b3353f0bd4970b4f7271962245bcea9ff24d4cc7bea16b433f8a60e42ca3dd snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-backports_InRelease' \
|
||||
'1d5041572116a8b23aabf79ac7439ad8af83d57ad3fb0f9aa0d4523ec10c5908 snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-security_InRelease' \
|
||||
| (cd /var/lib/apt/lists && sha256sum --check --strict) \
|
||||
# ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
|
||||
# them over plain HTTP and checks each one against the archive's signed
|
||||
# package lists, so no CA certificates are needed.
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
||||
--snapshot 20261001T000000Z \
|
||||
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
|
||||
-o Acquire::Retries=5 \
|
||||
ca-certificates nix-bin runit \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
||||
run example-app gitea-requests
|
||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \
|
||||
hooks build run example-app gitea-requests gitea-setup
|
||||
|
||||
# Makefile targets are thin shims; the implementations live in script/
|
||||
# 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,
|
||||
# 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; gitea-requests
|
||||
# reports the gitea requests the Core Rule Set refuses at the defaults.
|
||||
# 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:
|
||||
@script/bootstrap
|
||||
@@ -35,6 +37,9 @@ check:
|
||||
docker:
|
||||
@script/docker
|
||||
|
||||
publish:
|
||||
@script/publish
|
||||
|
||||
hooks:
|
||||
@script/install-precommit
|
||||
|
||||
@@ -49,3 +54,6 @@ example-app:
|
||||
|
||||
gitea-requests:
|
||||
@script/gitea-requests
|
||||
|
||||
gitea-setup:
|
||||
@script/gitea-setup
|
||||
@@ -26,10 +26,10 @@ It protects against:
|
||||
|
||||
- Scrapers that fetch every page of a site, all day. Each client gets a limit on
|
||||
requests and on bytes per minute, per hour and per day. The defaults leave
|
||||
room for programmatic use of an API, such as an automation fleet, and catch a
|
||||
client firing requests as fast as it can within seconds. A client over a limit
|
||||
is refused and banned: for a while at first, for three times as long at each
|
||||
repeat, and permanently when it keeps at it.
|
||||
room for a person reading a site for hours. They catch a scraper that keeps up
|
||||
one request every 2 seconds. A client over a limit is refused and banned: for
|
||||
a while at first, for three times as long at each repeat, and permanently when
|
||||
it keeps at it.
|
||||
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
|
||||
default rule file bans such a client at its first probe, and its next request
|
||||
during the ban makes the ban permanent. Your own rules, or a list of trap
|
||||
@@ -69,8 +69,7 @@ Not built, and not planned:
|
||||
### The image
|
||||
|
||||
No image is published. Build it from a clone. `make docker` runs the tests and
|
||||
the linter, then builds the image, tagged `smallwebwaf`. It builds for amd64, on
|
||||
an amd64 host only.
|
||||
the linter, then builds the image, tagged `smallwebwaf`.
|
||||
|
||||
```sh
|
||||
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
||||
@@ -237,13 +236,13 @@ Every setting the code reads, with its default:
|
||||
| `SWWAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
||||
| `SWWAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
|
||||
| `SWWAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
||||
| `SWWAF_RATE_LIMIT_PER_MINUTE` | `500` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
|
||||
| `SWWAF_RATE_LIMIT_PER_HOUR` | `20000` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
|
||||
| `SWWAF_RATE_LIMIT_PER_DAY` | `300000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
|
||||
| `SWWAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
|
||||
| `SWWAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
|
||||
| `SWWAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
|
||||
| `SWWAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
|
||||
| `SWWAF_BYTES_LIMIT_PER_MINUTE` | `4G` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
|
||||
| `SWWAF_BYTES_LIMIT_PER_HOUR` | `20G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
|
||||
| `SWWAF_BYTES_LIMIT_PER_DAY` | `200G` | The most bytes a client's requests may carry in a day, a size or `off`. |
|
||||
| `SWWAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
|
||||
| `SWWAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
|
||||
| `SWWAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
|
||||
| `SWWAF_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
|
||||
| `SWWAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
|
||||
| `SWWAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
||||
@@ -408,10 +407,10 @@ services:
|
||||
SWWAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
||||
# A netblock that sends nothing wanted.
|
||||
SWWAF_DENY_NETS: 198.51.100.128/25
|
||||
# Tighter limits for everyone else: a site with no API.
|
||||
SWWAF_RATE_LIMIT_PER_MINUTE: "120"
|
||||
SWWAF_RATE_LIMIT_PER_HOUR: "3000"
|
||||
SWWAF_RATE_LIMIT_PER_DAY: "30000"
|
||||
# Tighter limits for everyone else: a small static site.
|
||||
SWWAF_RATE_LIMIT_PER_MINUTE: "60"
|
||||
SWWAF_RATE_LIMIT_PER_HOUR: "600"
|
||||
SWWAF_RATE_LIMIT_PER_DAY: "3000"
|
||||
# Hosting networks and one country get a share of those limits.
|
||||
SWWAF_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
|
||||
SWWAF_COUNTRY_LIMIT_PERCENT: cn:25
|
||||
@@ -432,10 +431,9 @@ volumes:
|
||||
gitea behind traefik, set up for a code forge:
|
||||
|
||||
- the upload limits raised, in size and in time;
|
||||
- the response size limit and the byte limits raised, for clones over 1 GiB;
|
||||
- the response size limit raised, for clones over 1 GiB;
|
||||
- the Actions runners exempt from counting;
|
||||
- the default request limits kept, since they already leave room for API
|
||||
clients.
|
||||
- the default request and byte limits kept.
|
||||
|
||||
`traefik` is the network traefik is on, made outside this file.
|
||||
|
||||
@@ -489,11 +487,6 @@ services:
|
||||
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
|
||||
# Raised with rproxy's response limit, to four times the default
|
||||
# each, so that one clone of that size breaks no byte limit.
|
||||
SWWAF_BYTES_LIMIT_PER_MINUTE: 16G
|
||||
SWWAF_BYTES_LIMIT_PER_HOUR: 80G
|
||||
SWWAF_BYTES_LIMIT_PER_DAY: 800G
|
||||
# gitea never serves these; a request for one bans the client.
|
||||
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php,/wp-admin/
|
||||
volumes:
|
||||
@@ -511,8 +504,9 @@ volumes:
|
||||
servewaf-state:
|
||||
```
|
||||
|
||||
[`docs/gitea.md`](docs/gitea.md) records what the defaults do in front of gitea,
|
||||
request by request.
|
||||
`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
|
||||
|
||||
@@ -662,8 +656,8 @@ aside as `<name>.bad`, and never stops the running process (see
|
||||
|
||||
### The image and its processes
|
||||
|
||||
- The image is built on Ubuntu 26.04 LTS, pinned by digest, with its packages
|
||||
from a snapshot of Ubuntu's archive and nixpkgs fixed at one release. An app's
|
||||
- The image is built on Ubuntu 26.04 LTS, pinned by digest. Its Ubuntu packages
|
||||
come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's
|
||||
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
|
||||
same package each time it is built (see [`docs/image.md`](docs/image.md)).
|
||||
- runit, a service supervisor, runs `smallwebwaf` as the `smallwebwaf` user (uid
|
||||
@@ -749,6 +743,11 @@ on the host.
|
||||
`make check`.
|
||||
- `script/docker`: builds the image, whose build runs the tests and the linter
|
||||
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
|
||||
image build.
|
||||
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
||||
@@ -785,6 +784,10 @@ on the host.
|
||||
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
|
||||
|
||||
@@ -808,7 +811,8 @@ on the host.
|
||||
- [`docs/state-files.md`](docs/state-files.md): the state files and what each
|
||||
holds.
|
||||
- [`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.
|
||||
- [`docs/gitea.md`](docs/gitea.md): `smallwebwaf` in front of gitea: the setup
|
||||
in `deploy/gitea`, and what the defaults do there.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -397,32 +397,31 @@ Conventions:
|
||||
|
||||
### Request rate limits, per client
|
||||
|
||||
Breaking one bans the client. The defaults suit programmatic use of an API, such
|
||||
as an automation fleet on another host, and still stop abusive bots. A fleet of
|
||||
20 agents, each making a request every 10 seconds at its busiest, makes 120
|
||||
requests a minute and 7,200 an hour. Working around the clock, it makes 172,800
|
||||
a day. It stays under all three limits.
|
||||
Breaking one bans the client. The defaults let people read a site and stop
|
||||
scrapers that send many requests slowly over hours or days. The hour limit is
|
||||
the main one.
|
||||
|
||||
- Per minute: 500, about 8 requests a second. It leaves room for the fleet's 120
|
||||
a minute and a few quick first visits with an empty cache. A gitea page opened
|
||||
with an empty cache loads about 40 files. A client firing requests as fast as
|
||||
it can passes the limit within seconds.
|
||||
- Per hour: 20,000, about 5.5 requests a second for a whole hour. A client that
|
||||
keeps up 6 a second makes 21,600 in an hour, and passes it, at 360 a minute,
|
||||
under the minute limit. The fleet's 7,200 an hour is about a third of it.
|
||||
- Per day: 300,000, about 3.5 requests a second around the clock. A scraper that
|
||||
keeps up 4 a second makes 345,600 a day, and passes it. At 240 a minute and
|
||||
14,400 an hour, it stays under the other two limits.
|
||||
- Per minute: 120, 2 requests a second. It leaves room for a person opening two
|
||||
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.
|
||||
- Per hour: 1,500. A person reading code in gitea for a whole hour, a page every
|
||||
20 seconds, makes about 900 requests. A scraper making one request every 2
|
||||
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
|
||||
every 10 seconds makes 8,640 a day, more than the limit.
|
||||
|
||||
Counting cannot catch a client that stays under all three limits, such as one
|
||||
that keeps up 3 requests a second. Only the other checks can catch an abuser
|
||||
Counting cannot catch a client that stays under all three limits, about 4
|
||||
requests a minute around the clock. Only the other checks can catch an abuser
|
||||
that slow. They include the trap paths, the rule files, the Core Rule Set and
|
||||
the blocklists. An abuser that trips none of them is not caught. A machine that
|
||||
keeps up more than 500 requests a minute passes the minute limit. One that keeps
|
||||
up more than about 5.5 a second for an hour passes the hour limit. One that
|
||||
keeps up more than about 3.5 a second all day passes the day limit, as two busy
|
||||
Actions runners on one address do. Such a machine's address belongs in
|
||||
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||
the blocklists. An abuser that trips none of them is not caught.
|
||||
|
||||
Some legitimate clients 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
|
||||
limits.
|
||||
|
||||
Their addresses belong in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||
|
||||
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` takes path prefixes out of the count, such as
|
||||
static assets or health checks; their bytes are not counted either. A prefix is
|
||||
@@ -435,12 +434,10 @@ matched the same way.
|
||||
|
||||
### Byte limits, per client
|
||||
|
||||
The hour and day limits sit about twice above what 40 people busy with heavy web
|
||||
apps move together on one address. Each moves about 256 MiB an hour, for ten
|
||||
hours a day. A response's bytes are counted when it ends (see step 15). So the
|
||||
minute limit holds three of the largest responses, with their requests, ending
|
||||
in the same minute. No single download then breaks a limit. An operator who
|
||||
raises `SWRP_MAX_RESPONSE_SIZE` must raise the byte limits too.
|
||||
By default there is no minute limit, so a client can download in a burst at full
|
||||
speed. The hour limit defaults to 50 GiB and the day limit to 100 GiB. They
|
||||
allow several downloads of a few GiB each. A response's bytes are counted when
|
||||
it ends (see step 15).
|
||||
|
||||
### Lookup of AS number and country
|
||||
|
||||
@@ -1544,20 +1541,11 @@ It sets no `ENTRYPOINT` or `USER` of its own. The container must start
|
||||
|
||||
- The image is Ubuntu's newest long-term support release, pinned by digest, and
|
||||
moves to the next LTS release when that ships. Its packages come from Ubuntu's
|
||||
snapshot service, the archive as it was at the moment the Dockerfile names,
|
||||
rather than the live archive, whose packages change with every update. The
|
||||
snapshot is never older than the pinned Ubuntu image, since older packages can
|
||||
need older versions of packages the image holds, and it moves forward with the
|
||||
image's digest. The Dockerfile names the hash of each of the snapshot's
|
||||
`InRelease` files, which cover every package list and so every package, and
|
||||
the build checks them before installing. The live archive's `InRelease` files,
|
||||
which `apt-get update` also fetches and the install does not use, are left out
|
||||
of the check. The hashes are the amd64 archive's, so the image is built for
|
||||
amd64 only.
|
||||
archive, whose packages change with every update.
|
||||
- The Ubuntu image has no CA certificates. Without them Nix cannot download
|
||||
packages and `servewaf` cannot reach GeoJS, so `ca-certificates` is installed
|
||||
by name. The snapshot itself is fetched over HTTPS with the CA certificates of
|
||||
the Go image that builds `smallwebwaf`, itself pinned by digest.
|
||||
by name. apt fetches the packages over plain HTTP and checks each one against
|
||||
the archive's signed package lists, so the install needs no CA certificates.
|
||||
- nixpkgs comes from a release file on `releases.nixos.org`, named with its
|
||||
hash, not from a GitHub archive, whose bytes can change. Root uses Nix
|
||||
directly; no Nix daemon runs. `nix-bin` creates no build users, so
|
||||
|
||||
@@ -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:
|
||||
+169
-49
@@ -1,51 +1,177 @@
|
||||
# smallwebwaf in front of gitea
|
||||
|
||||
These notes record what `smallwebwaf` does in front of gitea at the default
|
||||
settings. They are observations, not requirements; the requirements are in
|
||||
[`SPEC.md`](../SPEC.md). `script/gitea-requests` runs gitea behind `rproxy` and
|
||||
`servewaf`, and sends through them a sample of gitea's ordinary use and the
|
||||
`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.
|
||||
version. In `detect` mode, as the setup runs it, the same requests are logged
|
||||
with their rule ids, and pass.
|
||||
|
||||
## What the defaults cover
|
||||
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.
|
||||
|
||||
- SSH access to gitea does not pass through `smallwebwaf` and is not protected
|
||||
by it.
|
||||
- A clone is a response. It fits the defaults of 30 minutes and 1 GiB unless the
|
||||
repository is large or the link slow. A repository whose clone is larger than
|
||||
1 GiB needs `SWRP_MAX_RESPONSE_SIZE` raised, and the byte limits with it
|
||||
("Byte limits, per client" in `SPEC.md`).
|
||||
- A push is a request, and so is every other upload:
|
||||
- a git push;
|
||||
- an LFS object;
|
||||
- a container image layer;
|
||||
- a package file;
|
||||
- a release attachment.
|
||||
### What it lets through
|
||||
|
||||
At the defaults, an upload larger than 100 MiB or taking more than 60
|
||||
seconds is cut off ("`rproxy`'s limits" in `SPEC.md`). A gitea that takes
|
||||
large uploads needs `SWRP_MAX_REQUEST_SIZE` and `SWRP_MAX_REQUEST_TIME_MS`
|
||||
raised to fit.
|
||||
|
||||
- Archive download and blame or history pages are what scrapers hammer. The
|
||||
request limits do most of the work there.
|
||||
- An Actions runner that reaches gitea through `smallwebwaf` sends requests all
|
||||
day. One older than version 0.4 (April 2026) asks for work every 2 seconds,
|
||||
43,200 requests a day. While it runs a job, it reports the job's log and state
|
||||
every second instead, two requests a second. So one runner busy all day sends
|
||||
120 requests a minute and 172,800 a day, under the request limits. Two such
|
||||
runners on one address, each busy all day, make 345,600 and pass the day limit
|
||||
of 300,000. Three busy at once make 21,600 an hour and pass the hour limit of
|
||||
20,000. Five busy at once make 600 a minute and pass the minute limit of 500.
|
||||
Their address is then banned, their 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 lets through
|
||||
|
||||
At the defaults, the Core Rule Set lets gitea's ordinary use through, apart from
|
||||
the refusals in the next section:
|
||||
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;
|
||||
@@ -60,12 +186,6 @@ the refusals in the next section:
|
||||
editor, code included, since no body is read;
|
||||
- the page gitea shows after a change, with its message naming what was done.
|
||||
|
||||
## What the Core Rule Set still refuses
|
||||
|
||||
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.
|
||||
|
||||
### A query string that reads as an attack
|
||||
|
||||
Most often this is a search. A search term is a query parameter, which the Core
|
||||
@@ -183,7 +303,7 @@ 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
|
||||
### 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
|
||||
@@ -195,7 +315,7 @@ log, are added to `SWWAF_WAF_DISABLED_RULES`:
|
||||
- PyPI uploads (922130);
|
||||
- attachments named like `debug.log` or `config.yml` (932180).
|
||||
|
||||
## What `script/gitea-requests` sends
|
||||
### What `script/gitea-requests` sends
|
||||
|
||||
Of the requests above, the script sends:
|
||||
|
||||
|
||||
+3
-6
@@ -8,8 +8,7 @@ is pinned that way.
|
||||
## Contents
|
||||
|
||||
- Ubuntu 26.04 LTS, pinned by digest.
|
||||
- `ca-certificates`, `nix-bin` and `runit` from Ubuntu, from the snapshot of its
|
||||
archive at `20261001T000000Z`.
|
||||
- `ca-certificates`, `nix-bin` and `runit` from Ubuntu's archive.
|
||||
- Nix, with nixpkgs `nixos-26.05.11045.774debe7a0d1` set up for root as
|
||||
`nixpkgs`. No Nix daemon; `build-users-group =` in `/etc/nix/nix.conf`. Root's
|
||||
Nix profile, `/nix/var/nix/profiles/default/bin`, last on the `PATH`.
|
||||
@@ -26,10 +25,8 @@ is pinned that way.
|
||||
|
||||
## Pins
|
||||
|
||||
- Ubuntu's packages: the snapshot service, `--snapshot 20261001T000000Z` on both
|
||||
`apt-get update` and `apt-get install`. The SHA-256 hash of each of the
|
||||
snapshot's `InRelease` files is named in the `Dockerfile` and checked after
|
||||
`apt-get update`. The hashes are the amd64 archive's.
|
||||
- Ubuntu's packages are not pinned: they come from the live archive, whose
|
||||
packages change with every update.
|
||||
- nixpkgs: the release file `nixexprs.tar.xz` of
|
||||
`nixos-26.05.11045.774debe7a0d1` on `releases.nixos.org`, named with its
|
||||
SHA-256 hash.
|
||||
|
||||
+18
-15
@@ -99,19 +99,22 @@ chooses between the two (see "The image's command" below).
|
||||
|
||||
## Request rate limits, per client
|
||||
|
||||
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `500`, about 8 a second): the most
|
||||
requests a client may make in a minute. A count or `off`. The default catches,
|
||||
within seconds, a client firing requests as fast as it can. It leaves room for
|
||||
a fleet of about 20 agents at its busiest, about 120 requests a minute. It
|
||||
also leaves room for a few quick first visits to gitea with an empty cache,
|
||||
about 40 files each.
|
||||
- `SWWAF_RATE_LIMIT_PER_HOUR` (default `20000`, about 5.5 a second): the most
|
||||
requests a client may make in an hour. A count or `off`. The default leaves
|
||||
room for an automation fleet at work. It catches a client that keeps up more
|
||||
than about 5.5 a second for an hour.
|
||||
- `SWWAF_RATE_LIMIT_PER_DAY` (default `300000`, about 3.5 a second): the most
|
||||
requests a client may make in a day. A count or `off`. The default catches a
|
||||
scraper that keeps up 4 a second around the clock.
|
||||
- `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
|
||||
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
|
||||
make in an hour. A count or `off`. It is the main limit against scrapers. A
|
||||
person reading code in gitea for an hour, a page every 20 seconds, makes
|
||||
about 900. A scraper making one request every 2 seconds makes 1,800 an hour,
|
||||
more than the limit.
|
||||
- `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.
|
||||
A scraper making one request every 10 seconds makes 8,640 a day, more than the
|
||||
limit.
|
||||
- An Actions runner asks gitea for work all day and can pass the limits. Put the
|
||||
runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
|
||||
network where many people share one address, such as an office.
|
||||
- Counting cannot catch a client that stays under all three limits (see "Request
|
||||
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
|
||||
@@ -119,8 +122,8 @@ chooses between the two (see "The image's command" below).
|
||||
|
||||
## Byte limits, per client
|
||||
|
||||
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `4G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
|
||||
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `200G`): the most
|
||||
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWWAF_BYTES_LIMIT_PER_HOUR`
|
||||
(default `50G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most
|
||||
bytes counted for a client in each window. A size or `off`.
|
||||
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
||||
`response`, `request` or `both`.
|
||||
|
||||
+347
-138
@@ -7,6 +7,9 @@
|
||||
// the image runs.
|
||||
package config
|
||||
|
||||
// FIXME: the rproxy config is in rproxy.go, the servewaf is in config.go -
|
||||
// wrong and inconsistent.
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
@@ -363,13 +366,16 @@ var (
|
||||
"is not a header name such as accept-language")
|
||||
errHeaderTakenOut = errors.New(
|
||||
"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")
|
||||
errNotLookupSource = errors.New("is not geojs, file or off")
|
||||
errNeedsLookups = errors.New("it needs each client looked up")
|
||||
errNeedsDBPath = errors.New("it names the file to look clients up in")
|
||||
errDBPathUnused = errors.New("only file reads it")
|
||||
errOver64K = errors.New(
|
||||
errNeedsDBPath = errors.New(
|
||||
"it names the file to look clients up in",
|
||||
)
|
||||
errDBPathUnused = errors.New("only file reads it")
|
||||
errOver64K = errors.New(
|
||||
"is more than 64K, the most servewaf gets of a body")
|
||||
errNotCommand = errors.New("is not rproxy or servewaf")
|
||||
errNotDurationAboveZero = errors.New(
|
||||
@@ -394,22 +400,29 @@ var (
|
||||
errNotPathPrefix = errors.New(
|
||||
"is not a path prefix starting with /, such as /assets/")
|
||||
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")
|
||||
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
||||
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(
|
||||
"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")
|
||||
errNotLogRemoteURL = errors.New(
|
||||
"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")
|
||||
errNotFacility = errors.New("is not a syslog facility such as local0 or daemon")
|
||||
errNotAppName = errors.New(
|
||||
"is not 1 to 48 printable ASCII characters without a space, such as gitea")
|
||||
errNotFacility = errors.New(
|
||||
"is not a syslog facility such as local0 or daemon",
|
||||
)
|
||||
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")
|
||||
errNotWebhookURL = errors.New(
|
||||
"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, " +
|
||||
"such as Authorization:Bearer <token>")
|
||||
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(
|
||||
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
|
||||
"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")
|
||||
errNotASN = errors.New("is not an AS number such as AS64496")
|
||||
errNotPercentItem = errors.New(
|
||||
"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")
|
||||
errNotListURL = errors.New(
|
||||
"is not an http or https URL without a user or a fragment, " +
|
||||
"such as https://www.spamhaus.org/drop/drop.txt")
|
||||
errInBlocklistURLs = errors.New("is in SWWAF_BLOCKLIST_URLS too")
|
||||
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
|
||||
errNotAction = errors.New(
|
||||
errNotAnHourOrMore = 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")
|
||||
errNotZone = errors.New("is not a DNS zone such as dnsbl.dronebl.org")
|
||||
errZoneTooLong = errors.New("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")
|
||||
errNotZone = errors.New(
|
||||
"is not a DNS zone such as dnsbl.dronebl.org",
|
||||
)
|
||||
errZoneTooLong = errors.New(
|
||||
"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(
|
||||
"is not an http or https URL without a user or a fragment, " +
|
||||
"such as http://172.17.0.1:8080")
|
||||
errNeedsLAPIKey = errors.New("the engine answers no request without it")
|
||||
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
|
||||
errAnotherList = errors.New(
|
||||
errLAPIKeyUnused = 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")
|
||||
)
|
||||
|
||||
@@ -457,97 +485,208 @@ var (
|
||||
// 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
|
||||
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
func FromEnvironment(
|
||||
lookupEnv func(string) (string, bool),
|
||||
) (*Config, error) {
|
||||
env := &environment{lookupEnv: lookupEnv}
|
||||
cfg := &Config{
|
||||
ListenAddr: env.address("SWWAF_LISTEN_ADDR"),
|
||||
InstanceName: env.instanceName(),
|
||||
Observe: env.observe("SWWAF_MODE", "enforce"),
|
||||
IPv6GroupPrefix: env.ipv6GroupPrefix("SWWAF_IPV6_GROUP_PREFIX", "64"),
|
||||
MaxTrackedClients: env.numberNotOff("SWWAF_MAX_TRACKED_CLIENTS", "20000"),
|
||||
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
|
||||
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""),
|
||||
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
|
||||
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "500"),
|
||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "20000"),
|
||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "300000"),
|
||||
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
||||
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "4G"),
|
||||
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
|
||||
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "200G"),
|
||||
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", ""),
|
||||
ListenAddr: env.address("SWWAF_LISTEN_ADDR"),
|
||||
InstanceName: env.instanceName(),
|
||||
Observe: env.observe("SWWAF_MODE", "enforce"),
|
||||
IPv6GroupPrefix: env.ipv6GroupPrefix(
|
||||
"SWWAF_IPV6_GROUP_PREFIX",
|
||||
"64",
|
||||
),
|
||||
MaxTrackedClients: env.numberNotOff(
|
||||
"SWWAF_MAX_TRACKED_CLIENTS",
|
||||
"20000",
|
||||
),
|
||||
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
|
||||
RateLimitExemptNets: env.netblocks(
|
||||
"SWWAF_RATE_LIMIT_EXEMPT_NETS",
|
||||
"",
|
||||
),
|
||||
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
|
||||
RateLimitPerMinute: env.count(
|
||||
"SWWAF_RATE_LIMIT_PER_MINUTE",
|
||||
"120",
|
||||
),
|
||||
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(
|
||||
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
|
||||
ASNLimitPercent: env.percents("SWWAF_ASN_LIMIT_PERCENT", ParseASN),
|
||||
CountryLimitPercent: env.percents("SWWAF_COUNTRY_LIMIT_PERCENT", parseCountry),
|
||||
ASNBytesPercent: env.percents("SWWAF_ASN_BYTES_PERCENT", ParseASN),
|
||||
CountryBytesPercent: env.percents("SWWAF_COUNTRY_BYTES_PERCENT", parseCountry),
|
||||
UnknownLimitPercent: env.percent("SWWAF_UNKNOWN_LIMIT_PERCENT", "100"),
|
||||
ASNLimitPercentURL: env.listURL("SWWAF_ASN_LIMIT_PERCENT_URL"),
|
||||
BlocklistURLs: env.listURLs("SWWAF_BLOCKLIST_URLS"),
|
||||
BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"),
|
||||
DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"),
|
||||
DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"),
|
||||
AbuseIPDBKey: env.secret("SWWAF_ABUSEIPDB_KEY"),
|
||||
AbuseIPDBMinScore: env.percent("SWWAF_ABUSEIPDB_MIN_SCORE", "75"),
|
||||
AbuseIPDBDailyBudget: env.numberNotOff("SWWAF_ABUSEIPDB_DAILY_BUDGET", "900"),
|
||||
ReputationCacheTTL: env.durationNotOff("SWWAF_REPUTATION_CACHE_TTL", "24h"),
|
||||
ReputationTimeout: env.durationNotOff("SWWAF_REPUTATION_TIMEOUT", "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"),
|
||||
ASNLimitPercent: env.percents(
|
||||
"SWWAF_ASN_LIMIT_PERCENT",
|
||||
ParseASN,
|
||||
),
|
||||
CountryLimitPercent: env.percents(
|
||||
"SWWAF_COUNTRY_LIMIT_PERCENT",
|
||||
parseCountry,
|
||||
),
|
||||
ASNBytesPercent: env.percents(
|
||||
"SWWAF_ASN_BYTES_PERCENT",
|
||||
ParseASN,
|
||||
),
|
||||
CountryBytesPercent: env.percents(
|
||||
"SWWAF_COUNTRY_BYTES_PERCENT",
|
||||
parseCountry,
|
||||
),
|
||||
UnknownLimitPercent: env.percent(
|
||||
"SWWAF_UNKNOWN_LIMIT_PERCENT",
|
||||
"100",
|
||||
),
|
||||
ASNLimitPercentURL: env.listURL("SWWAF_ASN_LIMIT_PERCENT_URL"),
|
||||
BlocklistURLs: env.listURLs("SWWAF_BLOCKLIST_URLS"),
|
||||
BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"),
|
||||
DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"),
|
||||
DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"),
|
||||
AbuseIPDBKey: env.secret("SWWAF_ABUSEIPDB_KEY"),
|
||||
AbuseIPDBMinScore: env.percent("SWWAF_ABUSEIPDB_MIN_SCORE", "75"),
|
||||
AbuseIPDBDailyBudget: env.numberNotOff(
|
||||
"SWWAF_ABUSEIPDB_DAILY_BUDGET",
|
||||
"900",
|
||||
),
|
||||
ReputationCacheTTL: env.durationNotOff(
|
||||
"SWWAF_REPUTATION_CACHE_TTL",
|
||||
"24h",
|
||||
),
|
||||
ReputationTimeout: env.durationNotOff(
|
||||
"SWWAF_REPUTATION_TIMEOUT",
|
||||
"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",
|
||||
"920340,920420,920440,920640,930130,930140"),
|
||||
WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""),
|
||||
WAFBodyLimit: env.wafBodyLimit("SWWAF_WAF_BODY_LIMIT", off),
|
||||
TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"),
|
||||
ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"),
|
||||
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
|
||||
LogRemoteTLSCAs: env.certificates("SWWAF_LOG_REMOTE_TLS_CA_FILE"),
|
||||
LogRemoteBuffer: env.numberNotOff("SWWAF_LOG_REMOTE_BUFFER", "10000"),
|
||||
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"),
|
||||
WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""),
|
||||
WAFBodyLimit: env.wafBodyLimit("SWWAF_WAF_BODY_LIMIT", off),
|
||||
TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"),
|
||||
ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"),
|
||||
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
|
||||
LogRemoteTLSCAs: env.certificates(
|
||||
"SWWAF_LOG_REMOTE_TLS_CA_FILE",
|
||||
),
|
||||
LogRemoteBuffer: env.numberNotOff(
|
||||
"SWWAF_LOG_REMOTE_BUFFER",
|
||||
"10000",
|
||||
),
|
||||
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",
|
||||
strings.Join(alerts.Events(), ",")),
|
||||
AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"),
|
||||
AlertMaxPerHour: env.numberOrOff("SWWAF_ALERT_MAX_PER_HOUR", "60"),
|
||||
AnomalyClient: env.thresholds("SWWAF_ANOMALY_CLIENT_"),
|
||||
AnomalyNet: env.thresholds("SWWAF_ANOMALY_NET_"),
|
||||
AnomalyASN: env.thresholds("SWWAF_ANOMALY_ASN_"),
|
||||
AnomalyTotal: env.thresholds("SWWAF_ANOMALY_TOTAL_"),
|
||||
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"),
|
||||
AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"),
|
||||
AlertMaxPerHour: env.numberOrOff(
|
||||
"SWWAF_ALERT_MAX_PER_HOUR",
|
||||
"60",
|
||||
),
|
||||
AnomalyClient: env.thresholds("SWWAF_ANOMALY_CLIENT_"),
|
||||
AnomalyNet: env.thresholds("SWWAF_ANOMALY_NET_"),
|
||||
AnomalyASN: env.thresholds("SWWAF_ANOMALY_ASN_"),
|
||||
AnomalyTotal: env.thresholds("SWWAF_ANOMALY_TOTAL_"),
|
||||
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",
|
||||
@@ -584,14 +723,20 @@ func Command(lookupEnv func(string) (string, bool)) (string, error) {
|
||||
case "servewaf":
|
||||
return command, nil
|
||||
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
|
||||
// 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.
|
||||
func ServeWAFListenAddr(lookupEnv func(string) (string, bool)) (string, error) {
|
||||
func ServeWAFListenAddr(
|
||||
lookupEnv func(string) (string, bool),
|
||||
) (string, error) {
|
||||
env := &environment{lookupEnv: lookupEnv}
|
||||
listenAddr := env.address("SWWAF_LISTEN_ADDR")
|
||||
|
||||
@@ -653,14 +798,19 @@ func (e *environment) lookup(name string) (string, bool) {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
e.settings = append(e.settings, slog.String(name, strings.Join(logged, ",")))
|
||||
e.settings = append(
|
||||
e.settings,
|
||||
slog.String(name, strings.Join(logged, ",")),
|
||||
)
|
||||
|
||||
return zones
|
||||
}
|
||||
@@ -963,10 +1116,15 @@ func (e *environment) checkLookupDBPath(cfg *Config) {
|
||||
switch {
|
||||
case cfg.LookupSource == fileSource && cfg.LookupDBPath == "":
|
||||
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 != "":
|
||||
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},
|
||||
} {
|
||||
if setting.set {
|
||||
e.check(setting.name, fmt.Errorf("is set while SWWAF_LOOKUP_SOURCE is off; %w",
|
||||
errNeedsLookups))
|
||||
e.check(
|
||||
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 {
|
||||
case decisionsURL != "" && cfg.CrowdSecKey == "":
|
||||
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 != "":
|
||||
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) ||
|
||||
decisionsURL == cfg.ASNLimitPercentURL):
|
||||
e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w",
|
||||
decisionsURL, errAnotherList))
|
||||
e.check(
|
||||
"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
|
||||
// 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))
|
||||
e.check(name, err)
|
||||
|
||||
@@ -1233,7 +1403,10 @@ func (e *environment) instanceName() string {
|
||||
|
||||
value := e.value("SWWAF_INSTANCE_NAME", hostname)
|
||||
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
|
||||
@@ -1242,7 +1415,10 @@ func (e *environment) instanceName() string {
|
||||
// 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
|
||||
// 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)
|
||||
if !set {
|
||||
value = instanceName
|
||||
@@ -1255,8 +1431,14 @@ func (e *environment) appName(name, instanceName string, sending bool) string {
|
||||
case set:
|
||||
e.check(name, fmt.Errorf("%q %w", value, errNotAppName))
|
||||
case sending:
|
||||
e.check(name, fmt.Errorf("is unset, and SWWAF_INSTANCE_NAME %q, its default, %w",
|
||||
value, errNotAppName))
|
||||
e.check(
|
||||
name,
|
||||
fmt.Errorf(
|
||||
"is unset, and SWWAF_INSTANCE_NAME %q, its default, %w",
|
||||
value,
|
||||
errNotAppName,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
// character while ntfySet, SWWAF_ALERT_NTFY_URL being set: ntfy is sent
|
||||
// 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) {
|
||||
e.check("SWWAF_INSTANCE_NAME", fmt.Errorf(
|
||||
"%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)
|
||||
}
|
||||
|
||||
named = append(named, anomaly.NamedNetblock{Name: name, Netblock: netblock})
|
||||
named = append(
|
||||
named,
|
||||
anomaly.NamedNetblock{Name: name, Netblock: netblock},
|
||||
)
|
||||
}
|
||||
|
||||
return named, nil
|
||||
@@ -1828,8 +2018,11 @@ func parseHeaderNames(value string) ([]string, error) {
|
||||
header := strings.ToLower(item)
|
||||
switch header {
|
||||
case "host":
|
||||
return nil, fmt.Errorf("%q %w; the request's host is the field host",
|
||||
item, errHeaderTakenOut)
|
||||
return nil, fmt.Errorf(
|
||||
"%q %w; the request's host is the field host",
|
||||
item,
|
||||
errHeaderTakenOut,
|
||||
)
|
||||
case "transfer-encoding":
|
||||
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") &&
|
||||
upstream.Hostname() != "" && upstream.User == nil && upstream.Opaque == "" &&
|
||||
upstream.Hostname() != "" && upstream.User == nil &&
|
||||
upstream.Opaque == "" &&
|
||||
(upstream.Path == "" || upstream.Path == "/") &&
|
||||
upstream.RawQuery == "" && upstream.Fragment == ""
|
||||
upstream.RawQuery == "" &&
|
||||
upstream.Fragment == ""
|
||||
if !onlySchemeAndHost {
|
||||
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)
|
||||
}
|
||||
|
||||
schemes := []string{remotelog.SchemeUDP, remotelog.SchemeTCP, remotelog.SchemeTLS}
|
||||
schemes := []string{
|
||||
remotelog.SchemeUDP,
|
||||
remotelog.SchemeTCP,
|
||||
remotelog.SchemeTLS,
|
||||
}
|
||||
port, err := strconv.ParseUint(remote.Port(), 10, 16)
|
||||
|
||||
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() != "" &&
|
||||
(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
|
||||
@@ -2022,9 +2223,12 @@ func parseZones(value string) ([]string, error) {
|
||||
|
||||
for i, zone := range zones {
|
||||
shown := MaskZoneKey(zone)
|
||||
listedBefore := slices.ContainsFunc(zones[:i], func(earlier string) bool {
|
||||
return strings.EqualFold(MaskZoneKey(earlier), shown)
|
||||
})
|
||||
listedBefore := slices.ContainsFunc(
|
||||
zones[:i],
|
||||
func(earlier string) bool {
|
||||
return strings.EqualFold(MaskZoneKey(earlier), shown)
|
||||
},
|
||||
)
|
||||
|
||||
switch {
|
||||
case len(zone) > maxZoneLength:
|
||||
@@ -2111,8 +2315,13 @@ func parseWebhookHeaders(value string) (http.Header, string, error) {
|
||||
name, headerValue, found := strings.Cut(item, ":")
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
if !found || !IsHeaderName(name) || strings.ContainsAny(headerValue, "\r\n\x00") {
|
||||
return nil, "", fmt.Errorf("item %d %w", i+1, errNotWebhookHeader)
|
||||
if !found || !IsHeaderName(name) ||
|
||||
strings.ContainsAny(headerValue, "\r\n\x00") {
|
||||
return nil, "", fmt.Errorf(
|
||||
"item %d %w",
|
||||
i+1,
|
||||
errNotWebhookHeader,
|
||||
)
|
||||
}
|
||||
|
||||
headers.Add(name, strings.TrimSpace(headerValue))
|
||||
|
||||
@@ -244,9 +244,9 @@ func TestDefaults(t *testing.T) {
|
||||
wantSettings(t, cfg, config.Config{
|
||||
ListenAddr: ":8080",
|
||||
Observe: false,
|
||||
RateLimitPerMinute: 500,
|
||||
RateLimitPerHour: 20000,
|
||||
RateLimitPerDay: 300000,
|
||||
RateLimitPerMinute: 120,
|
||||
RateLimitPerHour: 1500,
|
||||
RateLimitPerDay: 6000,
|
||||
BanResponse: 403,
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: 24 * time.Hour,
|
||||
@@ -266,8 +266,8 @@ func TestDefaults(t *testing.T) {
|
||||
LookupSource: defaultLookupSource, LookupTimeout: 800 * time.Millisecond,
|
||||
})
|
||||
wantByteLimitSettings(t, cfg, config.Config{
|
||||
BytesLimitPerMinute: 4 << 30, BytesLimitPerHour: 20 << 30,
|
||||
BytesLimitPerDay: 200 << 30, BytesCount: "both",
|
||||
BytesLimitPerMinute: 0, BytesLimitPerHour: 50 << 30,
|
||||
BytesLimitPerDay: 100 << 30, BytesCount: "both",
|
||||
})
|
||||
|
||||
wantNetblocks(t, cfg.AllowNets)
|
||||
@@ -308,7 +308,7 @@ func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) {
|
||||
for _, limit := range []int64{
|
||||
cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.BytesLimitPerDay,
|
||||
} {
|
||||
if largest > limit {
|
||||
if limit != 0 && largest > limit { // 0 is off
|
||||
t.Errorf("a request of %d bytes breaks the byte limit of %d", largest, limit)
|
||||
}
|
||||
}
|
||||
@@ -2271,12 +2271,12 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
rateLimitExemptNets: "",
|
||||
denyNets: "",
|
||||
rateLimitPerMinute: "45",
|
||||
rateLimitPerHour: "20000",
|
||||
rateLimitPerDay: "300000",
|
||||
rateLimitPerHour: "1500",
|
||||
rateLimitPerDay: "6000",
|
||||
rateLimitExemptPaths: "",
|
||||
bytesLimitPerMinute: "4G",
|
||||
bytesLimitPerHour: "20G",
|
||||
bytesLimitPerDay: "200G",
|
||||
bytesLimitPerMinute: "off",
|
||||
bytesLimitPerHour: "50G",
|
||||
bytesLimitPerDay: "100G",
|
||||
bytesCount: "both",
|
||||
lookupSource: defaultLookupSource,
|
||||
lookupDBPath: "",
|
||||
|
||||
+28
-6
@@ -16,6 +16,11 @@ import (
|
||||
"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
|
||||
// before it is read again, so that a file still being copied in is read
|
||||
// only once whole.
|
||||
@@ -133,8 +138,11 @@ func (f *File) Watch(ctx context.Context) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
f.params.ProcessLog.Error("cannot watch the lookup database for replacements",
|
||||
"error", err.Error())
|
||||
f.params.ProcessLog.Error(
|
||||
"cannot watch the lookup database for replacements",
|
||||
"error",
|
||||
err.Error(),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -194,7 +202,10 @@ func (f *File) readAgain() {
|
||||
f.params.Alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventFileError,
|
||||
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())
|
||||
|
||||
@@ -212,7 +223,11 @@ func (f *File) use(reader *maxminddb.Reader) {
|
||||
f.lastRead = f.params.Now()
|
||||
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
|
||||
@@ -221,12 +236,19 @@ func (f *File) use(reader *maxminddb.Reader) {
|
||||
func read(path string) (*maxminddb.Reader, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -19,29 +19,51 @@ const (
|
||||
// ActionForward is a request servewaf allowed, which rproxy passes to the
|
||||
// app.
|
||||
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 = "too_large"
|
||||
|
||||
// ActionRateLimited is a request refused because it took its client
|
||||
// over a rate limit, which bans the client.
|
||||
|
||||
//FIXME: better would be "client_is_rate_limited"
|
||||
ActionRateLimited = "rate_limited"
|
||||
|
||||
// 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
|
||||
// CrowdSec decision list lists its client, each of which bans the
|
||||
// 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"
|
||||
|
||||
// ActionRuleBlocked is a request refused because it matched a block
|
||||
// rule.
|
||||
|
||||
// FIXME: "blocked_by_block_rule"
|
||||
ActionRuleBlocked = "rule_blocked"
|
||||
|
||||
// ActionWAFBlocked is a request refused because the Core Rule Set
|
||||
// scored it at or over SWWAF_WAF_ANOMALY_THRESHOLD.
|
||||
ActionWAFBlocked = "waf_blocked"
|
||||
|
||||
// ActionDenied is a request refused because its client is in
|
||||
// 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
|
||||
// SWWAF_REPUTATION_ACTION is deny.
|
||||
ActionDenied = "denied"
|
||||
|
||||
// ActionCountryDenied is a request refused for its client's country.
|
||||
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,
|
||||
// or a question about a path under /_smallwebwaf/.
|
||||
ActionAdmin = "admin"
|
||||
@@ -245,7 +267,11 @@ func FormatTime(t time.Time) string {
|
||||
|
||||
// Milliseconds is d in milliseconds, to the microsecond.
|
||||
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:
|
||||
@@ -253,17 +279,25 @@ func Milliseconds(d time.Duration) float64 {
|
||||
// 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;
|
||||
// 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{
|
||||
Level: level,
|
||||
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
||||
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 slog.New(handler).With("type", "process", "instance", instanceName)
|
||||
return slog.New(handler).
|
||||
With("type", "process", "instance", instanceName)
|
||||
}
|
||||
@@ -524,7 +524,7 @@ func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testin
|
||||
server, out, _ := newServeWAFWith(t, func(params servewaf.Params) *servewaf.Server {
|
||||
params.AbuseIPDBURL = silentAbuseIPDBURL
|
||||
|
||||
return servewaf.NewServeWAF(params)
|
||||
return servewaf.New(params)
|
||||
}, held, time.Now, map[string]string{
|
||||
deniedCountries: "KP", wafMode: block,
|
||||
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
||||
|
||||
@@ -379,7 +379,7 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
|
||||
// clock of the test's own: the wait lasts exactly as long as it should,
|
||||
// however slowly the test process runs. Nothing in it may wait on the
|
||||
// network, which would keep that clock from moving on: the request is
|
||||
// handed to the proxy's handler, and GeoJS is one that never answers.
|
||||
// handed to servewaf's handler, and GeoJS is one that never answers.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
// Not the default. The exclusive list needs the answer, and the app
|
||||
// is never reached.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Package servewaf is the server the servewaf command runs. It answers each of
|
||||
// rproxy's questions with what to do with the request, and takes rproxy's
|
||||
// report once the visitor's answer has ended, writing the request's log line.
|
||||
// It also answers servewaf's own endpoints: the health check, the metrics, the
|
||||
// bans and the clients.
|
||||
// Package servewaf is the server the servewaf command runs. It answers rproxy's
|
||||
// questions, takes rproxy's reports and answers its own endpoints, and writes a
|
||||
// log line for each.
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
@@ -45,7 +43,7 @@ const BansPath = "/_smallwebwaf/bans"
|
||||
// client's address after it, with SWWAF_ADMIN_TOKEN.
|
||||
const ClientsPath = "/_smallwebwaf/clients/"
|
||||
|
||||
// Params are what NewServeWAF needs.
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
Config *config.Config
|
||||
// RequestLog receives two JSON lines per question, one at its decision and
|
||||
|
||||
@@ -404,7 +404,7 @@ func newServeWAF(
|
||||
) (*servewaf.Server, *output, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
return newServeWAFWith(t, servewaf.NewServeWAF, geojsURL, now, env)
|
||||
return newServeWAFWith(t, servewaf.New, geojsURL, now, env)
|
||||
}
|
||||
|
||||
// newServeWAFWith is newServeWAF for the server build makes.
|
||||
|
||||
@@ -617,7 +617,7 @@ const (
|
||||
// AbuseIPDB.
|
||||
const abuseipdb = reputation.AbuseIPDBSource
|
||||
|
||||
// abuseIPDBURL is where newProxy has clients checked with AbuseIPDB: at
|
||||
// abuseIPDBURL is where newServeWAF has clients checked with AbuseIPDB: at
|
||||
// abuseIPDBStandIn, which TestMain registers with Go's default transport,
|
||||
// through which AbuseIPDB is asked.
|
||||
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
|
||||
|
||||
@@ -103,13 +103,13 @@ type Report struct {
|
||||
DurationUpstreamTotal float64 `json:"duration_upstream_total"`
|
||||
}
|
||||
|
||||
// NewServeWAF returns the server `smallwebwaf servewaf` runs. It answers
|
||||
// New returns the server `smallwebwaf servewaf` runs. It answers
|
||||
// rproxy's questions, takes rproxy's reports and answers its own endpoints. It
|
||||
// passes nothing to an app. rproxy applies the size and time limits to the
|
||||
// visitor. Go's default limit on a request's line and headers, 1 MiB, stays: it
|
||||
// is far above rproxy's, and a question refused for its size would have rproxy
|
||||
// let the request through.
|
||||
func NewServeWAF(params Params) *Server {
|
||||
func New(params Params) *Server {
|
||||
h := newHandler(params)
|
||||
|
||||
return &Server{
|
||||
|
||||
@@ -207,7 +207,7 @@ func newServer(
|
||||
}
|
||||
}
|
||||
|
||||
server := servewaf.NewServeWAF(servewaf.Params{
|
||||
server := servewaf.New(servewaf.Params{
|
||||
Config: cfg,
|
||||
RequestLog: stdout,
|
||||
ProcessLog: processLog,
|
||||
|
||||
@@ -1225,9 +1225,9 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
|
||||
"SWWAF_ALLOW_NETS": "",
|
||||
rateLimitExemptNets: "",
|
||||
"SWWAF_DENY_NETS": "",
|
||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "500",
|
||||
"SWWAF_RATE_LIMIT_PER_HOUR": "20000",
|
||||
rateLimitPerDay: "300000",
|
||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "120",
|
||||
"SWWAF_RATE_LIMIT_PER_HOUR": "1500",
|
||||
rateLimitPerDay: "6000",
|
||||
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
|
||||
"SWWAF_DENIED_COUNTRIES": "",
|
||||
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
||||
|
||||
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