fix(auth): invalidate email OTP on failed guess, not just a match (#2221) - #2269
fix(auth): invalidate email OTP on failed guess, not just a match (#2221)#2269mini0n-ai wants to merge 1 commit into
Conversation
| ), | ||
| ); | ||
| .where(eq(verificationTokens.identifier, row.identifier)); | ||
|
|
There was a problem hiding this comment.
P2: The OTP deletion is scoped only by identifier and can invalidate a different token during rotation
DELETE filters only by email, so OTP rotation or concurrent requests can consume the wrong row or replay one OTP.
Atomically consume the selected token by its unique key and verify the affected-row count; add race tests.
AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.
<file name="packages/database/auth/drizzle-adapter.ts">
<violation number="1" location="packages/database/auth/drizzle-adapter.ts:528">
<priority>P2</priority>
<title>The OTP deletion is scoped only by identifier and can invalidate a different token during rotation</title>
<evidence>After selecting one row by normalized identifier, the new deletion uses only eq(verificationTokens.identifier, row.identifier). A token generated for the same email between the SELECT and DELETE, or another concurrently stored token for that email, can therefore be deleted even though it was not the row selected for this request. The separate SELECT and DELETE also leave concurrent verification requests able to both observe and return the same valid row before either deletion is committed, so this does not provide the claimed replay/race protection.</evidence>
<recommendation>Consume the exact selected row atomically: use a transaction with row locking, or a single conditional DELETE/UPDATE that identifies the row by its unique token/key and returns the consumed row. Check the affected-row/result count before returning success, and add concurrency and token-rotation tests.</recommendation>
</violation>
</file>
| await db | ||
| .delete(verificationTokens) | ||
| .where( | ||
| and( | ||
| eq(verificationTokens.token, token), | ||
| eq(verificationTokens.identifier, row.identifier), | ||
| ), | ||
| ); | ||
| .where(eq(verificationTokens.identifier, row.identifier)); |
There was a problem hiding this comment.
Replacement token can be deleted
If a user requests a new email link after this lookup but before the delete, token creation updates the row for the same identifier. This identifier-only delete then removes the newly issued token, making the fresh link unusable. The deletion should check both the identifier and the selected token, and consumption should only succeed when that row was deleted.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/database/auth/drizzle-adapter.ts
Line: 525-527
Comment:
**Replacement token can be deleted**
If a user requests a new email link after this lookup but before the delete, token creation updates the row for the same identifier. This identifier-only delete then removes the newly issued token, making the fresh link unusable. The deletion should check both the identifier and the selected token, and consumption should only succeed when that row was deleted.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| delete: () => ({ | ||
| where: () => { | ||
| deleted = true; | ||
| return Promise.resolve(); | ||
| }, | ||
| }), |
There was a problem hiding this comment.
Mocks ignore deletion predicates
The deletion mocks discard the where predicate and only record that a delete was attempted. These tests would still pass if deletion filtered by the submitted token, even though a wrong guess would then leave the real OTP valid. The repeated mocks have the same limitation. Test the row-level effect against a test database or assert the effective predicate so this security behavior is protected.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/__tests__/unit/verification-token.test.ts
Line: 23-28
Comment:
**Mocks ignore deletion predicates**
The deletion mocks discard the `where` predicate and only record that a delete was attempted. These tests would still pass if deletion filtered by the submitted token, even though a wrong guess would then leave the real OTP valid. The repeated mocks have the same limitation. Test the row-level effect against a test database or assert the effective predicate so this security behavior is protected.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const adapter = DrizzleAdapter(mockDb); | ||
| const result = await adapter.useVerificationToken?.({ | ||
| identifier: "USER@example.com", | ||
| token: "999999", // wrong guess |
There was a problem hiding this comment.
Comments merely narrate inputs
The // wrong guess comment, like the later // correct guess comment, only restates the visible test input. The repository directive requires defaulting to no comments and prohibits comments that merely narrate the code. Remove both comments before merging to satisfy that requirement.
Context Used: AGENTS.md (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/__tests__/unit/verification-token.test.ts
Line: 34
Comment:
**Comments merely narrate inputs**
The `// wrong guess` comment, like the later `// correct guess` comment, only restates the visible test input. The repository directive requires defaulting to no comments and prohibits comments that merely narrate the code. Remove both comments before merging to satisfy that requirement.
**Context Used:** AGENTS.md ([source](https://github.com/capsoftware/cap/blob/main/AGENTS.md))
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| deletePredicate = pred; | ||
| return Promise.resolve(); | ||
| }, | ||
| }), |
There was a problem hiding this comment.
P2: OTP invalidation tests do not verify the deletion predicate or database effect
The mock only records that delete.where() was called; it never applies the predicate or verifies a row was removed.
Use a stateful/database-backed mock and assert the exact row-level invalidation and affected-row result.
AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.
<file name="apps/web/__tests__/unit/verification-token.test.ts">
<violation number="1" location="apps/web/__tests__/unit/verification-token.test.ts:28">
<priority>P2</priority>
<title>OTP invalidation tests do not verify the deletion predicate or database effect</title>
<evidence>The mock delete implementation records that where() received some value, but does not evaluate the predicate or remove any row. Consequently, the tests pass even if production deletes the wrong token, fails to burn a mismatched token, or uses an ineffective condition.</evidence>
<recommendation>Use a test database or a stateful mock that applies the where predicate, and assert that the selected row is actually removed while an unrelated/replacement row remains. Also assert the affected-row behavior used by the production implementation.</recommendation>
</violation>
</file>
40adcaa to
5b8b604
Compare
5b8b604 to
06cbdbb
Compare
|
Updated with atomic token consumption and strengthened test assertions:
Ready for review! |
Summary
Resolves the OTP brute-force vulnerability where
useVerificationTokeninpackages/database/auth/drizzle-adapter.tsonly deleted the verification token row on an exact match. A wrong 6-digit code guess looked up by the non-existent token, found no row, and returnednullwithout invalidating the token row. This allowed an attacker unlimited attempts against a valid OTP for its entire TTL.Resolves #2221
/claim #2221
Solution Details
identifier(email) to lower-case.eq(verificationTokens.identifier, normalizedIdentifier).verificationTokenson lookup attempt so that failed guesses burn the token and prevent replay/race conditions.null(token is already invalidated).apps/web/__tests__/unit/verification-token.test.tscovering token invalidation on mismatch, successful verification on match, and non-existent tokens.Quality & Compliance
bun run biome check)Bounty Payout Address (USDC on Base):
0x46D5318E4397cFcBED06a235c1604E473682Ea1FThis PR is not yet safe to merge because consuming an older OTP can delete a concurrently issued replacement token, and the explicit repository comment rule must also be satisfied.
Findings
Fix with agent prompt
Summary
Reviews (1) · Last reviewed commit: "fix(auth): invalidate email OTP on faile..."