Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c354d5769 |
@@ -1,12 +1,13 @@
|
|||||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
||||||
run example-app gitea-requests
|
run example-app gitea-requests gitea-setup
|
||||||
|
|
||||||
# Makefile targets are thin shims; the implementations live in script/
|
# Makefile targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
||||||
# which test checks. build and run are for working on the code by hand;
|
# which test checks. build and run are for working on the code by hand;
|
||||||
# example-app checks the image with an app built on it; gitea-requests
|
# 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.
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@script/bootstrap
|
@script/bootstrap
|
||||||
@@ -49,3 +50,6 @@ example-app:
|
|||||||
|
|
||||||
gitea-requests:
|
gitea-requests:
|
||||||
@script/gitea-requests
|
@script/gitea-requests
|
||||||
|
|
||||||
|
gitea-setup:
|
||||||
|
@script/gitea-setup
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ Point traefik's labels at port 8080 of `rproxy`
|
|||||||
of the app or of `servewaf`. Anything on that network can reach the app around
|
of the app or of `servewaf`. Anything on that network can reach the app around
|
||||||
`rproxy`, and can ask `servewaf` questions.
|
`rproxy`, and can ask `servewaf` questions.
|
||||||
|
|
||||||
|
`deploy/gitea` holds this setup for gitea behind traefik, as a Docker Compose
|
||||||
|
file, which [`docs/gitea.md`](docs/gitea.md) explains.
|
||||||
|
|
||||||
With no setting, `servewaf` sends the address of every new visitor to GeoJS, a
|
With no setting, `servewaf` sends the address of every new visitor to GeoJS, a
|
||||||
web service, to find its country and network. `SWWAF_LOOKUP_SOURCE=off` sends
|
web service, to find its country and network. `SWWAF_LOOKUP_SOURCE=off` sends
|
||||||
nothing. `SWWAF_LOOKUP_SOURCE=file`, with `SWWAF_LOOKUP_DB_PATH` naming a
|
nothing. `SWWAF_LOOKUP_SOURCE=file`, with `SWWAF_LOOKUP_DB_PATH` naming a
|
||||||
@@ -380,6 +383,10 @@ on the host.
|
|||||||
with each. Needs network access, and a docker daemon that pushes to a registry
|
with each. Needs network access, and a docker daemon that pushes to a registry
|
||||||
on `localhost` over plain HTTP. [`docs/gitea.md`](docs/gitea.md) records the
|
on `localhost` over plain HTTP. [`docs/gitea.md`](docs/gitea.md) records the
|
||||||
results. `make gitea-requests`.
|
results. `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`.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
@@ -469,7 +476,8 @@ happens when a process stops, moving a port, and the forwarded headers.
|
|||||||
- [`docs/state-files.md`](docs/state-files.md): the state files and what each
|
- [`docs/state-files.md`](docs/state-files.md): the state files and what each
|
||||||
holds.
|
holds.
|
||||||
- [`docs/image.md`](docs/image.md): what the image holds and how it is pinned.
|
- [`docs/image.md`](docs/image.md): what the image holds and how it is pinned.
|
||||||
- [`docs/gitea.md`](docs/gitea.md): `smallwebwaf` in front of gitea.
|
- [`docs/gitea.md`](docs/gitea.md): `smallwebwaf` in front of gitea: the setup
|
||||||
|
in `deploy/gitea`, and what the defaults do there.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# 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.
|
||||||
|
# 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"
|
||||||
|
# rproxy's address. 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
|
||||||
|
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
|
||||||
|
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, the default request limits are sized for such a fleet.
|
||||||
|
SWWAF_ALLOW_NETS: 10.99.0.128/25
|
||||||
|
# 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 its
|
||||||
|
# upper half, which servewaf lets skip the checks. The gateway, the host's
|
||||||
|
# own address on it, stays out of that half: requests docker passes on from
|
||||||
|
# a published port, such as those of visitors over IPv6, can come from it.
|
||||||
|
shared:
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 10.99.0.0/24
|
||||||
|
ip_range: 10.99.0.128/25
|
||||||
|
gateway: 10.99.0.1
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gitea-data:
|
||||||
|
servewaf-state:
|
||||||
+165
-49
@@ -1,51 +1,173 @@
|
|||||||
# smallwebwaf in front of gitea
|
# smallwebwaf in front of gitea
|
||||||
|
|
||||||
These notes record what `smallwebwaf` does in front of gitea at the default
|
`deploy/gitea/compose.yaml` runs gitea behind traefik, with `rproxy` and
|
||||||
settings. They are observations, not requirements; the requirements are in
|
`servewaf` in front of it. This document says what that setup sets and why, and
|
||||||
[`SPEC.md`](../SPEC.md). `script/gitea-requests` runs gitea behind `rproxy` and
|
what the defaults would do without it. [`SPEC.md`](../SPEC.md) says how
|
||||||
`servewaf`, and sends through them a sample of gitea's ordinary use and the
|
`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.
|
||||||
|
3. Set `SMALLWEBWAF_IMAGE` to the `smallwebwaf` image, named by digest.
|
||||||
|
4. Run `docker compose up --detach` in `deploy/gitea`.
|
||||||
|
5. Make gitea's first user with `gitea admin user create`, in gitea's container,
|
||||||
|
as the user `git`.
|
||||||
|
|
||||||
|
gitea starts at its defaults, with SQLite, instead of showing its install page
|
||||||
|
to whoever opens it first. 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, and the byte limits with it ("Byte limits, per
|
||||||
|
client" in `SPEC.md`).
|
||||||
|
|
||||||
|
The byte limits stay at their defaults. Two uploads of 2 GiB from one address in
|
||||||
|
one minute pass the minute's limit of 4 GiB, and ban the address.
|
||||||
|
|
||||||
|
## 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, which "What the Core Rule Set refuses in `block`
|
||||||
|
mode" below lists. 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 500 requests a minute,
|
||||||
|
20,000 an hour and 300,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 subnet is `10.99.0.0/24`. Docker gives its containers
|
||||||
|
addresses in the upper half, `10.99.0.128/25`, 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.
|
||||||
|
- The network's gateway, `10.99.0.1`, is the host's own address on it. Requests
|
||||||
|
docker passes on from a published port can come from it, such as those of
|
||||||
|
visitors over IPv6. So the gateway is left out of `SWWAF_ALLOW_NETS`.
|
||||||
|
- 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, the default request limits are sized for such a fleet.
|
||||||
|
- 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 address, `10.99.0.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, 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.
|
||||||
|
|
||||||
|
Several runners on one address can pass them:
|
||||||
|
|
||||||
|
- Two busy all day make 345,600 requests, 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 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
|
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
|
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
|
### What it lets through
|
||||||
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.
|
|
||||||
|
|
||||||
At the defaults, an upload larger than 100 MiB or taking more than 60
|
The Core Rule Set lets gitea's ordinary use through, apart from the refusals in
|
||||||
seconds is cut off ("`rproxy`'s limits" in `SPEC.md`). A gitea that takes
|
the sections after this one:
|
||||||
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:
|
|
||||||
|
|
||||||
- browsing and views of files in a repository, with their history, blame and the
|
- browsing and views of files in a repository, with their history, blame and the
|
||||||
file tree;
|
file tree;
|
||||||
@@ -60,12 +182,6 @@ the refusals in the next section:
|
|||||||
editor, code included, since no body is read;
|
editor, code included, since no body is read;
|
||||||
- the page gitea shows after a change, with its message naming what was done.
|
- 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
|
### A query string that reads as an attack
|
||||||
|
|
||||||
Most often this is a search. A search term is a query parameter, which the Core
|
Most often this is a search. A search term is a query parameter, which the Core
|
||||||
@@ -183,7 +299,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
|
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.
|
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
|
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
|
front of gitea it then refuses these, until their rule ids, named in the request
|
||||||
@@ -195,7 +311,7 @@ log, are added to `SWWAF_WAF_DISABLED_RULES`:
|
|||||||
- PyPI uploads (922130);
|
- PyPI uploads (922130);
|
||||||
- attachments named like `debug.log` or `config.yml` (932180).
|
- 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:
|
Of the requests above, the script sends:
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package rproxy_test
|
package rproxy_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
@@ -604,12 +602,10 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
|
|||||||
func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
|
func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const start = "the start"
|
|
||||||
|
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
servewaf := startServeWAF(t, allow)
|
servewaf := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = io.WriteString(w, start)
|
_, _ = io.WriteString(w, "the start")
|
||||||
_ = http.NewResponseController(w).Flush()
|
_ = http.NewResponseController(w).Flush()
|
||||||
|
|
||||||
<-release
|
<-release
|
||||||
@@ -617,29 +613,17 @@ func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
|
|||||||
|
|
||||||
t.Cleanup(func() { close(release) })
|
t.Cleanup(func() { close(release) })
|
||||||
|
|
||||||
// The time limit is long enough for a loaded host to pass on the start of the
|
|
||||||
// answer before it runs out.
|
|
||||||
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
maxResponseTime: "3000",
|
maxResponseTime: "300",
|
||||||
})
|
})
|
||||||
|
|
||||||
conn := dial(t, addr)
|
conn := dial(t, addr)
|
||||||
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
||||||
|
|
||||||
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
// dial's deadline ends this read with an error if the connection stays open.
|
||||||
|
_, err := io.ReadAll(conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read the answer: %v", err)
|
t.Errorf("read: %v, want the connection closed", err)
|
||||||
}
|
|
||||||
|
|
||||||
// The body breaks off when rproxy closes the connection. If the connection
|
|
||||||
// stays open, dial's deadline ends this read with a timeout instead.
|
|
||||||
body, err := io.ReadAll(res.Body)
|
|
||||||
_ = res.Body.Close()
|
|
||||||
|
|
||||||
if res.StatusCode != http.StatusOK || string(body) != start ||
|
|
||||||
!errors.Is(err, io.ErrUnexpectedEOF) {
|
|
||||||
t.Errorf("answered %d %q (%v), want the app's 200 %q, then the connection closed",
|
|
||||||
res.StatusCode, body, err, start)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if report := servewaf.report(t); report.Status != http.StatusOK {
|
if report := servewaf.report(t); report.Status != http.StatusOK {
|
||||||
@@ -667,10 +651,8 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// The time limit is long enough for a loaded host to start passing on the
|
|
||||||
// answer before it runs out.
|
|
||||||
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
maxResponseTime: "3000",
|
maxResponseTime: "500",
|
||||||
})
|
})
|
||||||
|
|
||||||
// The visitor asks, and never reads the answer.
|
// The visitor asks, and never reads the answer.
|
||||||
@@ -811,10 +793,8 @@ func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing
|
|||||||
|
|
||||||
servewaf := startServeWAF(t, deny)
|
servewaf := startServeWAF(t, deny)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
// The time limit is long enough for a loaded host to read the first part of
|
|
||||||
// the body before it runs out.
|
|
||||||
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
maxRequestTime: "3000",
|
maxRequestTime: "500",
|
||||||
})
|
})
|
||||||
|
|
||||||
// Go's server waits for a little of the body left before it sends an answer,
|
// Go's server waits for a little of the body left before it sends an answer,
|
||||||
|
|||||||
@@ -1353,10 +1353,8 @@ func TestAReportServeWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
// The time limit is long enough for a loaded host to answer the question before
|
|
||||||
// it runs out.
|
|
||||||
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
||||||
timeoutSetting: "3000",
|
timeoutSetting: "1000",
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Cleanup(func() { close(release) })
|
t.Cleanup(func() { close(release) })
|
||||||
|
|||||||
Executable
+212
@@ -0,0 +1,212 @@
|
|||||||
|
#!/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, and traefik's and the gateway's addresses on the
|
||||||
|
# shared network once the setup runs.
|
||||||
|
WORK=""
|
||||||
|
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 and no port of traefik's published.
|
||||||
|
compose() {
|
||||||
|
SMALLWEBWAF_IMAGE="$IMAGE" 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" .
|
||||||
|
|
||||||
|
# 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