Episode 15: offboarding waits out the Vaultwarden admin rate limit, fail2ban exception only on the provider host

Measured clean run: Pocket ID disable alone left 12 of 14 ways open, the
script (0.9 s, 9 of 9 steps) left 1 of 14.
This commit is contained in:
Maximilian Grimm 2026-09-23 12:08:08 +02:00
parent 12542ecb19
commit 54ab43dac7
3 changed files with 16 additions and 8 deletions

View file

@ -587,11 +587,12 @@ before the eth0 lines:
**fail2ban locked out people logging in.** The `caddy-probes` jail counts **fail2ban locked out people logging in.** The `caddy-probes` jail counts
401/403/404. Pocket ID's login page asks `/api/users/me` (401 without a session) 401/403/404. Pocket ID's login page asks `/api/users/me` (401 without a session)
and the logo (404) on every load. Ten page loads in a minute and the employee is and the logo (404) on every load. Ten page loads in a minute and the employee is
banned for an hour. Narrow exception in `filter.d/caddy-probes.conf`: banned for an hour. Narrow exception in `filter.d/caddy-probes.conf`, only for
the provider's own host, so the same paths elsewhere still count:
``` ```
ignoreregex = "uri":"/api/users/me" ignoreregex = "host":"id\.[^"]*","uri":"/api/users/me"
"uri":"/api/application-images/ "host":"id\.[^"]*","uri":"/api/application-images/
``` ```
**Pocket ID's `/setup` belongs to whoever opens it first.** A scanner fetched **Pocket ID's `/setup` belongs to whoever opens it first.** A scanner fetched

View file

@ -7,7 +7,7 @@ Measured on the demo box, with a test employee who had the access a real one has
| --- | --- | | --- | --- |
| Forgejo login prohibited (Forgejo was the SSO provider) | **10 of 13**, unchanged for 45 minutes | | Forgejo login prohibited (Forgejo was the SSO provider) | **10 of 13**, unchanged for 45 minutes |
| Pocket ID account disabled, nothing else | **12 of 14** | | Pocket ID account disabled, nothing else | **12 of 14** |
| `./offboard.py <user>` (about one second) | **1 of 14**: the vault copy on their laptop | | `./offboard.py <user>` (0.9 s, all 9 steps ok) | **1 of 14**: the vault copy on their laptop |
What stayed open with only the SSO switched off: the wiki session (valid for What stayed open with only the SSO switched off: the wiki session (valid for
three months) and its API key, the vault sync (Forgejo kept handing out fresh three months) and its API key, the vault sync (Forgejo kept handing out fresh
@ -65,7 +65,8 @@ give every key an expiry, and renew them on purpose.
without a JSON content type is not a 403, it is a 404, and it looks exactly without a JSON content type is not a 403, it is a 404, and it looks exactly
like "user not found". like "user not found".
- **Vaultwarden rate-limits the admin login** (a handful per five minutes). Two - **Vaultwarden rate-limits the admin login** (a handful per five minutes). Two
test runs back to back and the third one gets 429. The script says so. test runs back to back and the third one gets 429. The script now waits 60 s
and retries, up to six times, and prints each wait.
- **An old n8n API key cannot delete users.** Keys created before scopes - **An old n8n API key cannot delete users.** Keys created before scopes
existed, or with the wrong ones, get 403. Create one with `user:delete`. existed, or with the wrong ones, get 403. Create one with `user:delete`.
- **Forgejo tokens:** an admin can list a user's tokens (`Sudo:` header) but - **Forgejo tokens:** an admin can list a user's tokens (`Sudo:` header) but

View file

@ -125,9 +125,15 @@ def outline(email):
def vaultwarden(email): def vaultwarden(email):
jar = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) jar = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
base = f"http://{ip('vaultwarden')}" base = f"http://{ip('vaultwarden')}"
# The admin login is rate-limited (a few tries per 5 minutes). On 429 wait and retry.
for attempt in range(6):
s, _ = call("POST", f"{base}/admin", {"token": CFG["VAULTWARDEN_ADMIN_TOKEN"]}, form=True, opener=jar) s, _ = call("POST", f"{base}/admin", {"token": CFG["VAULTWARDEN_ADMIN_TOKEN"]}, form=True, opener=jar)
if s != 200: # 429: the admin login allows only a few tries per 5 minutes if s != 429:
return step("vaultwarden", f"admin login refused (HTTP {s}), run again in 5 minutes", False) break
step("vaultwarden", f"admin login rate-limited (429), waiting 60 s, try {attempt + 2} of 6", True)
time.sleep(60)
if s != 200:
return step("vaultwarden", f"admin login refused (HTTP {s})", False)
s, users = call("GET", f"{base}/admin/users", opener=jar) s, users = call("GET", f"{base}/admin/users", opener=jar)
hits = [u for u in (users if s == 200 else []) if u["email"] == email] hits = [u for u in (users if s == 200 else []) if u["email"] == email]
if not hits: if not hits: