Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/helpers/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,9 @@ export { compose, Secret, safeEqual, MessageBuilder, defineStaticProperty } from
* Verification token utility for creating secure tokens.
*/
export { VerificationToken } from './verification_token.ts'

/**
* Ensures a callback takes at least a minimum amount of time
* to prevent timing attacks.
*/
export { safeTiming } from './safe_timing.ts'
66 changes: 66 additions & 0 deletions src/helpers/safe_timing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* @adonisjs/core
*
* (c) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { setTimeout } from 'node:timers/promises'

/**
* Ensures a callback takes at least a minimum amount of time to execute.
* This helps prevent timing attacks where an attacker measures response times
* to infer sensitive information (e.g., user enumeration via password reset).
*
* @example
* ```ts
* // Password reset: both paths (user exists or not) take same minimum time
* return safeTiming(200, async () => {
* const user = await User.findBy('email', email)
* if (user) await sendResetEmail(user)
* return { message: 'If this email exists, you will receive a reset link.' }
* })
*
* // API token verification: skip the delay on valid token
* return safeTiming(200, async (timing) => {
* const token = await Token.findBy('value', request.header('x-api-key'))
* if (token) {
* timing.returnEarly()
* return token.owner
* }
* throw new UnauthorizedException()
* })
* ```
*/
export async function safeTiming<T>(
minimumMs: number,
callback: (timing: { returnEarly(): void }) => Promise<T>
): Promise<T> {
let shouldReturnEarly = false
const timing = {
returnEarly() {
shouldReturnEarly = true
},
}

const startTime = performance.now()
let result: T
let caughtError: unknown

try {
result = await callback(timing)
} catch (error) {
caughtError = error
}

if (!shouldReturnEarly) {
const remaining = minimumMs - (performance.now() - startTime)
if (remaining > 0) await setTimeout(remaining)
}

if (caughtError) throw caughtError

return result!
}
84 changes: 84 additions & 0 deletions tests/safe_timing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* @adonisjs/core
*
* (c) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { test } from '@japa/runner'
import { safeTiming } from '../src/helpers/safe_timing.ts'

test.group('safeTiming', () => {
test('enforces minimum execution time', async ({ assert }) => {
const start = performance.now()

await safeTiming(200, async () => {
return 'done'
})

const elapsed = performance.now() - start
assert.isAbove(elapsed, 190)
})

test('returns the callback result', async ({ assert }) => {
const result = await safeTiming(50, async () => {
return { message: 'hello' }
})

assert.deepEqual(result, { message: 'hello' })
})

test('does not add delay when callback already exceeds minimum time', async ({ assert }) => {
const start = performance.now()

await safeTiming(50, async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
return 'slow'
})

const elapsed = performance.now() - start
assert.isAbove(elapsed, 95)
assert.isBelow(elapsed, 200)
})

test('returnEarly skips the minimum time wait', async ({ assert }) => {
const start = performance.now()

await safeTiming(500, async (box) => {
box.returnEarly()
return 'fast'
})

const elapsed = performance.now() - start
assert.isBelow(elapsed, 100)
})

test('still waits minimum time when callback throws', async ({ assert }) => {
const start = performance.now()

await assert.rejects(async () => {
await safeTiming(200, async () => {
throw new Error('kaboom')
})
}, 'kaboom')

const elapsed = performance.now() - start
assert.isAbove(elapsed, 190)
})

test('skips wait on error when returnEarly was called', async ({ assert }) => {
const start = performance.now()

await assert.rejects(async () => {
await safeTiming(500, async (box) => {
box.returnEarly()
throw new Error('early error')
})
}, 'early error')

const elapsed = performance.now() - start
assert.isBelow(elapsed, 100)
})
})
Loading