Skip to content

Commit 5793b1f

Browse files
authored
feat: safe timing helper (#5080)
1 parent 7e12fa4 commit 5793b1f

3 files changed

Lines changed: 156 additions & 0 deletions

File tree

src/helpers/main.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,9 @@ export { compose, Secret, safeEqual, MessageBuilder, defineStaticProperty } from
5555
* Verification token utility for creating secure tokens.
5656
*/
5757
export { VerificationToken } from './verification_token.ts'
58+
59+
/**
60+
* Ensures a callback takes at least a minimum amount of time
61+
* to prevent timing attacks.
62+
*/
63+
export { safeTiming } from './safe_timing.ts'

src/helpers/safe_timing.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* @adonisjs/core
3+
*
4+
* (c) AdonisJS
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
import { setTimeout } from 'node:timers/promises'
11+
12+
/**
13+
* Ensures a callback takes at least a minimum amount of time to execute.
14+
* This helps prevent timing attacks where an attacker measures response times
15+
* to infer sensitive information (e.g., user enumeration via password reset).
16+
*
17+
* @example
18+
* ```ts
19+
* // Password reset: both paths (user exists or not) take same minimum time
20+
* return safeTiming(200, async () => {
21+
* const user = await User.findBy('email', email)
22+
* if (user) await sendResetEmail(user)
23+
* return { message: 'If this email exists, you will receive a reset link.' }
24+
* })
25+
*
26+
* // API token verification: skip the delay on valid token
27+
* return safeTiming(200, async (timing) => {
28+
* const token = await Token.findBy('value', request.header('x-api-key'))
29+
* if (token) {
30+
* timing.returnEarly()
31+
* return token.owner
32+
* }
33+
* throw new UnauthorizedException()
34+
* })
35+
* ```
36+
*/
37+
export async function safeTiming<T>(
38+
minimumMs: number,
39+
callback: (timing: { returnEarly(): void }) => Promise<T>
40+
): Promise<T> {
41+
let shouldReturnEarly = false
42+
const timing = {
43+
returnEarly() {
44+
shouldReturnEarly = true
45+
},
46+
}
47+
48+
const startTime = performance.now()
49+
let result: T
50+
let caughtError: unknown
51+
52+
try {
53+
result = await callback(timing)
54+
} catch (error) {
55+
caughtError = error
56+
}
57+
58+
if (!shouldReturnEarly) {
59+
const remaining = minimumMs - (performance.now() - startTime)
60+
if (remaining > 0) await setTimeout(remaining)
61+
}
62+
63+
if (caughtError) throw caughtError
64+
65+
return result!
66+
}

tests/safe_timing.spec.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* @adonisjs/core
3+
*
4+
* (c) AdonisJS
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
import { test } from '@japa/runner'
11+
import { safeTiming } from '../src/helpers/safe_timing.ts'
12+
13+
test.group('safeTiming', () => {
14+
test('enforces minimum execution time', async ({ assert }) => {
15+
const start = performance.now()
16+
17+
await safeTiming(200, async () => {
18+
return 'done'
19+
})
20+
21+
const elapsed = performance.now() - start
22+
assert.isAbove(elapsed, 190)
23+
})
24+
25+
test('returns the callback result', async ({ assert }) => {
26+
const result = await safeTiming(50, async () => {
27+
return { message: 'hello' }
28+
})
29+
30+
assert.deepEqual(result, { message: 'hello' })
31+
})
32+
33+
test('does not add delay when callback already exceeds minimum time', async ({ assert }) => {
34+
const start = performance.now()
35+
36+
await safeTiming(50, async () => {
37+
await new Promise((resolve) => setTimeout(resolve, 100))
38+
return 'slow'
39+
})
40+
41+
const elapsed = performance.now() - start
42+
assert.isAbove(elapsed, 95)
43+
assert.isBelow(elapsed, 200)
44+
})
45+
46+
test('returnEarly skips the minimum time wait', async ({ assert }) => {
47+
const start = performance.now()
48+
49+
await safeTiming(500, async (box) => {
50+
box.returnEarly()
51+
return 'fast'
52+
})
53+
54+
const elapsed = performance.now() - start
55+
assert.isBelow(elapsed, 100)
56+
})
57+
58+
test('still waits minimum time when callback throws', async ({ assert }) => {
59+
const start = performance.now()
60+
61+
await assert.rejects(async () => {
62+
await safeTiming(200, async () => {
63+
throw new Error('kaboom')
64+
})
65+
}, 'kaboom')
66+
67+
const elapsed = performance.now() - start
68+
assert.isAbove(elapsed, 190)
69+
})
70+
71+
test('skips wait on error when returnEarly was called', async ({ assert }) => {
72+
const start = performance.now()
73+
74+
await assert.rejects(async () => {
75+
await safeTiming(500, async (box) => {
76+
box.returnEarly()
77+
throw new Error('early error')
78+
})
79+
}, 'early error')
80+
81+
const elapsed = performance.now() - start
82+
assert.isBelow(elapsed, 100)
83+
})
84+
})

0 commit comments

Comments
 (0)