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:
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
|
||||
Reference in New Issue
Block a user