Remove what is about deploying behind Gitea (closes #209)
check / check (push) Successful in 3m15s
check / check (push) Successful in 3m15s
Delete the Gitea compose setup, its document, its two scripts and their make targets. Documents and tests no longer use Gitea as the example deployment. The Core Rule Set exclusions for Gitea's false positives in internal/waf stay, with their comments and tests. Model: opus-5-5
This commit is contained in:
+3
-3
@@ -153,15 +153,15 @@ Closest existing options, and why each still falls short:
|
||||
- What it is: a single-binary reverse proxy that makes browsers solve a
|
||||
proof-of-work challenge before passing them to `TARGET`. Aimed at
|
||||
scrapers, which is likely a large share of unwanted traffic on a public
|
||||
gitea.
|
||||
code forge.
|
||||
- Covers: R10 well (one container, env vars for listener, target,
|
||||
difficulty, cookies). Policy rules can match on path, user agent, headers,
|
||||
IP ranges, and with the vendor's hosted data service also AS number and
|
||||
country, and can weigh a request toward a harder challenge.
|
||||
- Misses: R1, R3, R4, R5, R6, R9. Bot policy needs a YAML file, not env
|
||||
vars. AS number and country matching depend on the vendor's hosted
|
||||
service. Breaks non-browser clients unless paths are exempted; for gitea,
|
||||
git-over-HTTP and API paths must be allowed through by rule.
|
||||
service. Breaks non-browser clients unless paths are exempted; for a code
|
||||
forge, git-over-HTTP and API paths must be allowed through by rule.
|
||||
- Maturity: very active, widely deployed on code forges since 2025.
|
||||
- Value here: complementary. It can be chained (traefik, then the sidecar,
|
||||
then Anubis, then the app) if challenge pages are wanted.
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
||||
run example-app gitea-requests gitea-setup
|
||||
run example-app
|
||||
|
||||
# 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;
|
||||
# gitea-setup checks the setup in deploy/gitea.
|
||||
# example-app checks the image with an app built on it.
|
||||
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
@@ -47,9 +45,3 @@ run:
|
||||
|
||||
example-app:
|
||||
@script/example-app
|
||||
|
||||
gitea-requests:
|
||||
@script/gitea-requests
|
||||
|
||||
gitea-setup:
|
||||
@script/gitea-setup
|
||||
|
||||
@@ -100,8 +100,8 @@ Point traefik's labels at port 8080 of `rproxy`
|
||||
of the app or of `servewaf`. Anything on that network can reach the app around
|
||||
`rproxy`, and can ask `servewaf` questions, so the network holds only what you
|
||||
trust. Each further app adds its own container and its own `rproxy`, whose
|
||||
`SWRP_WAF_BACKEND` names the same `servewaf`. The "Smallest setup" and "In front
|
||||
of gitea" examples below show this shape.
|
||||
`SWRP_WAF_BACKEND` names the same `servewaf`. The "Smallest setup" example below
|
||||
shows this shape.
|
||||
|
||||
### `rproxy` in the app's container
|
||||
|
||||
@@ -338,9 +338,8 @@ full, and the limits that are fixed rather than settings.
|
||||
Each example is a complete `compose.yaml`. `smallwebwaf` is the tag
|
||||
`make docker` gives the image; on another host, use the name you pushed it
|
||||
under, by digest. Docker Compose puts the services on a private network of its
|
||||
own, where each reaches the others by service name. Only "In front of gitea"
|
||||
uses traefik. The other three publish `rproxy`'s port on the host, for trying it
|
||||
out.
|
||||
own, where each reaches the others by service name. Each publishes `rproxy`'s
|
||||
port on the host, for trying it out.
|
||||
|
||||
### Smallest setup
|
||||
|
||||
@@ -426,88 +425,6 @@ volumes:
|
||||
servewaf-state:
|
||||
```
|
||||
|
||||
### In front of gitea
|
||||
|
||||
gitea behind traefik, set up for a code forge:
|
||||
|
||||
- the upload limits raised, in size and in time;
|
||||
- the response size limit raised, for clones over 1 GiB;
|
||||
- the Actions runners exempt from counting;
|
||||
- the default request and byte limits kept.
|
||||
|
||||
`traefik` is the network traefik is on, made outside this file.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gitea:
|
||||
# gitea 1.27.3, 2026-08-29
|
||||
image: gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef
|
||||
environment:
|
||||
GITEA__server__ROOT_URL: https://git.example/
|
||||
# The host and the port of the SSH clone URLs gitea shows.
|
||||
GITEA__server__SSH_DOMAIN: git.example
|
||||
GITEA__server__SSH_PORT: "2222"
|
||||
# gitea's TCP peer is rproxy, on the private network.
|
||||
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
networks:
|
||||
- site
|
||||
ports:
|
||||
# SSH does not pass through smallwebwaf.
|
||||
- "2222:22"
|
||||
|
||||
rproxy:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
||||
SWRP_UPSTREAM_URL: http://gitea:3000
|
||||
# Pushes, LFS objects and image layers over 100 MiB, or slower
|
||||
# than a minute, are cut off at the defaults.
|
||||
SWRP_MAX_REQUEST_SIZE: 2G
|
||||
SWRP_MAX_REQUEST_TIME_MS: "600000"
|
||||
# A clone larger than 1 GiB is cut off at the default.
|
||||
SWRP_MAX_RESPONSE_SIZE: 4G
|
||||
networks:
|
||||
- site
|
||||
- traefik
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
# rproxy is on two networks; traefik reaches it on this one.
|
||||
traefik.docker.network: traefik
|
||||
traefik.http.routers.git.rule: Host(`git.example`)
|
||||
traefik.http.routers.git.entrypoints: websecure
|
||||
traefik.http.routers.git.tls.certresolver: letsencrypt
|
||||
traefik.http.services.git.loadbalancer.server.port: "8080"
|
||||
|
||||
servewaf:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SMALLWEBWAF_COMMAND: servewaf
|
||||
SWWAF_INSTANCE_NAME: fsn1/servewaf
|
||||
# The Actions runners ask for work all day; never count them.
|
||||
SWWAF_RATE_LIMIT_EXEMPT_NETS: 10.0.4.0/24
|
||||
# gitea never serves these; a request for one bans the client.
|
||||
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php,/wp-admin/
|
||||
volumes:
|
||||
- servewaf-state:/var/lib/smallwebwaf
|
||||
networks:
|
||||
- site
|
||||
|
||||
networks:
|
||||
site:
|
||||
traefik:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
gitea-data:
|
||||
servewaf-state:
|
||||
```
|
||||
|
||||
`deploy/gitea` holds a complete setup of gitea, traefik included, as a Docker
|
||||
Compose file. [`docs/gitea.md`](docs/gitea.md) explains it, and records what the
|
||||
defaults do in front of gitea, request by request.
|
||||
|
||||
### `rproxy` beside the app
|
||||
|
||||
The app is built on the `smallwebwaf` image, with `rproxy` inside its container.
|
||||
@@ -771,18 +688,6 @@ on the host.
|
||||
- orderly stops with `sv stop` and `docker stop`;
|
||||
- bans surviving a new container on the same volume;
|
||||
- a setting given as a file.
|
||||
- `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind
|
||||
`rproxy` and `servewaf`, three containers on one network. Every setting is at
|
||||
its default, but for those that join the containers and
|
||||
`SWWAF_LOG_REQUEST_HEADERS`, which adds the header that labels each request.
|
||||
It sends through them a sample of gitea's ordinary use and the requests the
|
||||
Core Rule Set is known to refuse, and prints what `servewaf` did with each.
|
||||
Needs network access, and a docker daemon that pushes to a registry on
|
||||
`localhost` over plain HTTP. `make gitea-requests`.
|
||||
- `script/gitea-setup`: builds the image, brings up the setup in `deploy/gitea`,
|
||||
and checks it as [`docs/gitea.md`](docs/gitea.md) describes. Then it removes
|
||||
the containers, the network, the volumes and the image it built. Needs network
|
||||
access. `make gitea-setup`.
|
||||
|
||||
## TODO
|
||||
|
||||
@@ -806,8 +711,6 @@ 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: the setup
|
||||
in `deploy/gitea`, and what the defaults do there.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -402,10 +402,10 @@ scrapers that send many requests slowly over hours or days. The hour limit is
|
||||
the main one.
|
||||
|
||||
- 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
|
||||
pages at once with an empty cache. A web app's 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
|
||||
- Per hour: 1,500. A person reading a site 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.
|
||||
@@ -417,7 +417,7 @@ 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.
|
||||
- A CI runner asks its code forge for work all day and can pass the limits.
|
||||
- Everyone behind one shared address, such as an office, shares one client's
|
||||
limits.
|
||||
|
||||
@@ -550,12 +550,11 @@ service's abuse comes from.
|
||||
By default the Core Rule Set reads the method, the URL with its query string,
|
||||
and the headers of every request. That is where automated attacks show:
|
||||
injection in query strings, path traversal, attacks carried in headers,
|
||||
scanners' user agents. It reads no request body. In front of a code forge such
|
||||
as gitea, bodies carry what people write and publish, such as issue text and
|
||||
files saved in the web editor. The Core Rule Set takes the shell commands and
|
||||
code in them for attacks. The default gives up refusing an attack carried in a
|
||||
body. The rule files, the limits and the bans still apply to the client that
|
||||
sends it.
|
||||
scanners' user agents. It reads no request body. In front of a code forge,
|
||||
bodies carry what people write and publish, such as issue text and files saved
|
||||
in the web editor. The Core Rule Set takes the shell commands and code in them
|
||||
for attacks. The default gives up refusing an attack carried in a body. The rule
|
||||
files, the limits and the bans still apply to the client that sends it.
|
||||
|
||||
- The Core Rule Set skips a path under `SWWAF_WAF_EXEMPT_PATHS`.
|
||||
- `SWWAF_WAF_PARANOIA_LEVEL` and `SWWAF_WAF_ANOMALY_THRESHOLD` are the Core Rule
|
||||
@@ -591,15 +590,14 @@ sends it.
|
||||
data, where binary content such as a git push trips rules written for text. A
|
||||
body the Core Rule Set cannot parse adds 5 to the score, and so does a
|
||||
multipart body cut by the limit in the middle of a part's header. Body
|
||||
inspection suits apps whose forms carry no code; `docs/gitea.md` records what
|
||||
it refuses in front of gitea.
|
||||
inspection suits apps whose forms carry no code.
|
||||
- `SWWAF_TRAP_PATHS` names paths the app never serves and only scanners ask for,
|
||||
such as `/wp-login.php` in front of gitea. This is the short form of a `path`
|
||||
rule with the `ban` action, for deployments that mount no rule files. A trap
|
||||
path is matched whole, without the query, against the path as sent and once
|
||||
percent-decoded: `/wp-login.php` matches `/wp-login.php?x=1` and
|
||||
`/%77p-login.php`, and does not match `/wp-login.php/` or
|
||||
`/blog/wp-login.php`.
|
||||
such as `/wp-login.php` in front of an app that is not WordPress. This is the
|
||||
short form of a `path` rule with the `ban` action, for deployments that mount
|
||||
no rule files. A trap path is matched whole, without the query, against the
|
||||
path as sent and once percent-decoded: `/wp-login.php` matches
|
||||
`/wp-login.php?x=1` and `/%77p-login.php`, and does not match `/wp-login.php/`
|
||||
or `/blog/wp-login.php`.
|
||||
- `SWWAF_ERROR_BURST_THRESHOLD` counts a client's refusals per minute of these
|
||||
kinds, and of no other:
|
||||
- a rule file match;
|
||||
@@ -609,8 +607,8 @@ sends it.
|
||||
More than this breaks a limit (see "Bans"). A client trying one attack after
|
||||
another is refused many times a minute; a person rarely more than a few
|
||||
times. Answers the app gives are not counted, since they do not tell a
|
||||
scanner from an ordinary client. In front of gitea, registry and git clients
|
||||
get `404` and `401` by design, hundreds of times a minute.
|
||||
scanner from an ordinary client. In front of a code forge, registry and git
|
||||
clients get `404` and `401` by design, hundreds of times a minute.
|
||||
|
||||
`smallwebwaf` changes the Core Rule Set 4.25.0 in six ways, since in front of
|
||||
gitea it would otherwise refuse ordinary requests. The changes hold in front of
|
||||
@@ -836,7 +834,7 @@ and the default file.
|
||||
runs. It reads the files again once the directory has had no change for 2
|
||||
seconds, so that a file still being written is read whole. A file whose name
|
||||
starts with `.`, such as an editor's lock file, is not a rule file.
|
||||
- Files are read in name order (`00-default.rules` before `50-gitea.rules`),
|
||||
- Files are read in name order (`00-default.rules` before `50-site.rules`),
|
||||
rules in line order.
|
||||
- A rule's regex is Go's RE2 syntax. It has no backreferences or lookaround; in
|
||||
exchange matching time is linear in the input, so no rule can be made to stall
|
||||
@@ -865,14 +863,15 @@ and the default file.
|
||||
|
||||
- The image ships a default file, kept short and limited to patterns that are
|
||||
wrong for every app, with its path rules anchored at the site root. In front
|
||||
of gitea, `/.env` is a probe, while
|
||||
of a code forge, `/.env` is a probe, while
|
||||
`/<owner>/<repo>/src/branch/main/.env.example` is a file in a repository that
|
||||
any visitor may open. Anchor the paths of your own `ban` rules at the site
|
||||
root too. Anything else, such as a rule banning WordPress probes in front of
|
||||
gitea, belongs in a file the deployer adds to the image `servewaf` runs from,
|
||||
or mounts. A rule has one target, so a `path` rule applies on every site
|
||||
behind that `servewaf`. A directory mounted over `SWWAF_RULES_DIR` replaces
|
||||
the default file; single files mounted into it add to it.
|
||||
an app that is not WordPress, belongs in a file the deployer adds to the image
|
||||
`servewaf` runs from, or mounts. A rule has one target, so a `path` rule
|
||||
applies on every site behind that `servewaf`. A directory mounted over
|
||||
`SWWAF_RULES_DIR` replaces the default file; single files mounted into it add
|
||||
to it.
|
||||
- Rules meant to be edited while `servewaf` runs belong in a mounted directory,
|
||||
not a mounted file. Many editors save a file by replacing it, and a container
|
||||
keeps seeing the old one when the file alone is mounted. The lookup database
|
||||
@@ -1695,8 +1694,7 @@ with no change to upaas. upaas builds the app's Dockerfile and runs the one
|
||||
container as it runs any app. The traefik labels route to port 8080, and the
|
||||
`SWRP_` settings go with the app's environment variables. `servewaf` runs in a
|
||||
container of its own, on a network the app's container can reach, with its state
|
||||
volume and the directory of any token file. `docs/gitea.md` records what the
|
||||
defaults do in front of gitea, and `script/gitea-requests` checks those notes.
|
||||
volume and the directory of any token file.
|
||||
|
||||
## Planned, not built
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# Gitea behind traefik, with rproxy and servewaf in front of it.
|
||||
# docs/gitea.md says what each setting here does and why. Before starting it:
|
||||
# - set SMALLWEBWAF_IMAGE to the smallwebwaf image, named by digest;
|
||||
# - replace git.example.org with the site's host name;
|
||||
# - replace each 10.99.0 with the first three numbers of a /24 subnet that
|
||||
# no other network on the host uses. The /24 must lie inside 10.0.0.0/8,
|
||||
# 172.16.0.0/12 or 192.168.0.0/16, because rproxy believes
|
||||
# X-Forwarded-For only from those by default. Replace each fd10:99 with
|
||||
# the first two hextets of an unused /64 subnet whose third and fourth
|
||||
# hextets are 0, as in fd10:99::/64;
|
||||
# - set GITEA_SECRET_KEY to the output of
|
||||
# `docker run --rm <gitea's image below> gitea generate secret SECRET_KEY`,
|
||||
# once per install, and keep it.
|
||||
# Then run `docker compose up --detach` in this directory.
|
||||
# script/gitea-setup checks this setup.
|
||||
|
||||
name: gitea
|
||||
|
||||
x-logging: &logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: "5"
|
||||
|
||||
services:
|
||||
traefik:
|
||||
# traefik v3.7.17, 2026-11-03
|
||||
image: traefik@sha256:68ae5fe5b64ca166704b746a9e8afcd385e36b3f569a5691401bf652d57b3748
|
||||
# traefik replaces the X-Forwarded-For header a visitor sends. Keep it
|
||||
# so: otherwise a visitor could name an address on the shared network,
|
||||
# and skip servewaf's checks.
|
||||
command:
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.websecure.address=:443
|
||||
# Until a certificate resolver is added here, as for your other
|
||||
# sites, traefik answers with a certificate it made itself.
|
||||
- --entrypoints.websecure.http.tls=true
|
||||
# traefik's own limit of 60 seconds for a whole request would cut
|
||||
# off an upload long before rproxy's limit of 30 minutes.
|
||||
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=30m
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
shared:
|
||||
# Containers on the shared network find traefik under the
|
||||
# site's name, and so reach it from their own address.
|
||||
aliases:
|
||||
- git.example.org
|
||||
restart: unless-stopped
|
||||
|
||||
gitea:
|
||||
# gitea 1.27.3, 2026-08-29
|
||||
image: gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef
|
||||
environment:
|
||||
GITEA__server__ROOT_URL: https://git.example.org/
|
||||
# Gitea starts at its defaults, with SQLite, instead of showing its
|
||||
# install page to whoever opens it first.
|
||||
GITEA__security__INSTALL_LOCK: "true"
|
||||
GITEA__security__SECRET_KEY: ${GITEA_SECRET_KEY:?set GITEA_SECRET_KEY, see docs/gitea.md}
|
||||
# Accounts are made with `gitea admin user create`.
|
||||
GITEA__service__DISABLE_REGISTRATION: "true"
|
||||
# rproxy's addresses. Gitea then takes each visitor's address from
|
||||
# the X-Real-Ip header traefik sets and rproxy passes on.
|
||||
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.99.0.2,fd10:99::2
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
networks:
|
||||
- shared
|
||||
restart: unless-stopped
|
||||
|
||||
rproxy:
|
||||
image: ${SMALLWEBWAF_IMAGE:?set SMALLWEBWAF_IMAGE to the smallwebwaf image}
|
||||
environment:
|
||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
||||
SWRP_UPSTREAM_URL: http://gitea:3000
|
||||
# Pushes, LFS objects, packages and release attachments of up to
|
||||
# 2 GiB, sent in up to 30 minutes.
|
||||
SWRP_MAX_REQUEST_SIZE: 2G
|
||||
SWRP_MAX_REQUEST_TIME_MS: "1800000"
|
||||
# 15 minutes for a clone or a download, half the default.
|
||||
SWRP_MAX_RESPONSE_TIME_MS: "900000"
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.gitea.rule: Host(`git.example.org`)
|
||||
traefik.http.routers.gitea.entrypoints: websecure
|
||||
traefik.http.services.gitea.loadbalancer.server.port: "8080"
|
||||
networks:
|
||||
shared:
|
||||
ipv4_address: 10.99.0.2
|
||||
ipv6_address: fd10:99::2
|
||||
logging: *logging
|
||||
restart: unless-stopped
|
||||
|
||||
servewaf:
|
||||
image: ${SMALLWEBWAF_IMAGE:?set SMALLWEBWAF_IMAGE to the smallwebwaf image}
|
||||
environment:
|
||||
SMALLWEBWAF_COMMAND: servewaf
|
||||
# The containers on the shared network skip the checks. Add the
|
||||
# address of an automation fleet on another host here too; without
|
||||
# that, it is counted like any client.
|
||||
SWWAF_ALLOW_NETS: 10.99.0.128/25,fd10:99:0:0:8000::/65
|
||||
# In block mode the Core Rule Set refuses everyday Gitea searches
|
||||
# and file names, and 31 refusals in a minute ban the address.
|
||||
SWWAF_WAF_MODE: detect
|
||||
volumes:
|
||||
- servewaf-state:/var/lib/smallwebwaf
|
||||
networks:
|
||||
- shared
|
||||
logging: *logging
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
# The network traefik and Gitea share. Its containers get addresses in the
|
||||
# upper half of each subnet, which servewaf lets skip the checks. Each
|
||||
# subnet's gateway, the host's own address on it, stays out of that half:
|
||||
# requests docker passes on from a published port can come from it.
|
||||
shared:
|
||||
enable_ipv6: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 10.99.0.0/24
|
||||
ip_range: 10.99.0.128/25
|
||||
gateway: 10.99.0.1
|
||||
- subnet: fd10:99::/64
|
||||
ip_range: fd10:99:0:0:8000::/65
|
||||
gateway: fd10:99::1
|
||||
|
||||
volumes:
|
||||
gitea-data:
|
||||
servewaf-state:
|
||||
-340
@@ -1,340 +0,0 @@
|
||||
# smallwebwaf in front of gitea
|
||||
|
||||
`deploy/gitea/compose.yaml` runs gitea behind traefik, with `rproxy` and
|
||||
`servewaf` in front of it. This document says what that setup sets and why, and
|
||||
what the defaults would do without it. [`SPEC.md`](../SPEC.md) says how
|
||||
`smallwebwaf` behaves.
|
||||
|
||||
## The setup
|
||||
|
||||
Four containers share one Docker network, the shared network:
|
||||
|
||||
- traefik, which routes the site's HTTPS requests to `rproxy`, and redirects
|
||||
plain HTTP to HTTPS;
|
||||
- `rproxy`, in front of gitea;
|
||||
- gitea 1.27.3, as its image comes;
|
||||
- `servewaf`, with a volume for its state files.
|
||||
|
||||
To start it:
|
||||
|
||||
1. In `deploy/gitea/compose.yaml`, replace `git.example.org` with the site's
|
||||
host name.
|
||||
2. Replace each `10.99.0` with the first three numbers of a `/24` subnet that no
|
||||
other network on the host uses. The `/24` must lie inside `10.0.0.0/8`,
|
||||
`172.16.0.0/12` or `192.168.0.0/16`, because `rproxy` believes
|
||||
`X-Forwarded-For` only from those by default. Replace each `fd10:99` with the
|
||||
first two hextets of an unused `/64` subnet whose third and fourth hextets
|
||||
are 0, as in `fd10:99::/64`.
|
||||
3. Set `SMALLWEBWAF_IMAGE` to the `smallwebwaf` image, named by digest.
|
||||
4. Set `GITEA_SECRET_KEY` to the output of
|
||||
`docker run --rm <gitea's image> gitea generate secret SECRET_KEY`, with
|
||||
gitea's image as `deploy/gitea/compose.yaml` names it. Do this once per
|
||||
install, and keep the key.
|
||||
5. Run `docker compose up --detach` in `deploy/gitea`.
|
||||
6. Make gitea's first user with `gitea admin user create`, in gitea's container,
|
||||
as the user `git`.
|
||||
|
||||
Sign-up is off: accounts are made with `gitea admin user create`. gitea starts
|
||||
at its defaults, with SQLite. traefik answers with a certificate it made itself
|
||||
until you add a certificate resolver, as for your other sites. SSH to gitea does
|
||||
not pass through `smallwebwaf`; publish gitea's SSH port yourself if you use it.
|
||||
|
||||
`make gitea-setup` checks the setup. It builds the image, brings the setup up
|
||||
under a name of its own, and checks:
|
||||
|
||||
- that a page load, a push and a clone sent through traefik pass through
|
||||
`rproxy`, and that `servewaf` checks them;
|
||||
- that gitea logs the visitor's address, not `rproxy`'s;
|
||||
- that a request from a container on the shared network skips the checks;
|
||||
- that with `servewaf` stopped, gitea still answers through `rproxy`.
|
||||
|
||||
## Uploads and downloads
|
||||
|
||||
An upload is a request:
|
||||
|
||||
- a git push;
|
||||
- an LFS object;
|
||||
- a container image layer;
|
||||
- a package file;
|
||||
- a release attachment.
|
||||
|
||||
The setup lets an upload of up to 2 GiB, sent in up to 30 minutes, through:
|
||||
|
||||
- `SWRP_MAX_REQUEST_SIZE` is `2G`. At the default, an upload over 100 MiB is cut
|
||||
off.
|
||||
- `SWRP_MAX_REQUEST_TIME_MS` is `1800000`, 30 minutes. At the default, an upload
|
||||
that takes over 60 seconds is cut off.
|
||||
- traefik's `readTimeout` is 30 minutes. traefik's own default of 60 seconds for
|
||||
a whole request would cut off an upload first.
|
||||
|
||||
A clone or a download is a response:
|
||||
|
||||
- `SWRP_MAX_RESPONSE_TIME_MS` is `900000`, 15 minutes, half the default. A clone
|
||||
or download that takes longer is cut off.
|
||||
- `SWRP_MAX_RESPONSE_SIZE` stays at its default, 1 GiB. A repository whose clone
|
||||
is larger needs it raised.
|
||||
|
||||
The byte limits stay at their defaults: no minute limit, 50 GiB an hour and 100
|
||||
GiB a day.
|
||||
|
||||
## The checks
|
||||
|
||||
`SWWAF_WAF_MODE` is `detect`. The Core Rule Set then logs a match, with its rule
|
||||
ids, and refuses nothing. In `block` mode, the default, it refuses everyday
|
||||
gitea searches and file names. The section "What the Core Rule Set refuses in
|
||||
`block` mode" below lists them. Each such refusal counts toward the error burst,
|
||||
so 31 in one minute ban the address.
|
||||
|
||||
Every other check stays at its default. A client may make 120 requests a minute,
|
||||
1,500 an hour and 6,000 a day. Archive downloads and blame and history pages are
|
||||
what scrapers hammer, and these limits do most of the work there.
|
||||
|
||||
## The shared network
|
||||
|
||||
Containers on the shared network skip `servewaf`'s checks:
|
||||
|
||||
- The shared network's subnets are `10.99.0.0/24` and `fd10:99::/64`. Docker
|
||||
gives its containers addresses in the upper half of each, `10.99.0.128/25` and
|
||||
`fd10:99:0:0:8000::/65`, which `SWWAF_ALLOW_NETS` names.
|
||||
- traefik is known on the shared network by the site's host name. So a container
|
||||
there that asks for the site reaches traefik from its own address. An
|
||||
automation fleet in such a container skips the checks.
|
||||
- Each subnet's gateway, `10.99.0.1` and `fd10:99::1`, is the host's own address
|
||||
on it. Requests docker passes on from a published port can come from the
|
||||
gateway, so the gateways are left out of `SWWAF_ALLOW_NETS`. The network has
|
||||
IPv6, so a visitor over IPv6 reaches traefik from its own address, and
|
||||
`servewaf` checks it as itself.
|
||||
- A container on the shared network that asks for the site at the host's public
|
||||
address arrives from the gateway. It is checked like any visitor.
|
||||
- A fleet on another host can have its address added to `SWWAF_ALLOW_NETS`.
|
||||
Without that, it is counted like any client.
|
||||
- traefik replaces the `X-Forwarded-For` a visitor sends, by default. Keep it
|
||||
so: otherwise a visitor could name an address on the shared network, and skip
|
||||
the checks.
|
||||
- Anything on the shared network can reach gitea and `servewaf` around `rproxy`.
|
||||
Put only containers you trust on it.
|
||||
|
||||
## gitea's settings
|
||||
|
||||
- `REVERSE_PROXY_TRUSTED_PROXIES` names `rproxy`'s fixed addresses, `10.99.0.2`
|
||||
and `fd10:99::2`. gitea then takes each visitor's address from the `X-Real-Ip`
|
||||
header, which traefik sets and `rproxy` passes on. Without it, gitea logs
|
||||
every visitor as `rproxy`.
|
||||
- `INSTALL_LOCK` is `true`, as "The setup" above says.
|
||||
|
||||
## Methods
|
||||
|
||||
`rproxy` passes on only these methods: `GET`, `HEAD`, `POST`, `OPTIONS`, `PUT`,
|
||||
`DELETE`, `PATCH` and `QUERY`. gitea 1.27.3 needs no other. Its routes use:
|
||||
|
||||
- pages and the API: `GET`, `HEAD`, `POST`, `PUT`, `PATCH` and `DELETE`;
|
||||
- git's push, clone and fetch over HTTP: `GET`, `POST` and `OPTIONS`;
|
||||
- LFS: `GET`, `POST` and `PUT`;
|
||||
- the container registry and the other package registries: `GET`, `HEAD`,
|
||||
`POST`, `PUT`, `PATCH` and `DELETE`;
|
||||
- Actions runners: `POST`.
|
||||
|
||||
## The log
|
||||
|
||||
`rproxy` and `servewaf` write each query string to the log whole. A token sent
|
||||
in the query string, as `?token=`, is then in the log. Send it in the
|
||||
`Authorization` header instead.
|
||||
|
||||
## Actions runners
|
||||
|
||||
A runner on the shared network skips the checks. One on another host is counted
|
||||
like any client. A runner older than version 0.4 (April 2026) asks for work
|
||||
every 2 seconds, 1,800 requests an hour. While it runs a job, it reports the
|
||||
job's log and state every second instead, two requests a second.
|
||||
|
||||
So one idle runner makes 1,800 requests an hour, more than the hour limit of
|
||||
1,500. A busy one, at two requests a second, passes the minute limit of 120
|
||||
within two minutes. Its address is then banned, its running jobs fail, and each
|
||||
repeat within a day makes the ban longer. Newer runners ask for work less often.
|
||||
Put the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||
|
||||
## What the Core Rule Set refuses in `block` mode
|
||||
|
||||
These notes record what the Core Rule Set does in front of gitea in `block`
|
||||
mode, at the other defaults. They are observations, not requirements.
|
||||
`script/gitea-requests` runs gitea behind `rproxy` and `servewaf` at the
|
||||
defaults. It sends through them a sample of gitea's ordinary use and the
|
||||
requests named below. It reports each request the Core Rule Set refuses, so that
|
||||
these notes can be checked again when gitea or the Core Rule Set changes
|
||||
version. In `detect` mode, as the setup runs it, the same requests are logged
|
||||
with their rule ids, and pass.
|
||||
|
||||
Each refusal answers only that request ("Bans" in `SPEC.md` says what it counts
|
||||
toward). The request log names the rule in `waf_rule_ids`, which
|
||||
`SWWAF_WAF_DISABLED_RULES` can switch off.
|
||||
|
||||
### What it lets through
|
||||
|
||||
The Core Rule Set lets gitea's ordinary use through, apart from the refusals in
|
||||
the sections after this one:
|
||||
|
||||
- browsing and views of files in a repository, with their history, blame and the
|
||||
file tree;
|
||||
- diffs, including their hidden lines and large files, and pull request review;
|
||||
- git's clone, fetch and push over HTTP;
|
||||
- signing in, including the return to the page a visitor came from, and sign-in
|
||||
with Git Credential Manager, git-credential-oauth or tea;
|
||||
- the API's calls for a file and its commits;
|
||||
- pushing and pulling container images and packages;
|
||||
- Actions runners, and artifacts uploaded with `actions/upload-artifact@v4`;
|
||||
- posting issues, pull requests, comments, wiki pages and files saved in the web
|
||||
editor, code included, since no body is read;
|
||||
- the page gitea shows after a change, with its message naming what was done.
|
||||
|
||||
### A query string that reads as an attack
|
||||
|
||||
Most often this is a search. A search term is a query parameter, which the Core
|
||||
Rule Set checks against its lists of system files and commands (see the fourth
|
||||
change under "Attack detection" in `SPEC.md`). A release attachment uploaded
|
||||
through the API as `docker-compose.yml` is refused the same way. Examples:
|
||||
|
||||
- a name on those lists, such as `package.json`, `.gitignore` or
|
||||
`docker-compose.yml`;
|
||||
- text that starts with a command name, such as `python3` or `ssh key`, or has
|
||||
one right after a `/` in its first word, such as `feat/docker-support`;
|
||||
- a shell command with its options or a system path (`ls -la`, `sed -i`,
|
||||
`/bin/sh`), or a command in backticks;
|
||||
- script code (`fetch(`, `${VAR}`, `process.env`), HTML (`<img src=`) or an SQL
|
||||
statement (`SELECT * FROM users WHERE`);
|
||||
- a URL naming an IP address or `localhost`.
|
||||
|
||||
### A branch, tag or file name that starts with a command name
|
||||
|
||||
The Core Rule Set's list of commands (932260) holds names such as `docker-`,
|
||||
`python3`, `ansible`, `base64` and `whoami`. A query parameter holding a branch,
|
||||
tag or file name that starts with one is refused. So is one that has such a name
|
||||
right after a `/`, such as `feat/docker-support`, `fix/docker-build` or
|
||||
`renovate/docker-build-push-action-6.x`. gitea's own pages send the name in a
|
||||
query parameter that 930120, 932160 and 932260 still check:
|
||||
|
||||
- `refSubUrl`, the branch or tag of a directory listing. A listing sends it when
|
||||
it asks for its entries' last commits in a second request. It does so whenever
|
||||
gitea takes more than a second to work them out;
|
||||
- `name`, when a branch is deleted or restored on the branches page;
|
||||
- `tag`, when a release is started from a tag;
|
||||
- `template`, the file of an issue template, when an issue is opened from it;
|
||||
- `rule_name`, when a branch protection rule is opened for editing in the
|
||||
repository's settings or has just been saved.
|
||||
|
||||
What a visitor sees, for a branch named `docker-build`:
|
||||
|
||||
- A directory listing that makes that second request leaves those last commits
|
||||
out and shows an error.
|
||||
- The branches page shows an error instead of deleting or restoring the branch.
|
||||
- A release started from such a tag gets the `403` answer of `smallwebwaf` in
|
||||
place of its form. So does an issue opened from such a template, and such a
|
||||
rule opened for editing.
|
||||
- Saving such a rule stores it. gitea then returns to the branch settings page,
|
||||
which gets the `403` answer, so the save looks as if it failed.
|
||||
|
||||
### A file name in a query parameter that trips the code rules
|
||||
|
||||
The Core Rule Set's rules for Java, script, PowerShell and SQL find what they
|
||||
look for in some file names. This holds too in the ten query parameters that
|
||||
930120, 932160 and 932260 do not check (see the fourth change under "Attack
|
||||
detection" in `SPEC.md`). Examples:
|
||||
|
||||
- `runtime` in OpenJDK's `src/java.base/share/classes/java/lang/Runtime.java`
|
||||
(944110);
|
||||
- a Java class, as in `java.lang.String.md` (944130);
|
||||
- `document.write.js` (941180);
|
||||
- `process.env.d.ts` (934100);
|
||||
- `__proto__` (934130);
|
||||
- the PowerShell command `Install-Script`, as in `install-script.sh` (932120);
|
||||
- a database's name, as in PostgreSQL's `information_schema.sql` (942140).
|
||||
|
||||
For such a file, these requests are refused:
|
||||
|
||||
- the API's list of its commits (`path`);
|
||||
- the API's call for the contents of files that names it in its query (`body`);
|
||||
- the commit graph of that file alone (`file`);
|
||||
- the hidden lines of its diff (`path`), which stay hidden;
|
||||
- the sign-in page, for a visitor who opens a page of the file that needs
|
||||
signing in, such as its edit page (`redirect_to`). The visitor can still sign
|
||||
in by opening the sign-in page itself.
|
||||
|
||||
### A path
|
||||
|
||||
- A file name ending in `~`, or an `.xhtml` file whose path holds a space.
|
||||
- A path that holds what the Core Rule Set takes for a Java class (944130), as
|
||||
`java.lang.String.md` and
|
||||
`META-INF/services/org.apache.commons.logging.LogFactory` do.
|
||||
- A path that holds what it takes for Node.js code (934100), as
|
||||
`process.env.d.ts` does.
|
||||
- A path with a directory named `...` (930100, 930110), such as `.../notes.txt`.
|
||||
|
||||
For such a file, these are refused:
|
||||
|
||||
- the page of the file;
|
||||
- its raw file;
|
||||
- its history and its blame;
|
||||
- its edit page.
|
||||
|
||||
The directory that holds it lists it, and a clone carries it.
|
||||
|
||||
### A container image push that reuses a layer
|
||||
|
||||
A push can reuse a layer of an image docker has pushed to that gitea before.
|
||||
docker then asks gitea to take the layer from that image, naming it in the query
|
||||
parameter `from`, which 932260 checks. The request is refused when the image's
|
||||
owner starts with an entry on the list of commands. It is refused too when the
|
||||
image's name after the owner starts with one. Examples are `owner/python3-app`,
|
||||
`owner/docker-base`, and any image of an owner named `ansible` or `docker-team`.
|
||||
|
||||
What happens next depends on docker's image store. A docker that keeps its
|
||||
images in containerd's image store fails the push, and fails each new try the
|
||||
same way. A new install of Docker Engine 29 does this. A docker that keeps them
|
||||
in its own older image store uploads the layer instead. The push then succeeds
|
||||
after one refusal for each layer it reuses, and each of those refusals counts
|
||||
toward the error burst ("Attack detection" in `SPEC.md`).
|
||||
|
||||
### Artifact uploads from `actions/upload-artifact@v3`
|
||||
|
||||
This action sends the header `Content-Range`, which the Core Rule Set refuses
|
||||
(920450). The action sends two files at a time, does not retry a `403`, and
|
||||
sends no further file once one is refused. So an upload is refused once or
|
||||
twice, however many files it holds, and the step fails. One upload does not ban
|
||||
the runner. The error burst ("Attack detection" in `SPEC.md`) bans it after more
|
||||
than 30 such refusals from its address in a minute, as when many jobs upload at
|
||||
once. `actions/upload-artifact@v4` does not send the header.
|
||||
|
||||
### What body inspection refuses
|
||||
|
||||
With `SWWAF_WAF_BODY_LIMIT` set, the Core Rule Set reads request bodies too. In
|
||||
front of gitea it then refuses these, until their rule ids, named in the request
|
||||
log, are added to `SWWAF_WAF_DISABLED_RULES`:
|
||||
|
||||
- issue and comment text, wiki pages and files saved in the web editor that hold
|
||||
shell commands or code (932125, 932235, 932250 and others);
|
||||
- package descriptions that show code;
|
||||
- PyPI uploads (922130);
|
||||
- attachments named like `debug.log` or `config.yml` (932180).
|
||||
|
||||
### What `script/gitea-requests` sends
|
||||
|
||||
Of the requests above, the script sends:
|
||||
|
||||
- for each file named under "A file name in a query parameter that trips the
|
||||
code rules", the API's list of its commits;
|
||||
- for `Runtime.java` alone, the commit graph of that file, the hidden lines of
|
||||
its diff, and the API's call for the contents of files that names it;
|
||||
- the sign-in page for a visitor who opens the edit page of `document.write.js`;
|
||||
- the pages of `java.lang.String.md`,
|
||||
`META-INF/services/org.apache.commons.logging.LogFactory`, `process.env.d.ts`
|
||||
and `.../notes.txt`;
|
||||
- one container image push that reuses a layer, which it does not try again.
|
||||
|
||||
It does not send the raw file, history, blame or edit page of those four files,
|
||||
or the pages of the directories `META-INF/services` and `...`. It also sends a
|
||||
short sample of gitea's ordinary use:
|
||||
|
||||
- signing in;
|
||||
- pages of files and commits, and a diff;
|
||||
- the API;
|
||||
- a push, and clone and fetch over HTTP;
|
||||
- two container image pushes.
|
||||
+3
-3
@@ -55,10 +55,10 @@ empty-agent user_agent log ^$
|
||||
## A file of your own
|
||||
|
||||
`servewaf` reads the rule files in its own container. Rules of your own go in a
|
||||
file named to sort after the default, such as `50-gitea.rules`:
|
||||
file named to sort after the default, such as `50-site.rules`:
|
||||
|
||||
```
|
||||
# 50-gitea.rules: WordPress probes, which gitea never serves
|
||||
# 50-site.rules: WordPress probes, which this site never serves
|
||||
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
||||
```
|
||||
|
||||
@@ -66,7 +66,7 @@ A `Dockerfile` built on the `smallwebwaf` image, for `servewaf` to run from,
|
||||
adds it:
|
||||
|
||||
```dockerfile
|
||||
COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules
|
||||
COPY 50-site.rules /etc/smallwebwaf/rules.d/50-site.rules
|
||||
```
|
||||
|
||||
Or mount it there. A `path` rule applies on every site behind that `servewaf`.
|
||||
|
||||
+6
-7
@@ -101,19 +101,18 @@ chooses between the two (see "The image's command" below).
|
||||
|
||||
- `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.
|
||||
person opening two 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.
|
||||
person reading a site 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
|
||||
- A CI runner asks its code forge 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)).
|
||||
|
||||
@@ -39,7 +39,7 @@ const (
|
||||
ntfyURL = "https://ntfy.example/smallwebwaf-alerts"
|
||||
ntfyToken = "tk_0123456789abcdefghijklmnopq"
|
||||
// instance is the instance name every alert gives.
|
||||
instance = "fsn1app1/gitea"
|
||||
instance = "fsn1app1/blog"
|
||||
// started is when each test starts, as an alert gives it, and
|
||||
// anHourOn an hour later.
|
||||
started = "2000-01-01T00:00:00Z"
|
||||
@@ -965,8 +965,8 @@ func TestEachDestinationIsSentOnlyTheAlertsWaitingForIt(t *testing.T) {
|
||||
synctest.Wait()
|
||||
|
||||
wantBodies(t, standIns[alerts.DestinationSlack],
|
||||
`{"text":"*fsn1app1/gitea: file_error*\nsecond"}`,
|
||||
`{"text":"*fsn1app1/gitea: file_error*\nthird"}`)
|
||||
`{"text":"*fsn1app1/blog: file_error*\nsecond"}`,
|
||||
`{"text":"*fsn1app1/blog: file_error*\nthird"}`)
|
||||
wantBodies(t, standIns[alerts.DestinationNtfy], "fourth")
|
||||
|
||||
// alerts.json then lists Slack and ntfy alone, with no alert waiting.
|
||||
@@ -1265,7 +1265,7 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Reason: "requests per hour over the limit of 10000",
|
||||
Detail: map[string]any{"mode": "observe"},
|
||||
},
|
||||
"fsn1app1/gitea: ban", "default no_entry",
|
||||
"fsn1app1/blog: ban", "default no_entry",
|
||||
"requests per hour over the limit of 10000\nclient: 203.0.113.9\n" +
|
||||
"netblock: 203.0.113.0/24\ncountry: DE\nmode: observe",
|
||||
},
|
||||
@@ -1275,7 +1275,7 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Netblock: netip.MustParsePrefix("198.51.100.7/32"), Country: "FR",
|
||||
Reason: "matched the rule env-file",
|
||||
},
|
||||
"fsn1app1/gitea: permanent_ban", "high no_entry",
|
||||
"fsn1app1/blog: permanent_ban", "high no_entry",
|
||||
"matched the rule env-file\nclient: 198.51.100.7\n" +
|
||||
"netblock: 198.51.100.7/32\ncountry: FR",
|
||||
},
|
||||
@@ -1286,7 +1286,7 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Reason: "refused by the Core Rule Set",
|
||||
Host: "app.example",
|
||||
},
|
||||
"fsn1app1/gitea: waf_block", "default shield",
|
||||
"fsn1app1/blog: waf_block", "default shield",
|
||||
"refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" +
|
||||
"netblock: 192.0.2.1/32",
|
||||
},
|
||||
@@ -1295,7 +1295,7 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Event: alerts.EventAnomaly, Netblock: netip.MustParsePrefix("192.0.2.0/24"),
|
||||
Reason: "requests per minute over the threshold of 5000",
|
||||
},
|
||||
"fsn1app1/gitea: anomaly", "high chart_with_upwards_trend",
|
||||
"fsn1app1/blog: anomaly", "high chart_with_upwards_trend",
|
||||
"requests per minute over the threshold of 5000\nnetblock: 192.0.2.0/24",
|
||||
},
|
||||
{
|
||||
@@ -1304,7 +1304,7 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Netblock: netip.MustParsePrefix("192.0.2.2/32"),
|
||||
Reason: "listed by a DNS blocklist",
|
||||
},
|
||||
"fsn1app1/gitea: reputation_hit", "low label",
|
||||
"fsn1app1/blog: reputation_hit", "low label",
|
||||
"listed by a DNS blocklist\nclient: 192.0.2.2\nnetblock: 192.0.2.2/32",
|
||||
},
|
||||
{
|
||||
@@ -1312,7 +1312,7 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Event: alerts.EventSourceFailure, Reason: "asking GeoJS failed",
|
||||
Detail: map[string]any{"source": "geojs"},
|
||||
},
|
||||
"fsn1app1/gitea: source_failure", "high warning",
|
||||
"fsn1app1/blog: source_failure", "high warning",
|
||||
"asking GeoJS failed\nsource: geojs",
|
||||
},
|
||||
{
|
||||
@@ -1323,7 +1323,7 @@ func anAlertForEachEvent() []eventMessage {
|
||||
"file": "/etc/smallwebwaf/rules.d/50-app.rules", "error": "line 2: no action",
|
||||
},
|
||||
},
|
||||
"fsn1app1/gitea: file_error", "high warning",
|
||||
"fsn1app1/blog: file_error", "high warning",
|
||||
"a rule file has an error, and the rules stay as they were\n" +
|
||||
"file: /etc/smallwebwaf/rules.d/50-app.rules\nerror: line 2: no action",
|
||||
},
|
||||
|
||||
@@ -409,7 +409,7 @@ var (
|
||||
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")
|
||||
"is not 1 to 48 printable ASCII characters without a space, such as blog")
|
||||
errSetTwice = errors.New("set only one of them")
|
||||
errNotWebhookURL = errors.New(
|
||||
"is not an http or https URL without a user or a fragment, " +
|
||||
|
||||
@@ -197,7 +197,7 @@ const (
|
||||
// instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and
|
||||
// remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines.
|
||||
const (
|
||||
instance = "fsn1app1/gitea"
|
||||
instance = "fsn1app1/blog"
|
||||
remoteURL = "syslog+udp://192.0.2.1:514"
|
||||
)
|
||||
|
||||
@@ -650,11 +650,11 @@ func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
instanceName: "fsn1app1/gitea",
|
||||
instanceName: "fsn1app1/blog",
|
||||
logRequestHeaders: " Accept , X-Custom",
|
||||
})
|
||||
|
||||
if cfg.InstanceName != "fsn1app1/gitea" ||
|
||||
if cfg.InstanceName != "fsn1app1/blog" ||
|
||||
!slices.Equal(cfg.LogRequestHeaders, []string{"accept", "x-custom"}) {
|
||||
t.Errorf("%s is %q and %s gives %v", instanceName, cfg.InstanceName,
|
||||
logRequestHeaders, cfg.LogRequestHeaders)
|
||||
@@ -797,7 +797,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) {
|
||||
|
||||
want := logRemoteAppName + `: is unset, and ` + instanceName +
|
||||
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
|
||||
`without a space, such as gitea`
|
||||
`without a space, such as blog`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
@@ -826,7 +826,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
|
||||
}.lookupEnv)
|
||||
|
||||
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
||||
`characters without a space, such as gitea`
|
||||
`characters without a space, such as blog`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
@@ -2166,7 +2166,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
|
||||
}.lookupEnv)
|
||||
|
||||
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
||||
`characters without a space, such as gitea`
|
||||
`characters without a space, such as blog`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ import (
|
||||
const (
|
||||
// started is the time a record made as a test starts gives.
|
||||
started = "2000-01-01T00:00:00.000Z"
|
||||
appName = "fsn1app1/gitea"
|
||||
appName = "fsn1app1/blog"
|
||||
// local0 is the number of the default facility, and local0Info the
|
||||
// priority of its records.
|
||||
local0 = 16
|
||||
@@ -94,7 +94,7 @@ func TestRecordsOverTCPAreOctetCountedWithTheirFacility(t *testing.T) {
|
||||
endpoint := listen(t)
|
||||
endpointParams := params(remotelog.SchemeTCP, endpoint.Addr())
|
||||
endpointParams.Facility = 19 // local3
|
||||
endpointParams.AppName = "gitea"
|
||||
endpointParams.AppName = "blog"
|
||||
sender, _, _ := run(t, endpointParams)
|
||||
|
||||
_, _ = sender.Write([]byte("first\nsecond\n"))
|
||||
@@ -103,8 +103,8 @@ func TestRecordsOverTCPAreOctetCountedWithTheirFacility(t *testing.T) {
|
||||
wantCounts(t, sender, 2, 0, 0)
|
||||
|
||||
frames := bufio.NewReader(accept(t, endpoint))
|
||||
wantFrame(t, frames, record(t, "<158>", "gitea", "first"))
|
||||
wantFrame(t, frames, record(t, "<158>", "gitea", "second"))
|
||||
wantFrame(t, frames, record(t, "<158>", "blog", "first"))
|
||||
wantFrame(t, frames, record(t, "<158>", "blog", "second"))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
requestlog.NewProcessLogger(&out, "fsn1app1/gitea", slog.LevelInfo).Info("starting",
|
||||
requestlog.NewProcessLogger(&out, "fsn1app1/blog", slog.LevelInfo).Info("starting",
|
||||
"version", "v1")
|
||||
|
||||
var fields map[string]any
|
||||
@@ -104,7 +104,7 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
|
||||
t.Fatalf("decode %q: %v", out.String(), err)
|
||||
}
|
||||
|
||||
if fields["type"] != "process" || fields["instance"] != "fsn1app1/gitea" ||
|
||||
if fields["type"] != "process" || fields["instance"] != "fsn1app1/blog" ||
|
||||
fields["msg"] != "starting" || fields["level"] != "INFO" ||
|
||||
fields["version"] != "v1" {
|
||||
t.Errorf("process line %v", fields)
|
||||
@@ -133,7 +133,7 @@ func TestProcessLoggerWritesTheMessagesAtItsLevelOrMoreSevere(t *testing.T) {
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
processLog := requestlog.NewProcessLogger(&out, "fsn1app1/gitea", level)
|
||||
processLog := requestlog.NewProcessLogger(&out, "fsn1app1/blog", level)
|
||||
for _, at := range levels {
|
||||
processLog.Log(t.Context(), at, "message")
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
||||
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
||||
// alertInstance is the instance every alert of these tests gives.
|
||||
alertInstance = "fsn1app1/gitea"
|
||||
alertInstance = "fsn1app1/blog"
|
||||
)
|
||||
|
||||
func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
|
||||
|
||||
@@ -19,7 +19,7 @@ const (
|
||||
// requestIDHeader carries the request's id.
|
||||
requestIDHeader = "X-Request-ID"
|
||||
// instance is the SWWAF_INSTANCE_NAME a test sets.
|
||||
instance = "fsn1app1/gitea"
|
||||
instance = "fsn1app1/blog"
|
||||
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
|
||||
// limits count it as.
|
||||
ipv6Client = "2001:db8::7"
|
||||
|
||||
@@ -49,7 +49,7 @@ const (
|
||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||
// instance is the SWWAF_INSTANCE_NAME the tests set where they look at
|
||||
// it.
|
||||
instance = "fsn1app1/gitea"
|
||||
instance = "fsn1app1/blog"
|
||||
// greeting is what the tests' app answers.
|
||||
greeting = "hello from the app"
|
||||
// placed is the client the tests' lookup databases place.
|
||||
@@ -341,8 +341,8 @@ func TestEveryLogLineAndMetricCarriesTheInstanceName(t *testing.T) {
|
||||
// the comments.
|
||||
for line := range strings.Lines(metrics) {
|
||||
if !strings.HasPrefix(line, "#") &&
|
||||
!strings.Contains(line, `instance="fsn1app1/gitea"`) {
|
||||
t.Errorf("series %q, without instance=\"fsn1app1/gitea\"", line)
|
||||
!strings.Contains(line, `instance="fsn1app1/blog"`) {
|
||||
t.Errorf("series %q, without instance=\"fsn1app1/blog\"", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -610,9 +610,9 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
|
||||
rename(t, replacement, path)
|
||||
|
||||
metrics := metricsWith(t, url+"_smallwebwaf/metrics", token,
|
||||
`smallwebwaf_lookup_database_read_failures_total{instance="fsn1app1/gitea"} 1`)
|
||||
`smallwebwaf_lookup_database_read_failures_total{instance="fsn1app1/blog"} 1`)
|
||||
lastRead = seriesValue(t, metrics,
|
||||
`smallwebwaf_lookup_database_last_read_timestamp_seconds{instance="fsn1app1/gitea"}`)
|
||||
`smallwebwaf_lookup_database_last_read_timestamp_seconds{instance="fsn1app1/blog"}`)
|
||||
|
||||
wantStatus(t, url, placed, http.StatusOK)
|
||||
})
|
||||
@@ -668,7 +668,7 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
|
||||
// too.
|
||||
rateLimitExemptNets: placed + "," + localhost,
|
||||
}
|
||||
failures := `smallwebwaf_reputation_failures_total{instance="fsn1app1/gitea",` +
|
||||
failures := `smallwebwaf_reputation_failures_total{instance="fsn1app1/blog",` +
|
||||
`source="` + torURL + `"} `
|
||||
|
||||
// tor.txt cannot be fetched at first, which is counted.
|
||||
@@ -909,8 +909,8 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
|
||||
// last.
|
||||
metrics := metricsText(t, url+"_smallwebwaf/metrics", token)
|
||||
for _, series := range []string{
|
||||
`smallwebwaf_remote_log_lines_sent_total{instance="fsn1app1/gitea"} 0`,
|
||||
`smallwebwaf_remote_log_buffer_depth{instance="fsn1app1/gitea"} 1`,
|
||||
`smallwebwaf_remote_log_lines_sent_total{instance="fsn1app1/blog"} 0`,
|
||||
`smallwebwaf_remote_log_buffer_depth{instance="fsn1app1/blog"} 1`,
|
||||
} {
|
||||
if !strings.Contains(metrics, "\n"+series+"\n") {
|
||||
t.Errorf("no %q in the metrics:\n%s", series, metrics)
|
||||
@@ -918,7 +918,7 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
const dropped = "\nsmallwebwaf_remote_log_lines_dropped_total" +
|
||||
`{instance="fsn1app1/gitea"} `
|
||||
`{instance="fsn1app1/blog"} `
|
||||
if strings.Contains(metrics, dropped+"0\n") || !strings.Contains(metrics, dropped) {
|
||||
t.Errorf("no line dropped in the metrics:\n%s", metrics)
|
||||
}
|
||||
@@ -1011,7 +1011,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
||||
runUntilStopped(t, env, func(url string) {
|
||||
webhook.waitFor(t, "permanent_ban", true)
|
||||
|
||||
const ofWebhook = `{destination="webhook",instance="fsn1app1/gitea"}`
|
||||
const ofWebhook = `{destination="webhook",instance="fsn1app1/blog"}`
|
||||
|
||||
metrics := metricsWith(t, url+"_smallwebwaf/metrics", token,
|
||||
"\nsmallwebwaf_alerts_sent_total"+ofWebhook+" 1\n")
|
||||
@@ -1062,13 +1062,13 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
|
||||
slackPost := slack.firstPost(t)
|
||||
err := json.Unmarshal([]byte(slackPost.body), &message)
|
||||
|
||||
if err != nil || !strings.HasPrefix(message.Text, "*fsn1app1/gitea: ban*\n") ||
|
||||
if err != nil || !strings.HasPrefix(message.Text, "*fsn1app1/blog: ban*\n") ||
|
||||
!strings.Contains(message.Text, "\nclient: "+client+"\n") {
|
||||
t.Errorf("Slack was sent %s", slackPost.body)
|
||||
}
|
||||
|
||||
ntfyPost := ntfy.firstPost(t)
|
||||
if ntfyPost.header.Get("Title") != "fsn1app1/gitea: ban" ||
|
||||
if ntfyPost.header.Get("Title") != "fsn1app1/blog: ban" ||
|
||||
ntfyPost.header.Get("Authorization") != "Bearer "+ntfyToken ||
|
||||
!strings.Contains(ntfyPost.body, "\nclient: "+client+"\n") {
|
||||
t.Errorf("ntfy was sent %s, with the headers %v", ntfyPost.body,
|
||||
@@ -1080,9 +1080,9 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
|
||||
// test process cannot fail the test.
|
||||
sent := []string{
|
||||
"\nsmallwebwaf_alerts_sent_total{destination=\"slack\"," +
|
||||
"instance=\"fsn1app1/gitea\"} 1\n",
|
||||
"instance=\"fsn1app1/blog\"} 1\n",
|
||||
"\nsmallwebwaf_alerts_sent_total{destination=\"ntfy\"," +
|
||||
"instance=\"fsn1app1/gitea\"} 1\n",
|
||||
"instance=\"fsn1app1/blog\"} 1\n",
|
||||
}
|
||||
|
||||
metrics := metricsWith(t, url+"_smallwebwaf/metrics", token, sent...)
|
||||
|
||||
@@ -143,7 +143,7 @@ const filledAlertsJSON = `{
|
||||
"waiting": {
|
||||
"webhook": [
|
||||
{
|
||||
"instance": "fsn1app1/gitea",
|
||||
"instance": "fsn1app1/blog",
|
||||
"time": "2026-10-06T00:00:00Z",
|
||||
"event": "ban",
|
||||
"client": "203.0.113.9",
|
||||
@@ -158,7 +158,7 @@ const filledAlertsJSON = `{
|
||||
"suppressed_repeats": 0
|
||||
},
|
||||
{
|
||||
"instance": "fsn1app1/gitea",
|
||||
"instance": "fsn1app1/blog",
|
||||
"time": "2026-10-06T00:00:00Z",
|
||||
"event": "file_error",
|
||||
"client": "",
|
||||
@@ -1721,7 +1721,7 @@ func newParams(dir string) state.Params {
|
||||
Events: alerts.Events(),
|
||||
Cooldown: 15 * time.Minute,
|
||||
MaxPerHour: 2,
|
||||
Instance: "fsn1app1/gitea",
|
||||
Instance: "fsn1app1/blog",
|
||||
Now: midnight,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/gitea-requests: build the image and run gitea 1.27.3 behind rproxy
|
||||
# and servewaf, three containers on one network. Every setting is at its
|
||||
# default but these: SWRP_UPSTREAM_URL names gitea's container, SWRP_WAF_BACKEND
|
||||
# names servewaf's, and SWWAF_LOG_REQUEST_HEADERS adds the header that labels a
|
||||
# request. Then send through rproxy the gitea requests that reading gitea's
|
||||
# source found the Core Rule Set refuses, which docs/gitea.md records, and a
|
||||
# short sample of gitea's ordinary use: signing in, pages of files and commits,
|
||||
# a diff, the API, a push, clone and fetch over HTTP, and two container image
|
||||
# pushes. Last, print each request servewaf logged, with what it did with it and
|
||||
# the ids of the Core Rule Set's rules that matched it.
|
||||
# Every request comes from one client, docker's gateway, so more than
|
||||
# SWWAF_ERROR_BURST_THRESHOLD, 30, refusals in a minute would ban it, and
|
||||
# the requests after would show as banned. The containers, the network,
|
||||
# the images and the files the script makes are removed however it ends;
|
||||
# gitea's own image stays, as the base images of a build do. It needs
|
||||
# network access, and a docker daemon that pushes to a registry on
|
||||
# localhost over plain HTTP, as docker does by default. script/check does
|
||||
# not run this.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
# gitea 1.27.3, 2026-08-29
|
||||
GITEA_IMAGE="gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef"
|
||||
|
||||
# Named after this run, so that runs in other clones on the same host
|
||||
# never touch each other's.
|
||||
NAME="$("$SCRIPT_DIR/projectname")-gitea-$$"
|
||||
IMAGE="$NAME-image"
|
||||
NETWORK="$NAME"
|
||||
RPROXY="$NAME-rproxy"
|
||||
SERVEWAF="$NAME-servewaf"
|
||||
GITEA="$NAME-gitea"
|
||||
|
||||
# gitea's one user, who owns the repository and the container images.
|
||||
PASSWORD="owner-password"
|
||||
|
||||
BROWSER="Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0"
|
||||
|
||||
# The header that labels a request in the report. rproxy makes every request's
|
||||
# id itself, so a label cannot be the X-Request-ID.
|
||||
LABEL_HEADER="X-Case"
|
||||
|
||||
# The default of SWWAF_LOG_REQUEST_HEADERS, and the header above.
|
||||
LOGGED_HEADERS="accept,accept-language,accept-encoding,content-type,origin,range,x-case"
|
||||
|
||||
# The files the script makes, and rproxy's address once it runs, localhost and
|
||||
# the port docker publishes it on: a host name, since the Core Rule Set takes a
|
||||
# Host that is an IP address for a warning (920350).
|
||||
WORK=""
|
||||
address=""
|
||||
|
||||
cleanup() {
|
||||
# So that a signal cannot stop it partway through.
|
||||
trap '' HUP INT TERM
|
||||
docker rm --force --volumes "$RPROXY" "$SERVEWAF" "$GITEA" >/dev/null 2>&1 || true
|
||||
docker network rm "$NETWORK" >/dev/null 2>&1 || true
|
||||
docker rmi --force "$IMAGE" >/dev/null 2>&1 || true
|
||||
if [ -n "$address" ]; then
|
||||
docker rmi --force "$address/owner/python3-app:1" "$address/owner/app:1" \
|
||||
>/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "gitea-requests: $*" >&2
|
||||
report
|
||||
exit 1
|
||||
}
|
||||
|
||||
# wait_for <what fails> <command>...: run the command every second until
|
||||
# it succeeds, for at most a minute.
|
||||
wait_for() {
|
||||
failure="$1"
|
||||
shift
|
||||
tries=0
|
||||
until "$@"; do
|
||||
tries=$((tries + 1))
|
||||
[ "$tries" -lt 60 ] || fail "$failure"
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# healthy <container>: docker says the container's health check passes.
|
||||
healthy() {
|
||||
status="$(docker inspect --format '{{.State.Health.Status}}' "$1")"
|
||||
[ "$status" = healthy ]
|
||||
}
|
||||
|
||||
# logged <text>...: a line of servewaf's output holds every text, in any order.
|
||||
logged() {
|
||||
lines="$(docker logs "$SERVEWAF" 2>&1)"
|
||||
for text in "$@"; do
|
||||
lines="$(printf '%s\n' "$lines" | grep -F "$text")" || return 1
|
||||
done
|
||||
}
|
||||
|
||||
# send <label> <path> <curl option>...: send a request for path to rproxy, with
|
||||
# label in LABEL_HEADER, which the report gives, and keep the body of the answer
|
||||
# in $WORK/answer. The Core Rule Set reads that header too, so a label names no
|
||||
# file the cases name.
|
||||
send() {
|
||||
label="$1"
|
||||
path="$2"
|
||||
shift 2
|
||||
curl --silent --show-error --max-time 30 --output "$WORK/answer" \
|
||||
--header "$LABEL_HEADER: $label" "$@" "http://$address$path"
|
||||
}
|
||||
|
||||
# page <label> <path> <curl option>...: send the request as a browser
|
||||
# does, with the cookies gitea has set. Its Referer is the repository's
|
||||
# page, which links to the others, and never a page the Core Rule Set
|
||||
# refused: its address would have the next request refused too.
|
||||
page() {
|
||||
label="$1"
|
||||
path="$2"
|
||||
shift 2
|
||||
send "$label" "$path" --compressed --user-agent "$BROWSER" \
|
||||
--header "Accept: text/html,application/xhtml+xml,*/*;q=0.8" \
|
||||
--header "Accept-Language: en-US,en;q=0.5" \
|
||||
--cookie "$WORK/cookies" --cookie-jar "$WORK/cookies" \
|
||||
--referer "http://$address/owner/repo" "$@"
|
||||
}
|
||||
|
||||
# api <label> <path> <curl option>...: send the request as a client of
|
||||
# gitea's API does, as the owner.
|
||||
api() {
|
||||
label="$1"
|
||||
path="$2"
|
||||
shift 2
|
||||
send "$label" "$path" --user "owner:$PASSWORD" \
|
||||
--header "Accept: application/json" "$@"
|
||||
}
|
||||
|
||||
# repo_git <git argument>...: run git as the owner, without the host's
|
||||
# own git settings, so that nothing it does is kept outside $WORK.
|
||||
repo_git() {
|
||||
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 git \
|
||||
-c user.name=owner -c user.email=owner@example.invalid "$@"
|
||||
}
|
||||
|
||||
# lines: forty lines of text, enough for a diff to hide some of them.
|
||||
lines() {
|
||||
i=1
|
||||
while [ "$i" -le 40 ]; do
|
||||
echo "line $i"
|
||||
i=$((i + 1))
|
||||
done
|
||||
}
|
||||
|
||||
# make_repository: create the repository owner/repo, holding a file of
|
||||
# each name the cases name, and push it, which is the push over HTTP of
|
||||
# the sample. The second commit adds a line at the end of Runtime.java,
|
||||
# so that its diff hides the lines above.
|
||||
make_repository() {
|
||||
status="$(api create-repository /api/v1/user/repos \
|
||||
--header "Content-Type: application/json" --data '{"name":"repo"}' \
|
||||
--write-out '%{http_code}')"
|
||||
[ "$status" = 201 ] || fail "creating the repository was answered $status"
|
||||
|
||||
dir="$WORK/repo"
|
||||
repo_git init --quiet --initial-branch=main "$dir"
|
||||
for file in \
|
||||
src/java.base/share/classes/java/lang/Runtime.java \
|
||||
java.lang.String.md \
|
||||
META-INF/services/org.apache.commons.logging.LogFactory \
|
||||
document.write.js \
|
||||
process.env.d.ts \
|
||||
test/fixtures/__proto__.json \
|
||||
install-script.sh \
|
||||
src/backend/catalog/information_schema.sql \
|
||||
.../notes.txt \
|
||||
README.md; do
|
||||
mkdir -p "$dir/$(dirname "$file")"
|
||||
lines >"$dir/$file"
|
||||
repo_git -C "$dir" add "$file"
|
||||
done
|
||||
repo_git -C "$dir" commit --quiet --message "Add the files"
|
||||
echo "line 41" >>"$dir/src/java.base/share/classes/java/lang/Runtime.java"
|
||||
repo_git -C "$dir" commit --quiet --all --message "Add a line"
|
||||
|
||||
repo_git -C "$dir" -c "http.extraHeader=$LABEL_HEADER: git-push" push --quiet \
|
||||
"http://owner:$PASSWORD@$address/owner/repo.git" main ||
|
||||
fail "the push of the repository failed"
|
||||
}
|
||||
|
||||
# browse: sign in, then open pages as a person does. The cases are those
|
||||
# of https://git.eeqj.de/sneak/smallwebwaf/issues/30.
|
||||
browse() {
|
||||
# Case: gitea sends a visitor who is not signed in to the sign-in
|
||||
# page, with the page asked for in redirect_to, when that page needs
|
||||
# signing in, as a file's edit page does.
|
||||
page issue-30-case /owner/repo/_edit/main/document.write.js --location
|
||||
|
||||
page sign-in /user/login
|
||||
status="$(page sign-in /user/login --header "Origin: http://$address" \
|
||||
--data-urlencode user_name=owner --data-urlencode "password=$PASSWORD" \
|
||||
--write-out '%{http_code}')"
|
||||
[ "$status" = 303 ] || fail "signing in was answered $status"
|
||||
|
||||
page browse /
|
||||
page browse /owner/repo
|
||||
page browse /owner/repo/src/branch/main/README.md
|
||||
page browse /owner/repo/commits/branch/main
|
||||
page browse "/owner/repo/commit/$(repo_git -C "$WORK/repo" rev-parse HEAD)"
|
||||
|
||||
# Case: expanding the lines the diff hides, by the link the diff's
|
||||
# page gives for it.
|
||||
excerpt="$(sed -n 's/.*data-fetch-url="\([^"]*blob_excerpt[^"]*\)".*/\1/p' \
|
||||
"$WORK/answer" | head -n 1 | sed 's/&/\&/g')"
|
||||
[ -n "$excerpt" ] || fail "the diff's page has no link to its hidden lines"
|
||||
page issue-30-case "$excerpt"
|
||||
|
||||
# Cases: the commit graph of one file, and the pages of files whose
|
||||
# path the Core Rule Set takes for an attack.
|
||||
page issue-30-case "/owner/repo/graph?file=src/java.base/share/classes/java/lang/Runtime.java"
|
||||
page issue-30-case /owner/repo/src/branch/main/META-INF/services/org.apache.commons.logging.LogFactory
|
||||
page issue-30-case /owner/repo/src/branch/main/java.lang.String.md
|
||||
page issue-30-case /owner/repo/src/branch/main/process.env.d.ts
|
||||
page issue-30-case /owner/repo/src/branch/main/.../notes.txt
|
||||
}
|
||||
|
||||
# call_api: the API's calls the cases name: the commits of a file, for a
|
||||
# file of each name the Core Rule Set takes for an attack in the query
|
||||
# parameter path, and the file-contents call, which takes its list of
|
||||
# files as JSON in the query parameter body.
|
||||
call_api() {
|
||||
for file in \
|
||||
src/java.base/share/classes/java/lang/Runtime.java \
|
||||
java.lang.String.md \
|
||||
document.write.js \
|
||||
process.env.d.ts \
|
||||
test/fixtures/__proto__.json \
|
||||
install-script.sh \
|
||||
src/backend/catalog/information_schema.sql; do
|
||||
api issue-30-case /api/v1/repos/owner/repo/commits --get --data-urlencode "path=$file"
|
||||
done
|
||||
api issue-30-case /api/v1/repos/owner/repo/file-contents --get \
|
||||
--data-urlencode 'body={"files":["src/java.base/share/classes/java/lang/Runtime.java"]}'
|
||||
}
|
||||
|
||||
# push_images: push a container image to owner/python3-app, then the same
|
||||
# image to owner/app. For the second, the case, docker asks gitea to take
|
||||
# its layer from owner/python3-app, naming it in the query parameter
|
||||
# from. docker sends no label, so the report gives its requests none. The image
|
||||
# is one small file, imported rather than built.
|
||||
push_images() {
|
||||
echo "$NAME" >"$WORK/layer.txt"
|
||||
tar -cf - -C "$WORK" layer.txt |
|
||||
docker import - "$address/owner/python3-app:1" >/dev/null
|
||||
docker tag "$address/owner/python3-app:1" "$address/owner/app:1"
|
||||
|
||||
# The login is written here rather than made with docker login, which
|
||||
# would keep it in the host's credential helper when it has one.
|
||||
mkdir "$WORK/docker"
|
||||
printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$address" \
|
||||
"$(printf 'owner:%s' "$PASSWORD" | base64)" >"$WORK/docker/config.json"
|
||||
for image in "$address/owner/python3-app:1" "$address/owner/app:1"; do
|
||||
DOCKER_CONFIG="$WORK/docker" docker push --quiet "$image" >/dev/null ||
|
||||
echo "gitea-requests: the push of $image failed" >&2
|
||||
done
|
||||
}
|
||||
|
||||
# clone_and_fetch: the clone and fetch over HTTP of the sample.
|
||||
clone_and_fetch() {
|
||||
repo_git -c "http.extraHeader=$LABEL_HEADER: git-clone" clone --quiet \
|
||||
"http://owner:$PASSWORD@$address/owner/repo.git" "$WORK/clone" ||
|
||||
fail "the clone failed"
|
||||
repo_git -C "$WORK/clone" -c "http.extraHeader=$LABEL_HEADER: git-fetch" fetch --quiet ||
|
||||
fail "the fetch failed"
|
||||
}
|
||||
|
||||
# field <name> <line>: the text of the field name in line, a line of the
|
||||
# request log, or nothing when it has none.
|
||||
field() {
|
||||
printf '%s\n' "$2" | sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p"
|
||||
}
|
||||
|
||||
# report: print a line for each request rproxy reported to servewaf: what
|
||||
# servewaf did with the request, the ids of the Core Rule Set's rules that
|
||||
# matched it, its label, and the request. waf_blocked is a refusal by the Core
|
||||
# Rule Set, and forward a request passed on to gitea, whatever gitea answered.
|
||||
report() {
|
||||
docker logs "$SERVEWAF" 2>/dev/null | grep '"servewaf_line":"report"' |
|
||||
sed 's/\\u0026/\&/g' >"$WORK/requests"
|
||||
while IFS= read -r line; do
|
||||
ids="$(printf '%s\n' "$line" | sed -n 's/.*"waf_rule_ids":\[\([0-9,]*\)\].*/\1/p')"
|
||||
query="$(field query "$line")"
|
||||
label="$(field x-case "$line")"
|
||||
printf '%-11s %-13s %-17s %s %s%s\n' "$(field action "$line")" "${ids:--}" \
|
||||
"${label:--}" "$(field method "$line")" \
|
||||
"$(field path "$line")" "${query:+?$query}"
|
||||
done <"$WORK/requests"
|
||||
echo "gitea-requests: $(grep -c . "$WORK/requests") requests," \
|
||||
"$(grep -c '"action":"waf_blocked"' "$WORK/requests") refused by the Core Rule Set"
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
trap cleanup EXIT
|
||||
trap 'exit 1' HUP INT TERM
|
||||
WORK="$(mktemp -d)"
|
||||
|
||||
docker build --no-cache -t "$IMAGE" .
|
||||
|
||||
# rproxy first, so that gitea can be given the address it is reached at,
|
||||
# and gitea's image pulled before, so that gitea starts within the minute
|
||||
# rproxy's health check waits for it.
|
||||
docker pull --quiet "$GITEA_IMAGE" >/dev/null
|
||||
docker network create "$NETWORK" >/dev/null
|
||||
docker run --detach --name "$SERVEWAF" --network "$NETWORK" \
|
||||
--env SMALLWEBWAF_COMMAND=servewaf \
|
||||
--env SWWAF_LOG_REQUEST_HEADERS="$LOGGED_HEADERS" \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_for "servewaf's health check did not pass" healthy "$SERVEWAF"
|
||||
docker run --detach --name "$RPROXY" --network "$NETWORK" \
|
||||
--publish 127.0.0.1::8080 \
|
||||
--env SWRP_UPSTREAM_URL="http://$GITEA:3000" \
|
||||
--env SWRP_WAF_BACKEND="http://$SERVEWAF:8080" \
|
||||
"$IMAGE" >/dev/null
|
||||
published="$(docker port "$RPROXY" 8080/tcp)"
|
||||
address="localhost:${published##*:}"
|
||||
|
||||
docker run --detach --name "$GITEA" --network "$NETWORK" \
|
||||
--env GITEA__security__INSTALL_LOCK=true \
|
||||
--env GITEA__server__ROOT_URL="http://$address/" \
|
||||
"$GITEA_IMAGE" >/dev/null
|
||||
# rproxy's health check passes once gitea takes connections.
|
||||
wait_for "rproxy's health check did not pass" healthy "$RPROXY"
|
||||
docker exec --user git "$GITEA" gitea admin user create --admin \
|
||||
--username owner --password "$PASSWORD" --email owner@example.invalid \
|
||||
--must-change-password=false >/dev/null
|
||||
|
||||
make_repository
|
||||
browse
|
||||
call_api
|
||||
push_images
|
||||
clone_and_fetch
|
||||
|
||||
# The fetch's last request is the last of all; once it is logged, so
|
||||
# is every other.
|
||||
wait_for "servewaf logged no report of the fetch" \
|
||||
logged '"x-case":"git-fetch"' '"method":"POST"' '"servewaf_line":"report"'
|
||||
report
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,216 +0,0 @@
|
||||
#!/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 "$@"
|
||||
Reference in New Issue
Block a user