Discovered during ad-hoc testing on PR #284. Full context in .claude/notes/2026-05-20-signup-flow-adhoc-findings.md — Findings 1, 2, 13.
Finding 1 — Email enumeration via /signup/check-email
POST /signup/check-email returns distinguishable responses based on whether the email belongs to an existing member:
| Email state |
Response |
| Existing member |
HTTP 302 → /login (body 199 bytes) |
| Not a member |
HTTP 200 rendering signup_form.html (body ~24 KB) |
| Empty |
HTTP 200 rendering signup_email.html + error flash |
Unauthenticated attacker can iterate emails to identify PS1 members. No CAPTCHA, no rate limit.
Finding 2 — Username enumeration via /api/check-username
GET /api/check-username?username=... returns {"is_taken": true|false} with no authentication required (works without cookies). Same enumeration risk, AJAX-friendly. No Origin check.
Finding 13 — No rate limiting on any signup-flow endpoint
Across all ad-hoc tests, no 429s, no captchas, no IP-based throttling. nginx config has no limit_req. Easy abuse vector: scripted enumeration, mass-creation of pending rows, etc.
Suggested approach
Two paths, possibly both:
- Rate-limit signup endpoints at the nginx layer (
limit_req) or via Flask-Limiter at the portal. Per-IP, per-endpoint.
- Unify response shapes to eliminate the distinguisher: always render
signup_form.html from /signup/check-email and defer the "already exists" check to /signup/submit. For /api/check-username, either gate behind a session cookie + CSRF, or rate-limit aggressively.
Context: #283, PR #284
Discovered during ad-hoc testing on PR #284. Full context in
.claude/notes/2026-05-20-signup-flow-adhoc-findings.md— Findings 1, 2, 13.Finding 1 — Email enumeration via
/signup/check-emailPOST /signup/check-emailreturns distinguishable responses based on whether the email belongs to an existing member:/login(body 199 bytes)signup_form.html(body ~24 KB)signup_email.html+ error flashUnauthenticated attacker can iterate emails to identify PS1 members. No CAPTCHA, no rate limit.
Finding 2 — Username enumeration via
/api/check-usernameGET /api/check-username?username=...returns{"is_taken": true|false}with no authentication required (works without cookies). Same enumeration risk, AJAX-friendly. No Origin check.Finding 13 — No rate limiting on any signup-flow endpoint
Across all ad-hoc tests, no 429s, no captchas, no IP-based throttling. nginx config has no
limit_req. Easy abuse vector: scripted enumeration, mass-creation ofpendingrows, etc.Suggested approach
Two paths, possibly both:
limit_req) or via Flask-Limiter at the portal. Per-IP, per-endpoint.signup_form.htmlfrom/signup/check-emailand defer the "already exists" check to/signup/submit. For/api/check-username, either gate behind a session cookie + CSRF, or rate-limit aggressively.Context: #283, PR #284