fix(security): use constant-time comparison for Convex API secret
- commit
- c31e89e21ca58dcf6b6bc8989c8dfd737d8640a4
- author
- Kit · · 2026-02-16 11:01 UTC
- parent
- 1a6a567
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
1 file · +22 -1
M src/convex/http.ts +22 -1
| @@ -5,6 +5,23 @@ import type { Id } from './_generated/dataModel'; | |||
| 5 | 5 | ||
| 6 | 6 | const http = httpRouter(); | |
| 7 | 7 | ||
| 8 | + | // Constant-time string comparison to prevent timing attacks | |
| 9 | + | function constantTimeCompare(a: string, b: string): boolean { | |
| 10 | + | if (a.length !== b.length) { | |
| 11 | + | // Still do work to avoid leaking length via timing | |
| 12 | + | let result = a.length ^ b.length; | |
| 13 | + | for (let i = 0; i < a.length; i++) { | |
| 14 | + | result |= a.charCodeAt(i) ^ a.charCodeAt(i); | |
| 15 | + | } | |
| 16 | + | return result === 0; // always false when lengths differ | |
| 17 | + | } | |
| 18 | + | let result = 0; | |
| 19 | + | for (let i = 0; i < a.length; i++) { | |
| 20 | + | result |= a.charCodeAt(i) ^ b.charCodeAt(i); | |
| 21 | + | } | |
| 22 | + | return result === 0; | |
| 23 | + | } | |
| 24 | + | ||
| 8 | 25 | // Validate the secret header for server-to-server calls | |
| 9 | 26 | function validateSecret(request: Request): boolean { | |
| 10 | 27 | const secret = request.headers.get('X-Convex-Secret'); | |
| @@ -15,7 +32,11 @@ function validateSecret(request: Request): boolean { | |||
| 15 | 32 | return false; | |
| 16 | 33 | } | |
| 17 | 34 | ||
| 18 | - | return secret === expectedSecret; | |
| 35 | + | if (!secret) { | |
| 36 | + | return false; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | return constantTimeCompare(secret, expectedSecret); | |
| 19 | 40 | } | |
| 20 | 41 | ||
| 21 | 42 | // Generic error response | |
| @@ -5,6 +5,23 @@ import type { Id } from './_generated/dataModel'; | |||||
| 5 | 5 | ||||
| 6 | const http = httpRouter(); | 6 | const http = httpRouter(); | ||
| 7 | 7 | ||||
| 8 | + | // Constant-time string comparison to prevent timing attacks | |||
| 9 | + | function constantTimeCompare(a: string, b: string): boolean { | |||
| 10 | + | if (a.length !== b.length) { | |||
| 11 | + | // Still do work to avoid leaking length via timing | |||
| 12 | + | let result = a.length ^ b.length; | |||
| 13 | + | for (let i = 0; i < a.length; i++) { | |||
| 14 | + | result |= a.charCodeAt(i) ^ a.charCodeAt(i); | |||
| 15 | + | } | |||
| 16 | + | return result === 0; // always false when lengths differ | |||
| 17 | + | } | |||
| 18 | + | let result = 0; | |||
| 19 | + | for (let i = 0; i < a.length; i++) { | |||
| 20 | + | result |= a.charCodeAt(i) ^ b.charCodeAt(i); | |||
| 21 | + | } | |||
| 22 | + | return result === 0; | |||
| 23 | + | } | |||
| 24 | + | ||||
| 8 | // Validate the secret header for server-to-server calls | 25 | // Validate the secret header for server-to-server calls | ||
| 9 | function validateSecret(request: Request): boolean { | 26 | function validateSecret(request: Request): boolean { | ||
| 10 | const secret = request.headers.get('X-Convex-Secret'); | 27 | const secret = request.headers.get('X-Convex-Secret'); | ||
| @@ -15,7 +32,11 @@ function validateSecret(request: Request): boolean { | |||||
| 15 | return false; | 32 | return false; | ||
| 16 | } | 33 | } | ||
| 17 | 34 | ||||
| 18 | - | return secret === expectedSecret; | 35 | + | if (!secret) { |
| 36 | + | return false; | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | return constantTimeCompare(secret, expectedSecret); | |||
| 19 | } | 40 | } | ||
| 20 | 41 | ||||
| 21 | // Generic error response | 42 | // Generic error response | ||