check / check (push) Successful in 3m50s
A compose setup that runs gitea 1.27.3 behind traefik, rproxy and servewaf on one shared network with IPv4 and IPv6, and `script/gitea-setup` (`make gitea-setup`), which brings it up and checks that a page load, a push and a clone pass through rproxy and are checked by servewaf. Containers on the shared network skip servewaf's checks; the gateways do not. gitea gets a secret key made per install and has sign-up off. `docs/gitea.md` explains each setting. Judgement call: the Core Rule Set runs in `detect` mode here until #198 lands. Model: deepseek-v4-pro (implementation), opus-5-5 (rework)
348 lines
16 KiB
Markdown
348 lines
16 KiB
Markdown
# 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, 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. 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 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 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, 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 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, 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
|
|
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.
|