fix(web): harden signup against bot/spam abuse (tier 2)#20
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
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>
This was referenced Jun 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 (thebit.lylink 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/bootstrapchokepoint (the write that creates theusers/{uid}doc — i.e. the row in the admin table):signupRateLimitpreset (10/hr prod, 100/hr dev) wired throughwithRateLimit, same pattern asforgot-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 theE2E_DISABLE_RATE_LIMITescape hatch → no CI impact.sanitizeDisplayName()— strips URLs (incl. barebit.ly/…), caps emoji, removes invisible/RTL-override spoof chars, caps length. Applied inbootstrapUser.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.isDisposableEmailDomain()— curated temp-mail blocklist; rejects with a 400.honest caveats (please read)
ya.ruis legitimate Yandex, deliberately not on the blocklist — so layer White text, dark background in entries #3 would not have stopped this specific bot. Layers ERROR 1053: The service did not respond to the start or control request in a timely fashion #2 (URL strip) + Dev #1 (rate cap) are what defuse it; White text, dark background in entries #3 is defense-in-depth for the common temp-mail vector.signupRateLimitnumbers are starting points — tune if a legit org onboards >10 brand-new accounts/hr from one IP.tests
sanitizeDisplayName+isDisposableEmailDomainunit suites; spam-name case added to the bootstrap action test. 63 passing.tscclean for all touched files.follow-ups (not in this PR)
🤖 Generated with Claude Code