From a582bb499d69c5f085503f8a4a3786d05e972c73 Mon Sep 17 00:00:00 2001 From: Bob Hemphill Date: Tue, 24 Mar 2026 10:17:15 -0600 Subject: [PATCH] fix: prevent timing attacks with `===` [ES-71] --- src/requests/timing-safe-string-equal.spec.ts | 26 +++++++++++++++++++ src/requests/timing-safe-string-equal.ts | 13 ++++++++++ src/requests/verify-request.spec.ts | 9 +++++++ src/requests/verify-request.ts | 3 ++- 4 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/requests/timing-safe-string-equal.spec.ts create mode 100644 src/requests/timing-safe-string-equal.ts diff --git a/src/requests/timing-safe-string-equal.spec.ts b/src/requests/timing-safe-string-equal.spec.ts new file mode 100644 index 00000000..000e6acc --- /dev/null +++ b/src/requests/timing-safe-string-equal.spec.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest' + +import { timingSafeUtf8StringEqual } from './timing-safe-string-equal' + +describe('timingSafeUtf8StringEqual', () => { + const hex64 = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + + it('returns true for identical strings', () => { + expect(timingSafeUtf8StringEqual(hex64, hex64)).toBe(true) + }) + + it('returns false for same-length unequal strings', () => { + const other = '1123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + expect(timingSafeUtf8StringEqual(hex64, other)).toBe(false) + }) + + it('returns false when lengths differ without throwing (timingSafeEqual guard)', () => { + expect(timingSafeUtf8StringEqual(hex64, hex64.slice(0, 63))).toBe(false) + expect(timingSafeUtf8StringEqual('', hex64)).toBe(false) + expect(timingSafeUtf8StringEqual(hex64, `${hex64}a`)).toBe(false) + }) + + it('is case-sensitive like string ===', () => { + expect(timingSafeUtf8StringEqual(hex64, hex64.toUpperCase())).toBe(false) + }) +}) diff --git a/src/requests/timing-safe-string-equal.ts b/src/requests/timing-safe-string-equal.ts new file mode 100644 index 00000000..996b54fa --- /dev/null +++ b/src/requests/timing-safe-string-equal.ts @@ -0,0 +1,13 @@ +import * as crypto from 'crypto' + +const textEncoder = new TextEncoder() + +/** Constant-time compare of UTF-8 bytes; preserves string `===` semantics (e.g. hex case). */ +export const timingSafeUtf8StringEqual = (a: string, b: string): boolean => { + const aBuf = textEncoder.encode(a) + const bBuf = textEncoder.encode(b) + if (aBuf.length !== bBuf.length) { + return false + } + return crypto.timingSafeEqual(aBuf, bBuf) +} diff --git a/src/requests/verify-request.spec.ts b/src/requests/verify-request.spec.ts index bdca5f97..b8f067a6 100644 --- a/src/requests/verify-request.spec.ts +++ b/src/requests/verify-request.spec.ts @@ -201,6 +201,15 @@ describe('verifyRequest', () => { expect(() => verifyRequest(VALID_SECRET, incomingRequest)).toThrow() }) + it('throws when signature length is not 64 (before timing-safe compare)', () => { + const incomingRequest = makeIncomingRequest({}, makeContextHeaders(contextHeaders)) + + incomingRequest.headers[ContentfulHeader.Signature] = incomingRequest.headers[ + ContentfulHeader.Signature + ].slice(0, 63) + + expect(() => verifyRequest(VALID_SECRET, incomingRequest, 0)).toThrow() + }) it('throws when missing timestamp', () => { const incomingRequest = makeIncomingRequest({}, makeContextHeaders(contextHeaders)) diff --git a/src/requests/verify-request.ts b/src/requests/verify-request.ts index e5f0e869..1da1ed24 100644 --- a/src/requests/verify-request.ts +++ b/src/requests/verify-request.ts @@ -10,6 +10,7 @@ import { ContentfulHeader, } from './typings' import { normalizeHeaders, pickHeaders } from './utils' +import { timingSafeUtf8StringEqual } from './timing-safe-string-equal' import { signRequest } from './sign-request' import { ExpiredRequestException } from './exceptions' @@ -83,5 +84,5 @@ export const verifyRequest = ( timestamp, ) - return signature === computedSignature + return timingSafeUtf8StringEqual(signature, computedSignature) }