From dcfeb12c6815f31914d14d62031bc083bceb2049 Mon Sep 17 00:00:00 2001 From: leora Date: Thu, 15 Oct 2026 20:57:10 +0800 Subject: [PATCH] Re-review fixes: EGRESS_MODE=lax on Gitea 28, basic-auth prod gate, pinned sha256, kit-owned down - app.ini.template: [security] EGRESS_MODE = lax (ALLOWED_HOST_LIST=external is invalid on 28.1.0) - README: egress rows for 28 vs 1.23; rollout gate while password basic auth is on (edge 429 on Authorization: Basic to /api/v1/ and git HTTP, or tokens first) - kit.sh: pinned sha256 per arch for 28.1.0; down stops only its own pid or the unit it installed - e2e: package upload must be 404; 'denied by egress' polled from the Gitea log; 4 MB attachment cap; Actions off; fail on [E] / 'invalid entry'; listener cleaned up in trap --- .gitignore | 1 + README.md | 18 +++++++++++++----- config/app.ini.template | 6 +++--- kit.sh | 24 +++++++++++++++++++----- tests/e2e-binary.sh | 25 +++++++++++++++++++------ 5 files changed, 55 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 074b034..7dda188 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ secrets/ bin/ backups/ *.zip +.kit-systemd diff --git a/README.md b/README.md index 2a0e6bf..84b2bcf 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Without systemd, `./kit.sh up` in binary mode runs Gitea in the background with | `DEFAULT_ALLOW_CREATE_ORGANIZATION` | `false` | new users cannot create organizations | | `MAX_CREATION_LIMIT` | `10` | repos per regular user (admins are exempt) | | `DISABLE_MIGRATIONS`, `[mirror] DISABLE_NEW_PULL` | `true` | no importing or mirroring from arbitrary URLs | -| `[security] ALLOWED_HOST_LIST` (`[webhook]` on Gitea 1.23) | `external` | webhooks may call public hosts only, not loopback, LAN or link-local | +| `[security] EGRESS_MODE` (Gitea 28) | `lax` | webhooks may call public hosts only, not loopback, LAN or link-local (`strict` = only `[security] ALLOWED_HOST_LIST`). On Gitea 1.23 use `[webhook] ALLOWED_HOST_LIST = external` | | `ENABLE_BASIC_AUTHENTICATION` | `false` | API and git-over-HTTP take tokens only; password basic auth is refused | | `[packages] ENABLED`, `[actions] ENABLED` | `false`, `false` | no package blobs, runner jobs or artifacts to fill the disk | | `[attachment] MAX_SIZE` / `MAX_FILES`, `[repository.upload] FILE_MAX_SIZE` / `MAX_FILES` | `4` MB / `3` | small uploads only | @@ -184,7 +184,7 @@ sign up and log in. Abuse is limited by what a new account can do, not by a chal - **Per-account caps:** `MAX_CREATION_LIMIT = 10` repos per user, and new users cannot create organizations. - **No server-side fetches into your network:** migrations and new mirrors are off, and - `ALLOWED_HOST_LIST = external` (`[security]` on Gitea 28+, `[webhook]` on 1.23) keeps webhooks off loopback, private and link-local addresses. + `[security] EGRESS_MODE = lax` (Gitea 28; `[webhook] ALLOWED_HOST_LIST = external` on 1.23) keeps webhooks off loopback, private and link-local addresses. - **Tokens, not passwords, on the API:** `ENABLE_BASIC_AUTHENTICATION = false` refuses username/password basic auth on the API and git-over-HTTP, so stolen or guessed passwords can't be scripted against it. Personal access tokens (as header or as the basic-auth password) still work. @@ -216,9 +216,17 @@ Known gaps: ### Rollout order (existing instance with a captcha) -1. Add the edge rate limit and verify it: a burst of `POST /user/sign_up` from one IP gets 429. -2. Then set `ENABLE_CAPTCHA = false` (and the settings above) and restart Gitea. -3. Rollback: set `ENABLE_CAPTCHA = true` and restart. +1. Add the edge rate limit on `POST /user/sign_up` and `POST /user/login`, and verify it: a burst + from one IP gets 429. +2. **Gate while password basic auth is still on.** If `ENABLE_BASIC_AUTHENTICATION` must stay `true` + (for example because a service mints or uses agent tokens with passwords), do not remove the + captcha until one of these is true: + - edge rate limits also cover requests carrying `Authorization: Basic` to `/api/v1/` and to git + HTTP (`*/info/refs`, `*/git-upload-pack`, `*/git-receive-pack`), each verified by a burst that + returns 429; or + - that service has moved to tokens and `ENABLE_BASIC_AUTHENTICATION = false` is live. +3. Then set `ENABLE_CAPTCHA = false` (and the settings above) and restart Gitea. +4. Rollback: set `ENABLE_CAPTCHA = true` and restart. ## Contributing diff --git a/config/app.ini.template b/config/app.ini.template index 03f85bc..321b1bd 100644 --- a/config/app.ini.template +++ b/config/app.ini.template @@ -50,9 +50,9 @@ INTERNAL_TOKEN = ${INTERNAL_TOKEN} MIN_PASSWORD_LENGTH = 10 PASSWORD_CHECK_PWN = false ; Outgoing requests (webhooks etc.) may reach public hosts only: no loopback, private or -; link-local addresses (SSRF guard). Gitea >= 28 reads this here; [webhook] ALLOWED_HOST_LIST is -; deprecated there. On Gitea 1.23 put the same line under [webhook] instead. -ALLOWED_HOST_LIST = external +; link-local addresses (SSRF guard). Gitea 28: lax = block private/loopback, strict = only hosts +; in [security] ALLOWED_HOST_LIST. (Gitea 1.23 has no EGRESS_MODE; use [webhook] ALLOWED_HOST_LIST = external.) +EGRESS_MODE = lax diff --git a/kit.sh b/kit.sh index 7e13f96..0f70fcb 100755 --- a/kit.sh +++ b/kit.sh @@ -73,7 +73,13 @@ install_binary() { local url="https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-${arch}" mkdir -p bin; log "downloading $url" curl -fsSL -o bin/gitea.tmp "$url" - local want got; want="$(curl -fsSL "$url.sha256" | awk '{print $1}')"; got="$(sha256sum bin/gitea.tmp | awk '{print $1}')" + # sha256 pinned per release (from dl.gitea.com//*.sha256); update with GITEA_VERSION. + local want got; case "$GITEA_VERSION-$arch" in + 28.1.0-amd64) want=ea93cfa4a300564ac457d3903469fea49ec5a4da37d0cebaa435f8e329ea64d0 ;; + 28.1.0-arm64) want=47bf18a8e4602b17910cd660a902541e2c13b67c732f2607da8326fa3cd46805 ;; + 28.1.0-arm-6) want=2bf4d222be997103b6668cdf241a83bb26ec365ee6d182faa67ec9f7f021ef39 ;; + *) rm -f bin/gitea.tmp; die "no pinned sha256 for gitea $GITEA_VERSION/$arch; add it to install_binary" ;; + esac; got="$(sha256sum bin/gitea.tmp | awk '{print $1}')" [ -n "$want" ] && [ "$want" = "$got" ] || { rm -f bin/gitea.tmp; die "sha256 mismatch for gitea binary"; } chmod +x bin/gitea.tmp; mv bin/gitea.tmp bin/gitea; log "gitea $GITEA_VERSION verified (sha256 $got)" } @@ -93,7 +99,7 @@ cmd_init() { start() { if [ "$KIT_MODE" = docker ]; then compose up -d - elif systemctl is-enabled gitea >/dev/null 2>&1; then sudo systemctl start gitea + elif [ -f "$KIT_DIR/.kit-systemd" ]; then sudo systemctl start gitea else mkdir -p "$WORK_PATH/log" if [ -f "$WORK_PATH/gitea.pid" ] && kill -0 "$(cat "$WORK_PATH/gitea.pid")" 2>/dev/null; then log "already running"; return; fi @@ -175,10 +181,17 @@ cmd_backup() { chmod 600 "backups/$f"; log "backup: backups/$f (contains secrets; store it encrypted)" } -cmd_down() { +cmd_down() { # stops only what this kit started: its compose project, its own pid, or the unit it installed if [ "$KIT_MODE" = docker ]; then compose down - elif systemctl is-active gitea >/dev/null 2>&1; then sudo systemctl stop gitea - elif [ -f "$WORK_PATH/gitea.pid" ]; then kill "$(cat "$WORK_PATH/gitea.pid")" 2>/dev/null || true; rm -f "$WORK_PATH/gitea.pid"; log "stopped" + elif [ -f "$WORK_PATH/gitea.pid" ]; then + local p; p="$(cat "$WORK_PATH/gitea.pid")" + local cmd=""; cmd="$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null || true)" + if kill -0 "$p" 2>/dev/null && [[ "$cmd" == *"--work-path $WORK_PATH "* ]]; then + kill "$p"; log "stopped pid $p" + else log "pid $p is not this kit's gitea; not touching it"; fi + rm -f "$WORK_PATH/gitea.pid" + elif [ -f "$KIT_DIR/.kit-systemd" ]; then sudo systemctl stop gitea + else log "nothing started by this kit is running" fi } @@ -198,6 +211,7 @@ cmd_install_service() { # binary mode, as root: git user, binary, config, system sed -e "s#/var/lib/gitea#$WORK_PATH#g" -e "s#/etc/gitea/app.ini#$CONF_HOST#g" -e "s#/etc/gitea#$(dirname "$CONF_HOST")#g" \ systemd/gitea.service > /etc/systemd/system/gitea.service systemctl daemon-reload; systemctl enable --now gitea + echo /etc/systemd/system/gitea.service > "$KIT_DIR/.kit-systemd" wait_healthy; cmd_setup log "systemd service 'gitea' enabled; run later kit commands with sudo" } diff --git a/tests/e2e-binary.sh b/tests/e2e-binary.sh index bf6b4e7..70afb1d 100755 --- a/tests/e2e-binary.sh +++ b/tests/e2e-binary.sh @@ -8,7 +8,7 @@ TMP="$(mktemp -d)"; WORK="$TMP/kit"; CLONE="$TMP/clone" pass=0; fail=0 ok() { echo "PASS $*"; pass=$((pass+1)); } bad() { echo "FAIL $*"; fail=$((fail+1)); } -cleanup() { (cd "$WORK" 2>/dev/null && ./kit.sh down >/dev/null 2>&1) || true; rm -rf "$TMP"; } +cleanup() { [ -n "${LPID:-}" ] && kill "$LPID" 2>/dev/null || true; (cd "$WORK" 2>/dev/null && ./kit.sh down >/dev/null 2>&1) || true; rm -rf "$TMP"; } trap cleanup EXIT ss -ltn 2>/dev/null | grep -q ":$PORT " && { echo "port $PORT is busy"; exit 2; } @@ -76,23 +76,36 @@ c=$(curl -s -o /dev/null -w '%{http_code}' -u "kitadmin:$T" "$A/user"); [ "$c" = c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $T" "$A/user"); [ "$c" = 200 ] && ok "token header works" || bad "token header: $c" (cd "$CLONE" && git checkout -qb pw-push && if git -c credential.helper= push -q "http://kitadmin:$P@127.0.0.1:$PORT/kitadmin/demo.git" pw-push 2>/dev/null; then exit 1; fi) && ok "git push over HTTP refuses password basic auth" || bad "git HTTP push accepted a password" c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" -H "Content-Type: application/json" -X POST "$A/orgs" -d '{"username":"e2e-org2"}'); [ "$c" = 403 ] && ok "agent token cannot create an org (403)" || bad "agent org create: $c" -c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" -X PUT --data-binary 'blob' "http://127.0.0.1:$PORT/api/packages/e2e-agent/generic/p/1.0/f.bin"); [ "$c" != 201 ] && ok "package upload refused ($c, registry off)" || bad "package upload accepted" +c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" -X PUT --data-binary 'blob' "http://127.0.0.1:$PORT/api/packages/e2e-agent/generic/p/1.0/f.bin"); [ "$c" = 404 ] && ok "package upload refused (404, registry off)" || bad "package upload: $c (want 404)" LPORT=$((PORT+7)); HIT="$TMP/webhook-hit" python3 -c "import http.server,sys class H(http.server.BaseHTTPRequestHandler): def do_POST(s): open(sys.argv[2],'w').write('hit'); s.send_response(200); s.end_headers() def log_message(s,*a): pass -http.server.HTTPServer(('127.0.0.1',int(sys.argv[1])),H).serve_forever()" "$LPORT" "$HIT" & LPID=$! +http.server.HTTPServer(('127.0.0.1',int(sys.argv[1])),H).serve_forever()" "$LPORT" "$HIT" >/dev/null 2>&1 & LPID=$! sleep 0.5 HID=$(curl -s -H "Authorization: token $AT" -H "Content-Type: application/json" -X POST "$A/repos/e2e-agent/agent-repo/hooks" \ -d "{\"type\":\"gitea\",\"active\":true,\"config\":{\"url\":\"http://127.0.0.1:$LPORT/\",\"content_type\":\"json\"}}" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null || true) [ -n "$HID" ] && ok "webhook to 127.0.0.1 created (id $HID); delivering test" || bad "webhook create failed" [ -n "$HID" ] && curl -s -o /dev/null -H "Authorization: token $AT" -X POST "$A/repos/e2e-agent/agent-repo/hooks/$HID/tests" -sleep 4; kill $LPID 2>/dev/null || true -[ ! -e "$HIT" ] && ok "webhook to 127.0.0.1 not delivered (ALLOWED_HOST_LIST=external)" || bad "webhook reached 127.0.0.1" -grep -q 'not allowed\|blocked\|ALLOWED_HOST' "$TMP/home/log/"*.log 2>/dev/null && ok "Gitea logged the blocked webhook host" || echo "note: no block line found in the Gitea log (delivery absence is the check)" +LOGS=("$TMP/home") +denied=0; for _ in $(seq 1 30); do + grep -rqs 'denied by egress' "${LOGS[@]}" 2>/dev/null && { denied=1; break; }; sleep 0.5; done +kill $LPID 2>/dev/null || true +[ "$denied" = 1 ] && ok "Gitea logged the webhook as denied by egress" || bad "no 'denied by egress' line within 15s" +[ ! -e "$HIT" ] && ok "webhook to 127.0.0.1 not delivered ([security] EGRESS_MODE=lax)" || bad "webhook reached 127.0.0.1" +# 4 MB attachment cap: a small file is accepted, a 5 MB file is refused +NUM=$(curl -s -H "Authorization: token $AT" -H "Content-Type: application/json" -X POST "$A/repos/e2e-agent/agent-repo/issues" -d '{"title":"attach"}' | python3 -c 'import json,sys;print(json.load(sys.stdin).get("number",""))') +head -c 1024 /dev/urandom > "$TMP/small.txt"; head -c $((5*1024*1024)) /dev/urandom > "$TMP/big.txt" +c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" -F "attachment=@$TMP/small.txt" "$A/repos/e2e-agent/agent-repo/issues/$NUM/assets"); [ "$c" = 201 ] && ok "1 KB attachment accepted" || bad "small attachment: $c" +c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" -F "attachment=@$TMP/big.txt" "$A/repos/e2e-agent/agent-repo/issues/$NUM/assets"); [ "$c" != 201 ] && ok "5 MB attachment refused ($c, MAX_SIZE=4)" || bad "5 MB attachment accepted" +# Actions off: the Actions API is not served +ha=$(curl -s -H "Authorization: token $AT" "$A/repos/e2e-agent/agent-repo" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("has_actions"))') +c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" "http://127.0.0.1:$PORT/e2e-agent/agent-repo/actions") +[ "$ha" = False ] && [ "$c" = 404 ] && ok "Actions disabled (has_actions false, /actions page 404)" || bad "Actions: has_actions=$ha, /actions page $c" echo "PENDING sign-up burst: throttling is the edge rate-limit rule (Cloudflare/proxy), not Gitea; test once the rule exists" if grep -q "Deprecation" "$TMP/home/log/gitea.out"; then bad "config uses deprecated keys: $(grep -o 'config option `[^`]*`' "$TMP/home/log/gitea.out" | sort -u | tr '\n' ' ')"; else ok "no deprecated config keys in the Gitea log"; fi +if grep -E '\[E\]|invalid entry' "$TMP/home/log/gitea.out" | grep -v 'denied by egress' | grep -q .; then bad "Gitea logged errors: $(grep -E '\[E\]|invalid entry' "$TMP/home/log/gitea.out" | grep -v 'denied by egress' | head -3)"; else ok "no [E] or 'invalid entry' lines in the Gitea log"; fi echo; echo "$pass passed, $fail failed" [ "$fail" = 0 ]