security(fail2ban): widen jail to /forgot-password and rate-limit 429

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-11 13:10:08 +02:00
parent 156afa14a2
commit 7e430998b8
2 changed files with 36 additions and 2 deletions

View File

@@ -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

View File

@@ -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":"<HOST>".*?"uri":"/api/auth/login.*?"status":\s*401\b
failregex = ^\s*\{.*?"remote_ip":"<HOST>".*?"uri":"/api/auth/(login|forgot-password).*?"status":\s*4(01|29)\b
ignoreregex =