From 0fe00aafb8f98b053e07c1cbbc49612ea154d07e Mon Sep 17 00:00:00 2001 From: sublime247 Date: Wed, 29 Apr 2026 01:16:38 +0100 Subject: [PATCH] feat(webhooks): add signature verification, timestamp validation, and replay attack prevention Closes #403 - Create WebhookSecurityService with HMAC-SHA256 signature verification, timestamp freshness validation (5-min window), and in-memory replay attack prevention with TTL-based eviction - Rewrite StripeWebhookGuard to use the full verification pipeline - Apply StripeWebhookGuard to the Stripe webhook endpoint - Add 29 unit tests covering all security scenarios - Register WebhookSecurityService in PaymentsModule - Fix IApiResponse -> ApiResponse in webhook controllers --- src/payments/payments.module.ts | 10 +- src/payments/webhooks/stripe-webhook.guard.ts | 63 +++- .../webhooks/webhook-management.controller.ts | 12 +- .../webhooks/webhook-security.service.spec.ts | 287 ++++++++++++++++ .../webhooks/webhook-security.service.ts | 318 ++++++++++++++++++ src/payments/webhooks/webhook.controller.ts | 9 +- 6 files changed, 680 insertions(+), 19 deletions(-) create mode 100644 src/payments/webhooks/webhook-security.service.spec.ts create mode 100644 src/payments/webhooks/webhook-security.service.ts diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index 6f83159c..fe4b40d1 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -10,6 +10,7 @@ import { WebhookManagementController } from './webhooks/webhook-management.contr import { WebhookService } from './webhooks/webhook.service'; import { WebhookQueueService } from './webhooks/webhook-queue.service'; import { WebhookRetryProcessor } from './webhooks/webhook-retry.processor'; +import { WebhookSecurityService } from './webhooks/webhook-security.service'; import { SubscriptionsService } from './subscriptions/subscriptions.service'; import { SubscriptionJobProcessor } from './subscriptions/subscription-job.processor'; import { StripeService } from './providers/stripe.service'; @@ -45,6 +46,7 @@ import { IdempotencyService } from '../common/services/idempotency.service'; WebhookService, WebhookQueueService, WebhookRetryProcessor, + WebhookSecurityService, SubscriptionsService, SubscriptionJobProcessor, StripeService, @@ -53,6 +55,12 @@ import { IdempotencyService } from '../common/services/idempotency.service'; TransactionHelperService, IdempotencyService, ], - exports: [PaymentsService, ProviderFactoryService, WebhookQueueService, IdempotencyService], + exports: [ + PaymentsService, + ProviderFactoryService, + WebhookQueueService, + WebhookSecurityService, + IdempotencyService, + ], }) export class PaymentsModule {} diff --git a/src/payments/webhooks/stripe-webhook.guard.ts b/src/payments/webhooks/stripe-webhook.guard.ts index e5ac3b5f..f3abdf3a 100644 --- a/src/payments/webhooks/stripe-webhook.guard.ts +++ b/src/payments/webhooks/stripe-webhook.guard.ts @@ -1,22 +1,67 @@ -import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { + Injectable, + CanActivate, + ExecutionContext, + Logger, + UnauthorizedException, +} from '@nestjs/common'; import { Request } from 'express'; +import { WebhookSecurityService } from './webhook-security.service'; +/** + * Guard that validates incoming Stripe webhook requests. + * + * Performs: + * 1. Signature verification using HMAC-SHA256 + * 2. Timestamp freshness validation (±5 minutes) + * 3. Replay attack prevention (duplicate event ID detection) + * + * Must be applied to Stripe webhook endpoints. + */ @Injectable() export class StripeWebhookGuard implements CanActivate { + private readonly logger = new Logger(StripeWebhookGuard.name); + + constructor(private readonly webhookSecurityService: WebhookSecurityService) {} + canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); - - // Verify the webhook signature here - // This is a simplified implementation - in production, you'd verify the signature - const signature = request.headers['stripe-signature']; + const signature = request.headers['stripe-signature'] as string; if (!signature) { - return false; + this.logger.warn('Webhook request missing stripe-signature header'); + throw new UnauthorizedException('Missing stripe-signature header'); + } + + // Get the raw body for signature verification + const rawBody = (request as any).rawBody as Buffer | undefined; + if (!rawBody) { + this.logger.warn('Webhook request missing raw body'); + throw new UnauthorizedException( + 'Missing raw body – ensure raw body parsing is enabled for this route', + ); } - // In a real implementation, you would verify the signature using Stripe's library - // const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); - // stripe.webhooks.constructEvent(request.body, signature, process.env.STRIPE_WEBHOOK_SECRET); + // Parse event ID from the body for replay prevention. + // We attempt to parse the JSON to extract the event ID, + // but we verify the signature against the raw (unparsed) body. + let eventId: string | undefined; + try { + const parsed = JSON.parse(rawBody.toString('utf8')); + eventId = parsed?.id; + } catch { + // If we can't parse, signature verification will still work, + // but replay prevention will be skipped. + this.logger.warn('Could not parse webhook body to extract event ID'); + } + + // Run the full verification pipeline + const result = this.webhookSecurityService.verifyStripeWebhook(rawBody, signature, eventId); + + if (!result.valid) { + this.logger.warn(`Stripe webhook rejected: ${result.reason}`); + throw new UnauthorizedException(result.reason); + } return true; } diff --git a/src/payments/webhooks/webhook-management.controller.ts b/src/payments/webhooks/webhook-management.controller.ts index 95a901f2..e155b89e 100644 --- a/src/payments/webhooks/webhook-management.controller.ts +++ b/src/payments/webhooks/webhook-management.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, HttpCode, HttpStatus, Param, Post, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, IApiResponse, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { WebhookQueueService } from './webhook-queue.service'; import { WebhookRetry } from './entities/webhook-retry.entity'; @@ -10,14 +10,14 @@ export class WebhookManagementController { @Get('status/:id') @ApiOperation({ summary: 'Get webhook retry status' }) - @IApiResponse({ status: 200, description: 'Webhook status retrieved' }) + @ApiResponse({ status: 200, description: 'Webhook status retrieved' }) async getWebhookStatus(@Param('id') id: string): Promise { return this.webhookQueueService.getWebhookStatus(id); } @Get('dead-letter') @ApiOperation({ summary: 'Get dead letter webhooks' }) - @IApiResponse({ status: 200, description: 'Dead letter webhooks retrieved' }) + @ApiResponse({ status: 200, description: 'Dead letter webhooks retrieved' }) @ApiQuery({ name: 'limit', required: false, type: Number, example: 100 }) async getDeadLetterWebhooks(@Query('limit') limit?: number): Promise { return this.webhookQueueService.getDeadLetterWebhooks(limit || 100); @@ -25,7 +25,7 @@ export class WebhookManagementController { @Get('pending') @ApiOperation({ summary: 'Get pending webhooks' }) - @IApiResponse({ status: 200, description: 'Pending webhooks retrieved' }) + @ApiResponse({ status: 200, description: 'Pending webhooks retrieved' }) @ApiQuery({ name: 'limit', required: false, type: Number, example: 100 }) async getPendingWebhooks(@Query('limit') limit?: number): Promise { return this.webhookQueueService.getPendingWebhooks(limit || 100); @@ -33,7 +33,7 @@ export class WebhookManagementController { @Get('processing') @ApiOperation({ summary: 'Get processing webhooks' }) - @IApiResponse({ status: 200, description: 'Processing webhooks retrieved' }) + @ApiResponse({ status: 200, description: 'Processing webhooks retrieved' }) async getProcessingWebhooks(): Promise { return this.webhookQueueService.getProcessingWebhooks(); } @@ -41,7 +41,7 @@ export class WebhookManagementController { @Post('requeue/:id') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Requeue a dead letter webhook' }) - @IApiResponse({ status: 200, description: 'Webhook requeued' }) + @ApiResponse({ status: 200, description: 'Webhook requeued' }) async requeueDeadLetterWebhook(@Param('id') id: string): Promise<{ success: boolean }> { await this.webhookQueueService.requeueDeadLetterWebhook(id); return { success: true }; diff --git a/src/payments/webhooks/webhook-security.service.spec.ts b/src/payments/webhooks/webhook-security.service.spec.ts new file mode 100644 index 00000000..d174c92b --- /dev/null +++ b/src/payments/webhooks/webhook-security.service.spec.ts @@ -0,0 +1,287 @@ +import * as crypto from 'crypto'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { WebhookSecurityService, WEBHOOK_SECURITY } from './webhook-security.service'; + +describe('WebhookSecurityService', () => { + let service: WebhookSecurityService; + const TEST_SECRET = 'whsec_test_secret_key_for_unit_tests'; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WebhookSecurityService, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + if (key === 'STRIPE_WEBHOOK_SECRET') return TEST_SECRET; + return undefined; + }), + }, + }, + ], + }).compile(); + + service = module.get(WebhookSecurityService); + }); + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + function createStripeSignature(payload: string, secret: string, timestamp?: number): string { + const ts = timestamp ?? Math.floor(Date.now() / 1_000); + const signedPayload = `${ts}.${payload}`; + const signature = crypto + .createHmac('sha256', secret) + .update(signedPayload, 'utf8') + .digest('hex'); + return `t=${ts},v1=${signature}`; + } + + // --------------------------------------------------------------------------- + // Stripe signature verification + // --------------------------------------------------------------------------- + + describe('verifyStripeSignature', () => { + const payload = JSON.stringify({ id: 'evt_test_123', type: 'payment_intent.succeeded' }); + + it('should accept a valid signature', () => { + const header = createStripeSignature(payload, TEST_SECRET); + const result = service.verifyStripeSignature(Buffer.from(payload), header); + expect(result.valid).toBe(true); + }); + + it('should accept a valid signature from a string payload', () => { + const header = createStripeSignature(payload, TEST_SECRET); + const result = service.verifyStripeSignature(payload, header); + expect(result.valid).toBe(true); + }); + + it('should reject a missing signature header', () => { + const result = service.verifyStripeSignature(Buffer.from(payload), ''); + expect(result.valid).toBe(false); + expect(result.reason).toContain('Missing'); + }); + + it('should reject an invalid signature', () => { + const header = createStripeSignature(payload, 'wrong_secret'); + const result = service.verifyStripeSignature(Buffer.from(payload), header); + expect(result.valid).toBe(false); + expect(result.reason).toContain('Signature verification failed'); + }); + + it('should reject a tampered payload', () => { + const header = createStripeSignature(payload, TEST_SECRET); + const tampered = JSON.stringify({ id: 'evt_tampered', type: 'charge.refunded' }); + const result = service.verifyStripeSignature(Buffer.from(tampered), header); + expect(result.valid).toBe(false); + expect(result.reason).toContain('Signature verification failed'); + }); + + it('should reject a timestamp that is too old', () => { + const oldTimestamp = Math.floor(Date.now() / 1_000) - 600; // 10 minutes ago + const header = createStripeSignature(payload, TEST_SECRET, oldTimestamp); + const result = service.verifyStripeSignature(Buffer.from(payload), header); + expect(result.valid).toBe(false); + expect(result.reason).toContain('too old'); + }); + + it('should reject a timestamp from the future', () => { + const futureTimestamp = Math.floor(Date.now() / 1_000) + 600; // 10 minutes in the future + const header = createStripeSignature(payload, TEST_SECRET, futureTimestamp); + const result = service.verifyStripeSignature(Buffer.from(payload), header); + expect(result.valid).toBe(false); + expect(result.reason).toContain('future'); + }); + + it('should accept a timestamp slightly in the future (clock skew tolerance)', () => { + const slightFuture = Math.floor(Date.now() / 1_000) + 10; // 10 seconds ahead + const header = createStripeSignature(payload, TEST_SECRET, slightFuture); + const result = service.verifyStripeSignature(Buffer.from(payload), header); + expect(result.valid).toBe(true); + }); + + it('should reject a header with missing timestamp', () => { + const signature = crypto + .createHmac('sha256', TEST_SECRET) + .update(`123.${payload}`, 'utf8') + .digest('hex'); + const header = `v1=${signature}`; // no t= field + const result = service.verifyStripeSignature(Buffer.from(payload), header); + expect(result.valid).toBe(false); + expect(result.reason).toContain('timestamp'); + }); + + it('should reject a header with no v1 signature', () => { + const ts = Math.floor(Date.now() / 1_000); + const header = `t=${ts}`; + const result = service.verifyStripeSignature(Buffer.from(payload), header); + expect(result.valid).toBe(false); + expect(result.reason).toContain('No v1 signature'); + }); + }); + + // --------------------------------------------------------------------------- + // Generic HMAC verification + // --------------------------------------------------------------------------- + + describe('verifyHmacSignature', () => { + const payload = '{"event":"test"}'; + const secret = 'my_webhook_secret'; + + function computeHmac(data: string, key: string): string { + return crypto.createHmac('sha256', key).update(data, 'utf8').digest('hex'); + } + + it('should accept a valid HMAC signature', () => { + const sig = computeHmac(payload, secret); + const result = service.verifyHmacSignature(payload, sig, secret); + expect(result.valid).toBe(true); + }); + + it('should reject an invalid HMAC signature', () => { + const result = service.verifyHmacSignature(payload, 'invalid_sig', secret); + expect(result.valid).toBe(false); + }); + + it('should reject a missing signature', () => { + const result = service.verifyHmacSignature(payload, '', secret); + expect(result.valid).toBe(false); + expect(result.reason).toContain('Missing'); + }); + + it('should reject a missing secret', () => { + const sig = computeHmac(payload, secret); + const result = service.verifyHmacSignature(payload, sig, ''); + expect(result.valid).toBe(false); + expect(result.reason).toContain('not configured'); + }); + }); + + // --------------------------------------------------------------------------- + // Timestamp validation + // --------------------------------------------------------------------------- + + describe('validateTimestamp', () => { + it('should accept a current timestamp', () => { + const now = Math.floor(Date.now() / 1_000); + const result = service.validateTimestamp(now); + expect(result.valid).toBe(true); + }); + + it('should accept a timestamp within the valid window', () => { + const threeMinutesAgo = Math.floor(Date.now() / 1_000) - 180; + const result = service.validateTimestamp(threeMinutesAgo); + expect(result.valid).toBe(true); + }); + + it('should reject a timestamp older than the max age', () => { + const tenMinutesAgo = Math.floor(Date.now() / 1_000) - 600; + const result = service.validateTimestamp(tenMinutesAgo); + expect(result.valid).toBe(false); + expect(result.reason).toContain('too old'); + }); + + it('should reject a timestamp far in the future', () => { + const tenMinutesFuture = Math.floor(Date.now() / 1_000) + 600; + const result = service.validateTimestamp(tenMinutesFuture); + expect(result.valid).toBe(false); + expect(result.reason).toContain('future'); + }); + + it('should reject NaN timestamp', () => { + const result = service.validateTimestamp(NaN); + expect(result.valid).toBe(false); + expect(result.reason).toContain('Invalid'); + }); + + it('should reject zero timestamp', () => { + const result = service.validateTimestamp(0); + expect(result.valid).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // Replay attack prevention + // --------------------------------------------------------------------------- + + describe('isReplayAttack', () => { + it('should return false for a new event ID', () => { + expect(service.isReplayAttack('evt_unique_001')).toBe(false); + }); + + it('should return true for a duplicate event ID', () => { + service.isReplayAttack('evt_duplicate_001'); + expect(service.isReplayAttack('evt_duplicate_001')).toBe(true); + }); + + it('should handle multiple unique event IDs', () => { + expect(service.isReplayAttack('evt_a')).toBe(false); + expect(service.isReplayAttack('evt_b')).toBe(false); + expect(service.isReplayAttack('evt_c')).toBe(false); + // Replays + expect(service.isReplayAttack('evt_a')).toBe(true); + expect(service.isReplayAttack('evt_b')).toBe(true); + }); + + it('should return false for empty event ID (skip replay check)', () => { + expect(service.isReplayAttack('')).toBe(false); + }); + + it('should allow re-processing after clearProcessedEvent', () => { + service.isReplayAttack('evt_clear_test'); + expect(service.isReplayAttack('evt_clear_test')).toBe(true); + + service.clearProcessedEvent('evt_clear_test'); + expect(service.isReplayAttack('evt_clear_test')).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // Full Stripe verification pipeline + // --------------------------------------------------------------------------- + + describe('verifyStripeWebhook', () => { + const payload = JSON.stringify({ id: 'evt_pipeline_001', type: 'charge.succeeded' }); + + it('should accept a fully valid webhook', () => { + const header = createStripeSignature(payload, TEST_SECRET); + const result = service.verifyStripeWebhook(Buffer.from(payload), header, 'evt_pipeline_001'); + expect(result.valid).toBe(true); + }); + + it('should reject an invalid signature in the pipeline', () => { + const header = createStripeSignature(payload, 'wrong'); + const result = service.verifyStripeWebhook(Buffer.from(payload), header, 'evt_pipeline_002'); + expect(result.valid).toBe(false); + expect(result.reason).toContain('Signature'); + }); + + it('should reject a replay in the pipeline', () => { + const header = createStripeSignature(payload, TEST_SECRET); + // First call succeeds + const first = service.verifyStripeWebhook(Buffer.from(payload), header, 'evt_pipeline_003'); + expect(first.valid).toBe(true); + + // Second call with same event ID is a replay + const freshHeader = createStripeSignature(payload, TEST_SECRET); + const second = service.verifyStripeWebhook( + Buffer.from(payload), + freshHeader, + 'evt_pipeline_003', + ); + expect(second.valid).toBe(false); + expect(second.reason).toContain('Duplicate'); + }); + + it('should reject an expired timestamp in the pipeline', () => { + const oldTs = Math.floor(Date.now() / 1_000) - 600; + const header = createStripeSignature(payload, TEST_SECRET, oldTs); + const result = service.verifyStripeWebhook(Buffer.from(payload), header, 'evt_pipeline_004'); + expect(result.valid).toBe(false); + expect(result.reason).toContain('too old'); + }); + }); +}); diff --git a/src/payments/webhooks/webhook-security.service.ts b/src/payments/webhooks/webhook-security.service.ts new file mode 100644 index 00000000..d17563c6 --- /dev/null +++ b/src/payments/webhooks/webhook-security.service.ts @@ -0,0 +1,318 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as crypto from 'crypto'; +import { TIME } from '../../common/constants/time.constants'; + +/** + * Constants for webhook security configuration + */ +export const WEBHOOK_SECURITY = { + /** Maximum age (in ms) for a webhook timestamp to be considered valid */ + MAX_TIMESTAMP_AGE_MS: TIME.FIVE_MINUTES_SECONDS * 1_000, // 5 minutes + /** Hash algorithm used for HMAC signature computation */ + HASH_ALGORITHM: 'sha256', + /** Prefix for Stripe signature scheme */ + STRIPE_SIGNATURE_SCHEME: 'v1', + /** Maximum number of processed event IDs to keep in memory for replay prevention */ + MAX_PROCESSED_EVENTS_SIZE: 10_000, + /** TTL for processed event IDs in ms (24 hours) */ + PROCESSED_EVENTS_TTL_MS: TIME.ONE_HOUR_MS * 24, +} as const; + +export interface IWebhookVerificationResult { + valid: boolean; + reason?: string; +} + +@Injectable() +export class WebhookSecurityService { + private readonly logger = new Logger(WebhookSecurityService.name); + + /** + * In-memory store for processed webhook event IDs. + * Key: eventId, Value: timestamp when the event was processed. + * + * In production at scale, this should be replaced with Redis + * to share state across multiple instances. + */ + private readonly processedEvents = new Map(); + + private readonly stripeWebhookSecret: string; + + constructor(private readonly configService: ConfigService) { + this.stripeWebhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET') || ''; + } + + // --------------------------------------------------------------------------- + // Stripe-specific verification + // --------------------------------------------------------------------------- + + /** + * Verify a Stripe webhook signature using the `stripe-signature` header. + * + * Stripe signs webhooks with a scheme: + * `t=,v1=` + * + * We parse the header, validate the timestamp freshness, compute the + * expected HMAC-SHA256 signature, and compare using a timing-safe check. + */ + verifyStripeSignature( + payload: Buffer | string, + signatureHeader: string, + ): IWebhookVerificationResult { + if (!signatureHeader) { + return { valid: false, reason: 'Missing stripe-signature header' }; + } + + if (!this.stripeWebhookSecret) { + this.logger.error( + 'STRIPE_WEBHOOK_SECRET is not configured – cannot verify webhook signatures', + ); + return { valid: false, reason: 'Webhook secret not configured' }; + } + + // ---- Parse the header ---- + const elements = signatureHeader.split(','); + const signatureMap: Record = {}; + + for (const element of elements) { + const [key, value] = element.split('=', 2); + if (!key || !value) continue; + if (!signatureMap[key]) { + signatureMap[key] = []; + } + signatureMap[key].push(value); + } + + const timestampStr = signatureMap['t']?.[0]; + const signatures = signatureMap[WEBHOOK_SECURITY.STRIPE_SIGNATURE_SCHEME] || []; + + if (!timestampStr) { + return { valid: false, reason: 'Missing timestamp in stripe-signature header' }; + } + + if (signatures.length === 0) { + return { + valid: false, + reason: `No ${WEBHOOK_SECURITY.STRIPE_SIGNATURE_SCHEME} signature found in header`, + }; + } + + // ---- Timestamp validation ---- + const timestamp = parseInt(timestampStr, 10); + const timestampResult = this.validateTimestamp(timestamp); + if (!timestampResult.valid) { + return timestampResult; + } + + // ---- Compute expected signature ---- + const payloadString = typeof payload === 'string' ? payload : payload.toString('utf8'); + const signedPayload = `${timestampStr}.${payloadString}`; + + const expectedSignature = crypto + .createHmac(WEBHOOK_SECURITY.HASH_ALGORITHM, this.stripeWebhookSecret) + .update(signedPayload, 'utf8') + .digest('hex'); + + // ---- Timing-safe comparison ---- + const isValid = signatures.some((sig) => this.timingSafeEqual(sig, expectedSignature)); + + if (!isValid) { + this.logger.warn('Stripe webhook signature mismatch'); + return { valid: false, reason: 'Signature verification failed' }; + } + + return { valid: true }; + } + + // --------------------------------------------------------------------------- + // Generic HMAC verification (for custom / non-Stripe webhooks) + // --------------------------------------------------------------------------- + + /** + * Verify a generic HMAC-SHA256 webhook signature. + * + * @param payload Raw request body + * @param signature The signature sent in a header by the caller + * @param secret The shared secret for HMAC computation + */ + verifyHmacSignature( + payload: Buffer | string, + signature: string, + secret: string, + ): IWebhookVerificationResult { + if (!signature) { + return { valid: false, reason: 'Missing webhook signature' }; + } + + if (!secret) { + return { valid: false, reason: 'Webhook secret not configured' }; + } + + const payloadString = typeof payload === 'string' ? payload : payload.toString('utf8'); + + const expectedSignature = crypto + .createHmac(WEBHOOK_SECURITY.HASH_ALGORITHM, secret) + .update(payloadString, 'utf8') + .digest('hex'); + + if (!this.timingSafeEqual(signature, expectedSignature)) { + this.logger.warn('HMAC webhook signature mismatch'); + return { valid: false, reason: 'Signature verification failed' }; + } + + return { valid: true }; + } + + // --------------------------------------------------------------------------- + // Timestamp validation + // --------------------------------------------------------------------------- + + /** + * Validate that a webhook timestamp is within the acceptable window. + * + * @param timestamp Unix timestamp in **seconds** (Stripe convention) + */ + validateTimestamp(timestamp: number): IWebhookVerificationResult { + if (!timestamp || isNaN(timestamp)) { + return { valid: false, reason: 'Invalid or missing timestamp' }; + } + + const timestampMs = timestamp * 1_000; + const now = Date.now(); + const age = now - timestampMs; + + // Reject timestamps from the future (with a small tolerance for clock skew) + if (age < -TIME.THIRTY_SECONDS_MS) { + this.logger.warn( + `Webhook timestamp is in the future: ${new Date(timestampMs).toISOString()}`, + ); + return { valid: false, reason: 'Webhook timestamp is in the future' }; + } + + // Reject timestamps that are too old + if (age > WEBHOOK_SECURITY.MAX_TIMESTAMP_AGE_MS) { + this.logger.warn( + `Webhook timestamp too old: ${age}ms (max ${WEBHOOK_SECURITY.MAX_TIMESTAMP_AGE_MS}ms)`, + ); + return { + valid: false, + reason: `Webhook timestamp is too old (${Math.round(age / 1_000)}s > ${WEBHOOK_SECURITY.MAX_TIMESTAMP_AGE_MS / 1_000}s)`, + }; + } + + return { valid: true }; + } + + // --------------------------------------------------------------------------- + // Replay attack prevention + // --------------------------------------------------------------------------- + + /** + * Check if a webhook event ID has already been processed (replay attack). + * If not, mark it as processed. + * + * @returns `true` if the event is a replay (already seen), `false` otherwise + */ + isReplayAttack(eventId: string): boolean { + if (!eventId) { + this.logger.warn('Empty event ID – cannot perform replay check'); + return false; // Let other guards handle missing IDs + } + + // Periodically evict stale entries + this.evictStaleEntries(); + + if (this.processedEvents.has(eventId)) { + this.logger.warn(`Replay attack detected: event ${eventId} already processed`); + return true; + } + + this.processedEvents.set(eventId, Date.now()); + return false; + } + + /** + * Clear a specific event ID from the replay cache. + * Useful when a webhook fails and should be retried. + */ + clearProcessedEvent(eventId: string): void { + this.processedEvents.delete(eventId); + } + + // --------------------------------------------------------------------------- + // Full verification pipeline + // --------------------------------------------------------------------------- + + /** + * Run the complete Stripe webhook verification pipeline: + * 1. Signature verification + * 2. Timestamp validation (embedded in signature check) + * 3. Replay attack prevention + */ + verifyStripeWebhook( + payload: Buffer | string, + signatureHeader: string, + eventId: string, + ): IWebhookVerificationResult { + // Step 1 & 2: Verify signature (includes timestamp validation) + const signatureResult = this.verifyStripeSignature(payload, signatureHeader); + if (!signatureResult.valid) { + return signatureResult; + } + + // Step 3: Replay attack prevention + if (this.isReplayAttack(eventId)) { + return { valid: false, reason: `Duplicate event: ${eventId}` }; + } + + return { valid: true }; + } + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + /** + * Timing-safe string comparison to prevent timing attacks. + */ + private timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) { + // Still do a comparison to keep constant-ish time, but return false + const bufA = Buffer.from(a, 'utf8'); + const bufB = Buffer.from(a, 'utf8'); // intentionally same length + crypto.timingSafeEqual(bufA, bufB); + return false; + } + + const bufA = Buffer.from(a, 'utf8'); + const bufB = Buffer.from(b, 'utf8'); + return crypto.timingSafeEqual(bufA, bufB); + } + + /** + * Evict stale entries from the in-memory replay cache to prevent + * unbounded memory growth. + */ + private evictStaleEntries(): void { + if (this.processedEvents.size <= WEBHOOK_SECURITY.MAX_PROCESSED_EVENTS_SIZE) { + return; + } + + const now = Date.now(); + for (const [eventId, timestamp] of this.processedEvents.entries()) { + if (now - timestamp > WEBHOOK_SECURITY.PROCESSED_EVENTS_TTL_MS) { + this.processedEvents.delete(eventId); + } + } + + // If still too large after TTL eviction, drop oldest entries + if (this.processedEvents.size > WEBHOOK_SECURITY.MAX_PROCESSED_EVENTS_SIZE) { + const entries = Array.from(this.processedEvents.entries()).sort((a, b) => a[1] - b[1]); + const toRemove = entries.length - WEBHOOK_SECURITY.MAX_PROCESSED_EVENTS_SIZE; + for (let i = 0; i < toRemove; i++) { + this.processedEvents.delete(entries[i][0]); + } + } + } +} diff --git a/src/payments/webhooks/webhook.controller.ts b/src/payments/webhooks/webhook.controller.ts index 3ad4dee2..d64b5278 100644 --- a/src/payments/webhooks/webhook.controller.ts +++ b/src/payments/webhooks/webhook.controller.ts @@ -7,11 +7,13 @@ import { HttpStatus, Req, RawBodyRequest, + UseGuards, } from '@nestjs/common'; import { Request } from 'express'; -import { ApiTags, ApiOperation, IApiResponse } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { SkipThrottle } from '@nestjs/throttler'; import { WebhookService } from './webhook.service'; +import { StripeWebhookGuard } from './stripe-webhook.guard'; @SkipThrottle() @ApiTags('webhooks') @@ -21,8 +23,9 @@ export class WebhookController { @Post('stripe') @HttpCode(HttpStatus.OK) + @UseGuards(StripeWebhookGuard) @ApiOperation({ summary: 'Handle Stripe webhook events' }) - @IApiResponse({ status: 200, description: 'Webhook processed' }) + @ApiResponse({ status: 200, description: 'Webhook processed' }) async handleStripeWebhook( @Headers('stripe-signature') signature: string, @Req() req: RawBodyRequest, @@ -33,7 +36,7 @@ export class WebhookController { @Post('paypal') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Handle PayPal webhook events' }) - @IApiResponse({ status: 200, description: 'Webhook processed' }) + @ApiResponse({ status: 200, description: 'Webhook processed' }) async handlePayPalWebhook( @Headers('paypal-transmission-id') transmissionId: string, @Headers('paypal-transmission-time') transmissionTime: string,