Skip to content

fix(auth): rate-limit + body-cap the public register endpoint (TODO 8/9 §B) - #106

Closed
lopugit wants to merge 3 commits into
mainfrom
claude/lockdown-unauth-endpoints-s7
Closed

fix(auth): rate-limit + body-cap the public register endpoint (TODO 8/9 §B)#106
lopugit wants to merge 3 commits into
mainfrom
claude/lockdown-unauth-endpoints-s7

Conversation

@lopugit

@lopugit lopugit commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Advances TODO #8 / claude-todo/09-security-hardening.md §B: POST /api/v1/auth/register was the last unhardened public auth endpoint. login, resend-verification, and password-reset already enforce the shared Mongo-backed rate limiter, but register had no throttle and no body cap — each request costs a bcrypt hash plus a verification email to any caller-named address, so it was brute-forceable and a mail-bomb / DoS-amplification vector at full speed.

What changed

  • New auth.register rule in rateLimit/config.ts: 10 per 15 min per IP — same bucket family and window as auth.passwordReset / auth.resendVerification, admin-overridable like every other rule. Single source of truth; no second limiter invented.
  • Enforced before the body read in _register.tsx, so a burst of empty-body signups (each a 400) still trips the cap. Returns 429 + Retry-After via the existing rateLimitedResponseInit helper.
  • Body-size cap: register now reads through readJsonBody(request, 16*1024) (the same idiom the things/auth routes already use) → 413 on oversize before any bcrypt/DB work.

Not touched because already safe: meta mass-assignment — register never forwards caller meta, and privileged keys are stripped again at the createUserAccount chokepoint (sanitizeCreateMeta). Service-account (A3) throttling is a sibling branch (claude/service-account-provisioning-hardening, PR #100); raw-results (A1) and populate (A2) already gained requireAdmin + failClosed limiters on main. This PR is scoped to the register gap and does not overlap them.

Verification (live dev stack, worktree, ports 9977/9979)

Exercised the running Nitro action (the exact path the browser hits), using distinct X-Forwarded-For values to get isolated per-IP buckets:

  • 10 empty-body signups → 400 (validation), the 11th → 429 (cap trips).
  • Oversize body (20 KB) → 413 before any work.
  • Valid signup from a fresh IP → ok:true with a real user id + auth cookie.
  • Per-IP isolation confirmed: one IP's exhausted bucket does not throttle another.
  • Seeding path unaffected: registerAll in setup.ts calls registerUser() directly and never crosses the HTTP limiter.

Cleared the test rate-limit buckets from the local dev DB afterward. Added a regression checklist section to TESTING.md and ticked the two now-satisfied boxes in the security-hardening spec.

🤖 Generated with Claude Code

POST /api/v1/auth/register had no throttle and no body cap: each request
costs a bcrypt hash + a verification email to any caller-named address, so
it was brute-forceable and a mail-bomb/DoS-amplification vector at full
speed (the last unhardened auth endpoint — login, resend-verification and
password-reset already enforce the shared limiter).

- New auth.register rule in rateLimit/config.ts: 10 per 15 min per IP,
  same bucket family as auth.passwordReset/auth.resendVerification.
- Enforce it before the body read (a burst of empty-body 400s still trips
  the cap), returning 429 + Retry-After via the existing helpers.
- Read the body through readJsonBody(request, 16*1024) so an oversize
  payload is rejected with 413 before any bcrypt/DB work.

Seeding is unaffected: setup.ts calls registerUser() directly and never
crosses the HTTP limiter. meta is already stripped of privileged keys at
the createUserAccount chokepoint and register never forwards caller meta.

Verified on a live dev stack: 10 empty-body signups 400, the 11th 429,
oversize body 413, a fresh-IP valid signup returns ok:true; per-IP buckets
isolate (distinct X-Forwarded-For not throttled).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
thingtime Ready Ready Preview Jul 30, 2026 11:58am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Could not auto-resolve conflicts with main — manual resolution needed. See the workflow run.

Conflicted files (as recorded by the merge step):

  • TESTING.md

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Could not auto-resolve conflicts with main — manual resolution needed. See the workflow run.

Conflicted files (as recorded by the merge step):

  • TESTING.md

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Could not auto-resolve conflicts with main — manual resolution needed. See the workflow run.

Conflicted files (as recorded by the merge step):

  • TESTING.md

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Could not auto-resolve conflicts with main — manual resolution needed. See the workflow run.

Conflicted files (as recorded by the merge step):

  • TESTING.md

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Could not auto-resolve conflicts with main — manual resolution needed. See the workflow run.

Conflicted files (as recorded by the merge step):

  • TESTING.md

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Could not auto-resolve conflicts with main — manual resolution needed. See the workflow run.

Conflicted files (as recorded by the merge step):

  • TESTING.md

…licts)

Conflicted paths: TESTING.md

Resolved by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/30540294630

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🤝 Merged main into claude/lockdown-unauth-endpoints-s7 — conflicts auto-resolved by the resolve-pr-conflicts workflow.

Conflicted files:

  • TESTING.md

Please review the merge commit before relying on it.

lopugit added a commit that referenced this pull request Jul 30, 2026
…98/#106)

Upgrade the persist codec from strict-ISO string revival to the tagged
scheme the duplicate TODO-9 PRs used, closing the remaining false-positive:

- Real Dates persist as {ttype:'date', iso}. The replacer reads the
  original off the holder (this[key]) because Date.toJSON converts Dates
  to strings before the replacer sees the value.
- A USER string that merely looks like a full ISO timestamp is escaped as
  {ttype:'iso-string', s} so the legacy fallback can never capture it —
  previously such a string became a Date on the next load.
- The legacy bare-ISO fallback remains only to migrate pre-tagging
  persists.
- Both replacer and reviver skip their own wrappers' inner keys (without
  the guard the escape rule re-wraps its own output unboundedly, and the
  legacy fallback hands the wrapper branches a Date instead of the
  original string — caught by the new tests).

Tests: 9/9 in test:persist, including Date + identical-looking string
coexisting, two-cycle string stability, and hostile-payload drops.
TESTING.md gains the register rate-limit checklist (from #106) and a
persisted-codec checklist. Live-verified: app hydrates under the CSP,
persists and reloads cleanly, zero console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lopugit

lopugit commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Consolidating the TODO 8/9/10 security PRs: #99 is the single winner and now carries this PR's unique value (see the consolidation note on #99 for exactly what was folded in and why). Closing as a duplicate.

@lopugit lopugit closed this Jul 30, 2026
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