#!/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 the Core Rule Set refuses none of the ordinary gitea requests in # internal/waf/testdata/gitea-requests.txt, nor a fetch, nor API calls with # JSON bodies; # - that it refuses SQL injection, path traversal and script injection in a # query string; # - 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 ...: 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 ...: 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 ...: 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 ...: 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 ...: 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 : 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 : 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":' } # refused: print each request servewaf logged as refused, for any reason. refused() { compose logs --no-color --no-log-prefix servewaf 2>&1 | grep -F '"servewaf_line":"report"' | grep -v -F '"action":"forward"' || true } # ordinary_requests: sign in as the owner, save a file and open an issue # through the API with JSON bodies, then send, signed in, each request in # internal/waf/testdata/gitea-requests.txt, with that request's own address as # its Referer, as a page opened from it has. One a second keeps them under the # default limit of 120 requests a minute, since a request refused for a limit is # never inspected. Fail if servewaf refuses any of them. ordinary_requests() { send /user/login --cookie-jar "$WORK/cookies" >/dev/null status="$(send /user/login --cookie "$WORK/cookies" --cookie-jar "$WORK/cookies" \ --header "Origin: https://$SITE" --data-urlencode user_name=owner \ --data-urlencode "password=$PASSWORD")" [ "$status" = 303 ] || fail "signing in was answered $status" content="$(printf 'declare const env: Record;\n' | base64)" status="$(send /api/v1/repos/owner/repo/contents/process.env.d.ts \ --user "owner:$PASSWORD" --header "Content-Type: application/json" \ --data "{\"message\":\"Add process.env.d.ts\",\"content\":\"$content\"}")" [ "$status" = 201 ] || fail "saving a file through the API was answered $status" status="$(send /api/v1/repos/owner/repo/issues --user "owner:$PASSWORD" \ --header "Content-Type: application/json" \ --data '{"title":"ls -la fails","body":"SELECT * FROM users; "}')" [ "$status" = 201 ] || fail "opening an issue through the API was answered $status" grep -v -e '^#' -e '^$' "$ROOT/internal/waf/testdata/gitea-requests.txt" | while IFS= read -r target; do send "$target" --globoff --cookie "$WORK/cookies" \ --header "Referer: https://$SITE$target" >/dev/null sleep 1 done # The last request's report is logged once its answer has ended. send /api/v1/settings/api >/dev/null wait_for "servewaf logged no report of the last ordinary request" logged servewaf \ '"servewaf_line":"report"' '"path":"/api/v1/settings/api"' refusals="$(refused)" [ -z "$refusals" ] || fail "servewaf refused ordinary requests: $refusals" } # attacks: send SQL injection, path traversal and script injection in a query # string, and fail unless the Core Rule Set refuses each. attacks() { # The traversal climbs one directory: the default rule file refuses two or # more first, before the Core Rule Set runs. for query in "id=1'%20OR%20'1'%3D'1" "id=..%2Fcustom%2Fconf%2Fapp.ini" \ "id=%3Cscript%3Ealert(1)%3C%2Fscript%3E"; do status="$(send "/owner/repo?$query")" [ "$status" = 403 ] || fail "the attack ?$query was answered $status" wait_for "servewaf logged no refusal of ?$query" logged servewaf \ '"servewaf_line":"report"' '"action":"waf_blocked"' "\"query\":\"$query\"" done } 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" site_git -C "$WORK/clone" fetch --quiet || fail "the fetch failed" ordinary_requests echo "gitea-setup: the Core Rule Set refuses no ordinary gitea request" attacks echo "gitea-setup: the Core Rule Set refuses the attacks" 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 "$@"