Skip to content

Commit d3503cb

Browse files
fix: prevent timing attacks with === [ES-71] (#819)
1 parent 08b7ec5 commit d3503cb

File tree

4 files changed

+50
-1
lines changed

4 files changed

+50
-1
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, it, expect } from 'vitest'
2+
3+
import { timingSafeUtf8StringEqual } from './timing-safe-string-equal'
4+
5+
describe('timingSafeUtf8StringEqual', () => {
6+
const hex64 = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
7+
8+
it('returns true for identical strings', () => {
9+
expect(timingSafeUtf8StringEqual(hex64, hex64)).toBe(true)
10+
})
11+
12+
it('returns false for same-length unequal strings', () => {
13+
const other = '1123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
14+
expect(timingSafeUtf8StringEqual(hex64, other)).toBe(false)
15+
})
16+
17+
it('returns false when lengths differ without throwing (timingSafeEqual guard)', () => {
18+
expect(timingSafeUtf8StringEqual(hex64, hex64.slice(0, 63))).toBe(false)
19+
expect(timingSafeUtf8StringEqual('', hex64)).toBe(false)
20+
expect(timingSafeUtf8StringEqual(hex64, `${hex64}a`)).toBe(false)
21+
})
22+
23+
it('is case-sensitive like string ===', () => {
24+
expect(timingSafeUtf8StringEqual(hex64, hex64.toUpperCase())).toBe(false)
25+
})
26+
})
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as crypto from 'crypto'
2+
3+
const textEncoder = new TextEncoder()
4+
5+
/** Constant-time compare of UTF-8 bytes; preserves string `===` semantics (e.g. hex case). */
6+
export const timingSafeUtf8StringEqual = (a: string, b: string): boolean => {
7+
const aBuf = textEncoder.encode(a)
8+
const bBuf = textEncoder.encode(b)
9+
if (aBuf.length !== bBuf.length) {
10+
return false
11+
}
12+
return crypto.timingSafeEqual(aBuf, bBuf)
13+
}

src/requests/verify-request.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,15 @@ describe('verifyRequest', () => {
201201

202202
expect(() => verifyRequest(VALID_SECRET, incomingRequest)).toThrow()
203203
})
204+
it('throws when signature length is not 64 (before timing-safe compare)', () => {
205+
const incomingRequest = makeIncomingRequest({}, makeContextHeaders(contextHeaders))
206+
207+
incomingRequest.headers[ContentfulHeader.Signature] = incomingRequest.headers[
208+
ContentfulHeader.Signature
209+
].slice(0, 63)
210+
211+
expect(() => verifyRequest(VALID_SECRET, incomingRequest, 0)).toThrow()
212+
})
204213
it('throws when missing timestamp', () => {
205214
const incomingRequest = makeIncomingRequest({}, makeContextHeaders(contextHeaders))
206215

src/requests/verify-request.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
ContentfulHeader,
1111
} from './typings'
1212
import { normalizeHeaders, pickHeaders } from './utils'
13+
import { timingSafeUtf8StringEqual } from './timing-safe-string-equal'
1314
import { signRequest } from './sign-request'
1415
import { ExpiredRequestException } from './exceptions'
1516

@@ -83,5 +84,5 @@ export const verifyRequest = (
8384
timestamp,
8485
)
8586

86-
return signature === computedSignature
87+
return timingSafeUtf8StringEqual(signature, computedSignature)
8788
}

0 commit comments

Comments
 (0)