Skip to content

Commit 16072b5

Browse files
waleedlatif1claude
andcommitted
fix: make OTP attempt counting atomic to prevent TOCTOU race
Redis path: use Lua script for atomic read-increment-conditional-delete. DB path: use optimistic locking (UPDATE WHERE value = currentValue) with re-read fallback on conflict. Prevents concurrent wrong guesses from each counting as a single attempt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7e56894 commit 16072b5

File tree

2 files changed

+78
-21
lines changed

2 files changed

+78
-21
lines changed

apps/sim/app/api/chat/[identifier]/otp/route.test.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const {
1111
mockRedisGet,
1212
mockRedisDel,
1313
mockRedisTtl,
14+
mockRedisEval,
1415
mockGetRedisClient,
1516
mockRedisClient,
1617
mockDbSelect,
@@ -32,11 +33,13 @@ const {
3233
const mockRedisGet = vi.fn()
3334
const mockRedisDel = vi.fn()
3435
const mockRedisTtl = vi.fn()
36+
const mockRedisEval = vi.fn()
3537
const mockRedisClient = {
3638
set: mockRedisSet,
3739
get: mockRedisGet,
3840
del: mockRedisDel,
3941
ttl: mockRedisTtl,
42+
eval: mockRedisEval,
4043
}
4144
const mockGetRedisClient = vi.fn()
4245
const mockDbSelect = vi.fn()
@@ -59,6 +62,7 @@ const {
5962
mockRedisGet,
6063
mockRedisDel,
6164
mockRedisTtl,
65+
mockRedisEval,
6266
mockGetRedisClient,
6367
mockRedisClient,
6468
mockDbSelect,
@@ -573,9 +577,9 @@ describe('Chat OTP API Route', () => {
573577
mockGetStorageMethod.mockReturnValue('redis')
574578
})
575579

576-
it('should update stored value with incremented attempts on wrong OTP', async () => {
580+
it('should atomically increment attempts on wrong OTP', async () => {
577581
mockRedisGet.mockResolvedValue('654321:0')
578-
mockRedisTtl.mockResolvedValue(600)
582+
mockRedisEval.mockResolvedValue('654321:1')
579583

580584
mockDbSelect.mockImplementationOnce(() => ({
581585
from: vi.fn().mockReturnValue({
@@ -592,16 +596,18 @@ describe('Chat OTP API Route', () => {
592596

593597
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
594598

595-
expect(mockRedisSet).toHaveBeenCalledWith(
599+
expect(mockRedisEval).toHaveBeenCalledWith(
600+
expect.any(String),
601+
1,
596602
`otp:${mockEmail}:${mockChatId}`,
597-
'654321:1',
598-
'KEEPTTL'
603+
5
599604
)
600605
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Invalid verification code', 400)
601606
})
602607

603-
it('should invalidate OTP and return 429 after 5 failed attempts', async () => {
608+
it('should invalidate OTP and return 429 after max failed attempts', async () => {
604609
mockRedisGet.mockResolvedValue('654321:4')
610+
mockRedisEval.mockResolvedValue('LOCKED')
605611

606612
mockDbSelect.mockImplementationOnce(() => ({
607613
from: vi.fn().mockReturnValue({
@@ -618,7 +624,7 @@ describe('Chat OTP API Route', () => {
618624

619625
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
620626

621-
expect(mockRedisDel).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`)
627+
expect(mockRedisEval).toHaveBeenCalled()
622628
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
623629
'Too many failed attempts. Please request a new code.',
624630
429

apps/sim/app/api/chat/[identifier]/otp/route.ts

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,40 @@ async function getOTP(email: string, chatId: string): Promise<string | null> {
9494
return record?.value ?? null
9595
}
9696

97-
async function updateOTPValue(email: string, chatId: string, value: string): Promise<void> {
97+
/**
98+
* Lua script for atomic OTP attempt increment.
99+
* Returns: "LOCKED" if max attempts reached (key deleted), new encoded value otherwise, nil if key missing.
100+
*/
101+
const ATOMIC_INCREMENT_SCRIPT = `
102+
local val = redis.call('GET', KEYS[1])
103+
if not val then return nil end
104+
local colon = val:find(':([^:]*$)')
105+
local otp, attempts
106+
if colon then
107+
otp = val:sub(1, colon - 1)
108+
attempts = tonumber(val:sub(colon + 1)) or 0
109+
else
110+
otp = val
111+
attempts = 0
112+
end
113+
attempts = attempts + 1
114+
if attempts >= tonumber(ARGV[1]) then
115+
redis.call('DEL', KEYS[1])
116+
return 'LOCKED'
117+
end
118+
local newVal = otp .. ':' .. attempts
119+
redis.call('SET', KEYS[1], newVal, 'KEEPTTL')
120+
return newVal
121+
`
122+
123+
/**
124+
* Atomically increments OTP attempts. Returns 'locked' if max reached, 'incremented' otherwise.
125+
*/
126+
async function incrementOTPAttempts(
127+
email: string,
128+
chatId: string,
129+
currentValue: string
130+
): Promise<'locked' | 'incremented'> {
98131
const identifier = `chat-otp:${chatId}:${email}`
99132
const storageMethod = getStorageMethod()
100133

@@ -104,13 +137,35 @@ async function updateOTPValue(email: string, chatId: string, value: string): Pro
104137
throw new Error('Redis configured but client unavailable')
105138
}
106139
const key = `otp:${email}:${chatId}`
107-
await redis.set(key, value, 'KEEPTTL')
108-
} else {
109-
await db
110-
.update(verification)
111-
.set({ value, updatedAt: new Date() })
112-
.where(eq(verification.identifier, identifier))
140+
const result = await redis.eval(ATOMIC_INCREMENT_SCRIPT, 1, key, MAX_OTP_ATTEMPTS)
141+
return result === 'LOCKED' ? 'locked' : 'incremented'
142+
}
143+
144+
// DB path: optimistic locking — only update if value hasn't changed since we read it
145+
const { otp, attempts } = decodeOTPValue(currentValue)
146+
const newAttempts = attempts + 1
147+
148+
if (newAttempts >= MAX_OTP_ATTEMPTS) {
149+
await db.delete(verification).where(eq(verification.identifier, identifier))
150+
return 'locked'
113151
}
152+
153+
const newValue = encodeOTPValue(otp, newAttempts)
154+
const updated = await db
155+
.update(verification)
156+
.set({ value: newValue, updatedAt: new Date() })
157+
.where(and(eq(verification.identifier, identifier), eq(verification.value, currentValue)))
158+
.returning({ id: verification.id })
159+
160+
// If no rows updated, another request already incremented — re-read to check state
161+
if (updated.length === 0) {
162+
const fresh = await getOTP(email, chatId)
163+
if (!fresh) return 'locked'
164+
const { attempts: freshAttempts } = decodeOTPValue(fresh)
165+
return freshAttempts >= MAX_OTP_ATTEMPTS ? 'locked' : 'incremented'
166+
}
167+
168+
return 'incremented'
114169
}
115170

116171
async function deleteOTP(email: string, chatId: string): Promise<void> {
@@ -257,18 +312,14 @@ export async function PUT(
257312
const { otp: storedOTP, attempts } = decodeOTPValue(storedValue)
258313

259314
if (storedOTP !== otp) {
260-
const newAttempts = attempts + 1
261-
if (newAttempts >= MAX_OTP_ATTEMPTS) {
262-
await deleteOTP(email, deployment.id)
263-
logger.warn(
264-
`[${requestId}] OTP invalidated after ${newAttempts} failed attempts for ${email}`
265-
)
315+
const result = await incrementOTPAttempts(email, deployment.id, storedValue)
316+
if (result === 'locked') {
317+
logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`)
266318
return addCorsHeaders(
267319
createErrorResponse('Too many failed attempts. Please request a new code.', 429),
268320
request
269321
)
270322
}
271-
await updateOTPValue(email, deployment.id, encodeOTPValue(storedOTP, newAttempts))
272323
return addCorsHeaders(createErrorResponse('Invalid verification code', 400), request)
273324
}
274325

0 commit comments

Comments
 (0)