Skip to content

fix(web): harden signup against bot/spam abuse (tier 2)#20

Merged
dylanroscover merged 2 commits into
devfrom
fix/signup-abuse-tier2
Jun 20, 2026
Merged

fix(web): harden signup against bot/spam abuse (tier 2)#20
dylanroscover merged 2 commits into
devfrom
fix/signup-abuse-tier2

Conversation

@dylanroscover

Copy link
Copy Markdown
Member

why

A spam bot self-registered with a Turkish gambling ad stuffed into its display name (salavat@ya.ru"15K lira bonus kapıda! https://bit.ly/trclicko 🔥 Go"). Self-serve signup had zero abuse controls: no rate limit, no input sanitization, no disposable-email gating.

Severity is contained, not critical. Firestore rules already pin new accounts to role=member / sites=[] / mandatory 2FA, so a spam account has no data access and the display name renders as escaped JSX (the bit.ly link is inert text, not a clickable link or XSS). The real harms are admin user-list pollution, the display name as a billboard, and Firebase Auth quota — i.e. spam hygiene.

This is Tier 2 of a layered plan (server-side gates that reuse existing infra). Tier 1 (Firebase App Check / Auth blocking functions, which cap the upstream Auth account itself) is tracked separately.

what

Three server-side layers at the /api/users/bootstrap chokepoint (the write that creates the users/{uid} doc — i.e. the row in the admin table):

  1. Per-IP signup rate limit — new signupRateLimit preset (10/hr prod, 100/hr dev) wired through withRateLimit, same pattern as forgot-password. Only fires on first-login bootstrap (the auth listener bootstraps solely when the user doc is missing), so returning users behind a NAT are unaffected. Honors the E2E_DISABLE_RATE_LIMIT escape hatch → no CI impact.
  2. sanitizeDisplayName() — strips URLs (incl. bare bit.ly/…), caps emoji, removes invisible/RTL-override spoof chars, caps length. Applied in bootstrapUser.server.ts (single write chokepoint), so it covers both the signup form and direct Firebase-API signups. This is what actually neutralizes this attack's payload.
  3. isDisposableEmailDomain() — curated temp-mail blocklist; rejects with a 400.

honest caveats (please read)

tests

  • sanitizeDisplayName + isDisposableEmailDomain unit suites; spam-name case added to the bootstrap action test. 63 passing.
  • One test caught a real bug (tabs/newlines were deleted, silently joining words) — fixed to collapse control chars to a space for display names.
  • Lint clean; tsc clean for all touched files.

⚠️ Full Playwright e2e not run locally (heavy). Recommend /preflight before merge; PR CI e2e will also gate.

follow-ups (not in this PR)

  • Tier 1: Firebase App Check (enforce) + Auth blocking functions.
  • One-off cleanup of existing spam rows.
  • Optional: Cloudflare Turnstile on the register form.

🤖 Generated with Claude Code

Self-serve signup had zero abuse controls, letting a bot create an account
with a spam ad in the display name (salavat@ya.ru, "15K lira bonus … bit.ly/…").
Firestore rules already contain the blast radius (role=member, sites=[],
mandatory 2FA — no data access), so this is spam hygiene, not a breach. Adds
three server-side layers at the /api/users/bootstrap chokepoint:

- per-IP signup rate limit (signupRateLimit: 10/hr prod, 100/hr dev) wired
  through withRateLimit. Only fires on first-login bootstrap (the listener
  bootstraps solely when the user doc is missing), so returning users are
  unaffected. Honors the E2E_DISABLE_RATE_LIMIT escape hatch.
- sanitizeDisplayName(): strips URLs / emoji-spam / invisible+RTL spoof chars
  and caps length, applied at the single write chokepoint so it covers form
  and direct-API signups alike. This neutralises the billboard payload.
- isDisposableEmailDomain(): curated temp-mail blocklist (excludes mainstream
  providers incl. yandex/ya.ru — defence-in-depth only).

Caveats: ya.ru is legitimate Yandex, so the domain block wouldn't have stopped
this specific bot — the URL strip + rate cap do. Capping the upstream Firebase
Auth account itself (vs the visible Firestore doc) is Tier 1 (App Check /
blocking functions), tracked separately.

Tests: sanitizeDisplayName + isDisposableEmailDomain unit suites + a spam-name
case on the bootstrap action. 63 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
owlette Ignored Ignored Jun 20, 2026 10:21pm

Fast-follow from pre-deploy review. The disposable-email block and per-IP
signup rate limit live in the route, but only their pure helpers were tested —
a wiring regression (dropping the withRateLimit wrap, or moving the disposable
check after the bootstrap write) would have passed every existing test.

Adds 4 route-level tests asserting: disposable-domain email -> 400 and
bootstrapUser is NEVER called; normal email -> 200 + bootstrap; rate-limit
exceeded -> 429 and no write; malformed email -> 400 before the disposable
check. 67 PR#20-related tests passing; lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant