From 7e430998b8aed39484585467899c7d38b23e8d36 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 11 May 2026 13:10:08 +0200 Subject: [PATCH] security(fail2ban): widen jail to /forgot-password and rate-limit 429 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter only watched /api/auth/login 401 — leaving the forgot-password endpoint open to: - email enumeration (slow brute-force probing which addresses exist) - password-reset brute-force against accounts whose addresses leak Widens the failregex to /api/auth/(login|forgot-password) and adds 429 to the status alternation so a future in-app rate-limiter response is also caught by the jail (defense in depth). CI assertions extended to cover both new dimensions plus a negative case on an unrelated 401 endpoint (/api/documents) — pins that the widening did not over-match. Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/ci.yml | 24 +++++++++++++++++++ .../filter.d/familienarchiv-auth.conf | 14 +++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index fd30bac6..6f6aa0e0 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -140,6 +140,22 @@ jobs: echo "$out" | grep -qE '1 matched' \ || { echo "expected 1 match for /api/auth/login 401"; exit 1; } + - name: Matches /api/auth/login 429 + run: | + echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/login"},"status":429}' > /tmp/sample.log + out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf) + echo "$out" + echo "$out" | grep -qE '1 matched' \ + || { echo "expected 1 match for /api/auth/login 429"; exit 1; } + + - name: Matches /api/auth/forgot-password 401 + run: | + echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/forgot-password"},"status":401}' > /tmp/sample.log + out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf) + echo "$out" + echo "$out" | grep -qE '1 matched' \ + || { echo "expected 1 match for /api/auth/forgot-password 401"; exit 1; } + - name: Does not match /api/auth/login 200 run: | echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/login"},"status":200}' > /tmp/sample.log @@ -148,6 +164,14 @@ jobs: echo "$out" | grep -qE '0 matched' \ || { echo "expected 0 matches for /api/auth/login 200"; exit 1; } + - name: Does not match /api/documents (unrelated 401) + run: | + echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"GET","host":"archiv.raddatz.cloud","uri":"/api/documents"},"status":401}' > /tmp/sample.log + out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf) + echo "$out" + echo "$out" | grep -qE '0 matched' \ + || { echo "expected 0 matches for /api/documents 401"; exit 1; } + # ─── Compose Bucket-Bootstrap Idempotency ───────────────────────────────────── # docker-compose.prod.yml's create-buckets service runs on every # `docker compose up` (one-shot, no restart). Must be idempotent — a diff --git a/infra/fail2ban/filter.d/familienarchiv-auth.conf b/infra/fail2ban/filter.d/familienarchiv-auth.conf index 6f06551f..0f85a798 100644 --- a/infra/fail2ban/filter.d/familienarchiv-auth.conf +++ b/infra/fail2ban/filter.d/familienarchiv-auth.conf @@ -1,5 +1,5 @@ # fail2ban filter for credential-stuffing attempts against the -# Familienarchiv login endpoint. +# Familienarchiv authentication endpoints. # # Parses Caddy JSON access log entries (configured in # infra/caddy/Caddyfile via the (access_log) snippet). @@ -12,6 +12,16 @@ # "uri":"/api/auth/login",…}, # "status":401,…} # +# Watched endpoints: +# - /api/auth/login — credential stuffing +# - /api/auth/forgot-password — email enumeration + slow brute-force +# against accounts whose addresses leak +# +# Watched statuses: +# - 401 — bad credentials +# - 429 — server-side rate limit (in case a future in-app limiter +# returns 429 before fail2ban catches the volume) +# # Caddy emits remote_ip *inside* the request object and status at the # top level. The order within the request object is stable # (remote_ip → … → uri) across Caddy 2.7+. Lazy `.*?` keeps the regex @@ -21,7 +31,7 @@ before = common.conf [Definition] -failregex = ^\s*\{.*?"remote_ip":"".*?"uri":"/api/auth/login.*?"status":\s*401\b +failregex = ^\s*\{.*?"remote_ip":"".*?"uri":"/api/auth/(login|forgot-password).*?"status":\s*4(01|29)\b ignoreregex =