|
1 | 1 | import crypto from "node:crypto"; |
2 | 2 | import { logger } from "./logger.js"; |
3 | 3 | import { apiGatewaySignatureCacheSize, apiGatewayReplayBlockedTotal } from "./metrics.js"; |
| 4 | +import { connectRedisClient } from "./redis.js"; |
4 | 5 |
|
5 | 6 | const DEFAULT_SIGNATURE_WINDOW_SECONDS = 300; |
6 | 7 | // Minimum HMAC secret length to prevent signing with trivially weak keys |
@@ -262,6 +263,62 @@ function buildCanonicalPayload({ method, path, timestamp, body }) { |
262 | 263 | // state-changing requests, where re-execution has a real side effect. |
263 | 264 | const REPLAY_PROTECTED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); |
264 | 265 |
|
| 266 | +function isReplayProtectedMethod(method) { |
| 267 | + return REPLAY_PROTECTED_METHODS.has(String(method || "GET").toUpperCase()); |
| 268 | +} |
| 269 | + |
| 270 | +function distributedReplayKey(secret, signatureHeader) { |
| 271 | + return `api-gateway:replay:${crypto |
| 272 | + .createHash("sha256") |
| 273 | + .update(`${secret}:${signatureHeader}`, "utf8") |
| 274 | + .digest("hex")}`; |
| 275 | +} |
| 276 | + |
| 277 | +/** |
| 278 | + * Atomically reserve a mutating request signature across API instances. |
| 279 | + * Redis SET NX is used when REDIS_URL is configured; the verifier's local |
| 280 | + * cache remains the fallback for single-instance deployments. |
| 281 | + */ |
| 282 | +export async function reserveApiGatewaySignature({ |
| 283 | + secret, |
| 284 | + signatureHeader, |
| 285 | + method, |
| 286 | + toleranceSeconds, |
| 287 | + redisClient, |
| 288 | +}) { |
| 289 | + if (!isReplayProtectedMethod(method)) return { reserved: true }; |
| 290 | + |
| 291 | + if (!process.env.REDIS_URL && !redisClient) return { reserved: true }; |
| 292 | + |
| 293 | + try { |
| 294 | + const client = redisClient || (await connectRedisClient()); |
| 295 | + if (!client?.isOpen) { |
| 296 | + throw new Error("Redis is unavailable for distributed replay protection"); |
| 297 | + } |
| 298 | + |
| 299 | + const result = await client.set( |
| 300 | + distributedReplayKey(secret, signatureHeader), |
| 301 | + "1", |
| 302 | + { NX: true, EX: Math.max(1, Math.ceil(toleranceSeconds)) }, |
| 303 | + ); |
| 304 | + |
| 305 | + if (result !== "OK") { |
| 306 | + apiGatewayReplayBlockedTotal.inc(); |
| 307 | + logger.warn("Rejected replayed API gateway signature from distributed cache"); |
| 308 | + return { reserved: false, replay: true }; |
| 309 | + } |
| 310 | + |
| 311 | + return { reserved: true }; |
| 312 | + } catch (err) { |
| 313 | + logger.error({ err }, "Distributed API gateway replay protection unavailable"); |
| 314 | + return { |
| 315 | + reserved: false, |
| 316 | + code: "API_GATEWAY_REPLAY_PROTECTION_UNAVAILABLE", |
| 317 | + reason: "API gateway replay protection is temporarily unavailable", |
| 318 | + }; |
| 319 | + } |
| 320 | +} |
| 321 | + |
265 | 322 | function signaturesEqual(a, b) { |
266 | 323 | const aBuf = Buffer.from(a, "hex"); |
267 | 324 | const bBuf = Buffer.from(b, "hex"); |
@@ -381,13 +438,20 @@ export function verifyApiGatewayRequestSignature({ |
381 | 438 | return { valid: false, reason: "Missing or insufficient signature secret" }; |
382 | 439 | } |
383 | 440 |
|
384 | | - const timestamp = Number.parseInt(String(timestampHeader || ""), 10); |
385 | | - if (!Number.isFinite(timestamp)) { |
| 441 | + const timestampValue = String(timestampHeader || "").trim(); |
| 442 | + if (!/^[0-9]+$/.test(timestampValue)) { |
386 | 443 | recordApiGatewaySignatureAttempt(clientIp, false, now); |
387 | 444 | _recordCircuitBreakerFailure(now); |
388 | 445 | logger.warn({ timestampHeader, clientIp }, "Missing or invalid x-api-timestamp header"); |
389 | 446 | return { valid: false, reason: "Missing or invalid x-api-timestamp header" }; |
390 | 447 | } |
| 448 | + const timestamp = Number(timestampValue); |
| 449 | + if (!Number.isSafeInteger(timestamp)) { |
| 450 | + recordApiGatewaySignatureAttempt(clientIp, false, now); |
| 451 | + _recordCircuitBreakerFailure(now); |
| 452 | + logger.warn({ clientIp }, "API gateway timestamp exceeds safe integer range"); |
| 453 | + return { valid: false, reason: "Missing or invalid x-api-timestamp header" }; |
| 454 | + } |
391 | 455 |
|
392 | 456 | const deltaSeconds = Math.abs(Math.floor(now / 1000) - timestamp); |
393 | 457 | if (deltaSeconds > toleranceSeconds) { |
@@ -424,7 +488,7 @@ export function verifyApiGatewayRequestSignature({ |
424 | 488 | // that has already been used within its own tolerance window is a |
425 | 489 | // replay of a captured request, not a legitimate second use. Scoped to |
426 | 490 | // state-changing methods only - see REPLAY_PROTECTED_METHODS. |
427 | | - const isReplayProtected = REPLAY_PROTECTED_METHODS.has(String(method || "GET").toUpperCase()); |
| 491 | + const isReplayProtected = isReplayProtectedMethod(method); |
428 | 492 | if (isReplayProtected && _isReplayedSignature(receivedSignature, now)) { |
429 | 493 | recordApiGatewaySignatureAttempt(clientIp, false, now); |
430 | 494 | _recordCircuitBreakerFailure(now); |
|
0 commit comments