Files
smallwebwaf/docs/gitea.md
T
clawbot 20f99b2cdd
check / check (push) Successful in 4m18s
deploy/gitea: Gitea behind traefik, rproxy and servewaf, with its test (closes #178)
deploy/gitea/compose.yaml runs traefik, stock Gitea 1.27.3, rproxy and
servewaf on one network with a fixed subnet. Its containers skip
servewaf's checks; the network's gateway is left out of that range.
rproxy takes uploads of 2 GiB and 30 minutes, and cuts responses at 15
minutes. script/gitea-setup (make gitea-setup) brings the setup up and
checks it. docs/gitea.md is rewritten around the setup.

Judgement call: the Core Rule Set runs in detect mode, so it refuses nothing.
Judgement call: traefik serves its own certificate; no certificate resolver.
Partially verified: the image build failed three runs on Ubuntu's snapshot
service, so the checks ran only against a stand-in image with the same binary.

Model: deepseek-v4-pro
2026-11-05 22:35:23 +00:00

16 KiB

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 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, and each fd10:99 with the first two hextets of a /64 subnet, using subnets 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. 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.