Skip to content

Signup: enumeration via /signup/check-email + /api/check-username + missing rate-limit #292

@rubin110

Description

@rubin110

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:

  1. Rate-limit signup endpoints at the nginx layer (limit_req) or via Flask-Limiter at the portal. Per-IP, per-endpoint.
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecurity

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions