diff --git a/.env.example b/.env.example index 8ba3be5..3144671 100644 --- a/.env.example +++ b/.env.example @@ -229,3 +229,17 @@ EVOAI_CRM_CIRCUIT_RECOVERY_MS=60000 EVO_AUTH_API_TOKEN= EVO_AUTH_TIMEOUT_MS=10000 EVO_AUTH_CACHE_TTL_MS=30000 + +# Webhook signature validation — RUN_MODE=event-process (story 3.4 / EVO-1210) +# Per-provider secrets for src/runners/event-process/validators/. When a secret +# is unset that provider fails-closed (drops every event) and logs an +# "unconfigured-secrets" warning at boot. SendGrid is opt-in: with no key it +# passes through unverified. SES needs no secret (AWS SNS certificate-based). +EVOLUTION_API_WEBHOOK_TOKEN= +SPARKPOST_WEBHOOK_USER= +SPARKPOST_WEBHOOK_PASSWORD= +SENDGRID_WEBHOOK_VERIFICATION_KEY= +MAILERSEND_WEBHOOK_SECRET= +RESEND_WEBHOOK_SECRET= +MANDRILL_WEBHOOK_SECRET= +MANDRILL_WEBHOOK_URL= diff --git a/package-lock.json b/package-lock.json index 7483d0c..a4d6133 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,8 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "snappy": "^7.3.2", + "sns-validator": "^0.3.5", + "svix": "^1.95.2", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.21", "typeorm-extension": "^3.7.0", @@ -4346,6 +4348,12 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@swc/cli": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@swc/cli/-/cli-0.6.0.tgz", @@ -8359,6 +8367,12 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", @@ -12933,6 +12947,12 @@ "integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg==", "license": "MIT" }, + "node_modules/sns-validator": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/sns-validator/-/sns-validator-0.3.5.tgz", + "integrity": "sha512-lapYNmezLsltnt2fcQyhmYnC41mYZ7EjU7f2oR/hrhpbD/LemryclRpApwxO84gOyDIQ7MWe/h4HvkdunFzSKg==", + "license": "Apache-2.0" + }, "node_modules/sort-keys": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", @@ -13079,6 +13099,16 @@ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -13348,6 +13378,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.95.2", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.95.2.tgz", + "integrity": "sha512-i2gDtqABKQritWlFOxQHr+Oa63PBQ3q1oUhwhNwJgH+Xi0NjgWz5o6nd+8EwgNFGrH6UOe3wjKZtbZmjVn9r8w==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0" + } + }, "node_modules/swagger-ui-dist": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.20.1.tgz", diff --git a/package.json b/package.json index 9af78e5..8b65f10 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,8 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "snappy": "^7.3.2", + "sns-validator": "^0.3.5", + "svix": "^1.95.2", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.21", "typeorm-extension": "^3.7.0", diff --git a/src/runners/event-process/event-process.module.ts b/src/runners/event-process/event-process.module.ts index 4cb9d15..c524469 100644 --- a/src/runners/event-process/event-process.module.ts +++ b/src/runners/event-process/event-process.module.ts @@ -1,17 +1,25 @@ import { Module } from '@nestjs/common'; import { EventProcessService } from './services/event-process.service'; import { EventsReceivedConsumer } from './services/events-received.consumer'; +import { SignatureValidatorRegistry } from './services/signature-validator.registry'; +import { EventProcessMetrics } from './metrics/event-process-metrics'; /** * Runner module for RUN_MODE=event-process (story 3.3 / EVO-1208). * * Boots the `events.received.` consumer end of the webhook pipeline. * IMESSAGE_BROKER (BrokerModule, @Global) and CorrelationContext - * (CorrelationModule, @Global) come from their own modules, so this module only - * declares the consumer + stub handler. Imported conditionally from - * AppModule.forRoot() when AppFactory.shouldStartEventProcess() is true. + * (CorrelationModule, @Global) come from their own modules, and ConfigService + * is global (ConfigModule.forRoot isGlobal), so this module only declares the + * consumer, handler, signature-validator registry and metrics. Imported + * conditionally from AppModule.forRoot() when shouldStartEventProcess() is true. */ @Module({ - providers: [EventProcessService, EventsReceivedConsumer], + providers: [ + EventProcessService, + EventsReceivedConsumer, + SignatureValidatorRegistry, + EventProcessMetrics, + ], }) export class EventProcessModule {} diff --git a/src/runners/event-process/metrics/event-process-metrics.ts b/src/runners/event-process/metrics/event-process-metrics.ts new file mode 100644 index 0000000..aa996f4 --- /dev/null +++ b/src/runners/event-process/metrics/event-process-metrics.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { Counter, register } from 'prom-client'; + +const SIGNATURE_INVALID_METRIC = 'evo_webhook_signature_invalid_total'; + +/** + * Prometheus counters for the event-process webhook pipeline (story 3.4). + * + * The counter is fetched from the global registry if it already exists so that + * re-instantiating this provider (e.g. across test modules) does not throw the + * "metric already registered" error prom-client raises on duplicate names. + */ +@Injectable() +export class EventProcessMetrics { + readonly signatureInvalid: Counter; + + constructor() { + this.signatureInvalid = + (register.getSingleMetric(SIGNATURE_INVALID_METRIC) as + | Counter + | undefined) ?? + new Counter({ + name: SIGNATURE_INVALID_METRIC, + help: 'Webhook envelopes dropped because the signature was missing, invalid or unverifiable', + labelNames: ['platform', 'reason'], + }); + } +} diff --git a/src/runners/event-process/services/event-process.service.spec.ts b/src/runners/event-process/services/event-process.service.spec.ts index 6a919b1..d53c8c3 100644 --- a/src/runners/event-process/services/event-process.service.spec.ts +++ b/src/runners/event-process/services/event-process.service.spec.ts @@ -1,20 +1,39 @@ import { EventProcessService } from './event-process.service'; +import { SignatureValidatorRegistry } from './signature-validator.registry'; +import { EventProcessMetrics } from '../metrics/event-process-metrics'; describe('EventProcessService', () => { - const service = new EventProcessService(); + let service: EventProcessService; + let validate: jest.Mock; + let forPlatform: jest.Mock; + let inc: jest.Mock; const validEnvelope = { platform: 'evolution-api', - rawPayload: { hello: 'world' }, - headers: { 'x-test': '1' }, + rawPayload: 'raw-body-bytes', + headers: { apikey: 'tok' }, receivedAt: '2026-06-08T12:00:00.000Z', sourceIp: '203.0.113.10', ingestionId: '00000000-0000-4000-8000-000000000000', correlationId: '11111111-1111-4111-8111-111111111111', }; - it('resolves for a valid events.received envelope', async () => { + beforeEach(() => { + validate = jest.fn().mockReturnValue(true); + forPlatform = jest + .fn() + .mockReturnValue({ platform: 'evolution-api', validate }); + inc = jest.fn(); + service = new EventProcessService( + { for: forPlatform } as unknown as SignatureValidatorRegistry, + { signatureInvalid: { inc } } as unknown as EventProcessMetrics, + ); + }); + + it('processes a valid envelope whose signature verifies (no drop metric)', async () => { await expect(service.handle(validEnvelope)).resolves.toBeUndefined(); + expect(validate).toHaveBeenCalledWith('raw-body-bytes', { apikey: 'tok' }); + expect(inc).not.toHaveBeenCalled(); }); it('throws for a payload that is not a valid envelope', async () => { @@ -28,4 +47,40 @@ describe('EventProcessService', () => { service.handle({ ...validEnvelope, platform: 'not-a-platform' }), ).rejects.toThrow(); }); + + it('drops (acks, no throw) and counts the metric when the signature is invalid (AC2)', async () => { + validate.mockReturnValue(false); + + await expect(service.handle(validEnvelope)).resolves.toBeUndefined(); + + expect(inc).toHaveBeenCalledWith({ + platform: 'evolution-api', + reason: 'invalid_signature', + }); + }); + + it('drops with a warning when no validator is registered for the platform (AC3)', async () => { + forPlatform.mockReturnValue(null); + + await expect( + service.handle({ ...validEnvelope, platform: 'unknown' }), + ).resolves.toBeUndefined(); + + expect(validate).not.toHaveBeenCalled(); + expect(inc).toHaveBeenCalledWith({ + platform: 'unknown', + reason: 'no_validator', + }); + }); + + it('awaits an async validator (SES/SNS path)', async () => { + validate.mockResolvedValue(false); + + await expect(service.handle(validEnvelope)).resolves.toBeUndefined(); + + expect(inc).toHaveBeenCalledWith({ + platform: 'evolution-api', + reason: 'invalid_signature', + }); + }); }); diff --git a/src/runners/event-process/services/event-process.service.ts b/src/runners/event-process/services/event-process.service.ts index 69ab28f..9779a69 100644 --- a/src/runners/event-process/services/event-process.service.ts +++ b/src/runners/event-process/services/event-process.service.ts @@ -5,6 +5,8 @@ import { isEventsReceivedContract, } from 'src/shared/broker/contracts/events-received.contract'; import { TerminalError } from 'src/shared/errors/terminal-error'; +import { SignatureValidatorRegistry } from './signature-validator.registry'; +import { EventProcessMetrics } from '../metrics/event-process-metrics'; /** * Thrown when a consumed message is not a valid `events.received` envelope. @@ -26,6 +28,11 @@ export class InvalidEnvelopeError extends TerminalError {} export class EventProcessService { private readonly logger = new CustomLoggerService(EventProcessService.name); + constructor( + private readonly validators: SignatureValidatorRegistry, + private readonly metrics: EventProcessMetrics, + ) {} + async handle(envelope: unknown): Promise { if (!isEventsReceivedContract(envelope)) { throw new InvalidEnvelopeError( @@ -34,6 +41,8 @@ export class EventProcessService { } const valid: EventsReceivedContract = envelope; + if (!(await this.hasValidSignature(valid))) return; + this.logger.log('event-process.handle', { action: 'event-process.handle', platform: valid.platform, @@ -46,4 +55,61 @@ export class EventProcessService { return Promise.resolve(); } + + /** + * Resolves the provider's signature validator and runs it. Returns false — + * meaning "drop, but ack" — when no validator is registered for the platform + * (e.g. `unknown`) or the signature does not verify. Dropping is a plain + * `return` upstream, never a throw, so the broker acks and never redelivers a + * payload we have already rejected. + */ + private async hasValidSignature( + valid: EventsReceivedContract, + ): Promise { + const validator = this.validators.for(valid.platform); + if (!validator) { + this.logger.warn('event-process.signature.no-validator', { + action: 'event-process.signature.no-validator', + platform: valid.platform, + correlationId: valid.correlationId, + ingestionId: valid.ingestionId, + }); + this.metrics.signatureInvalid.inc({ + platform: valid.platform, + reason: 'no_validator', + }); + return false; + } + + // HMAC validators need the exact bytes the provider signed. The receiver + // (story 3.1) preserves rawPayload as the raw UTF-8 string; a non-string + // here means an upstream change broke that invariant and HMAC checks would + // silently fail, so surface it loudly. + if (typeof valid.rawPayload !== 'string') { + this.logger.warn('event-process.signature.non-string-payload', { + action: 'event-process.signature.non-string-payload', + platform: valid.platform, + correlationId: valid.correlationId, + ingestionId: valid.ingestionId, + }); + } + const rawPayload = + typeof valid.rawPayload === 'string' + ? valid.rawPayload + : JSON.stringify(valid.rawPayload ?? ''); + + if (await validator.validate(rawPayload, valid.headers)) return true; + + this.logger.warn('event-process.signature.invalid', { + action: 'event-process.signature.invalid', + platform: valid.platform, + correlationId: valid.correlationId, + ingestionId: valid.ingestionId, + }); + this.metrics.signatureInvalid.inc({ + platform: valid.platform, + reason: 'invalid_signature', + }); + return false; + } } diff --git a/src/runners/event-process/services/signature-validator.registry.spec.ts b/src/runners/event-process/services/signature-validator.registry.spec.ts new file mode 100644 index 0000000..ed2d42b --- /dev/null +++ b/src/runners/event-process/services/signature-validator.registry.spec.ts @@ -0,0 +1,31 @@ +import { ConfigService } from '@nestjs/config'; +import { SignatureValidatorRegistry } from './signature-validator.registry'; + +describe('SignatureValidatorRegistry', () => { + const config = { + get: jest.fn().mockReturnValue('configured-secret'), + } as unknown as ConfigService; + const registry = new SignatureValidatorRegistry(config); + + it.each([ + 'evolution-api', + 'sparkpost', + 'sendgrid', + 'mailersend', + 'resend', + 'ses', + 'mandrill', + ])('resolves a validator for %s', (platform) => { + const validator = registry.for(platform); + expect(validator).not.toBeNull(); + expect(validator?.platform).toBe(platform); + }); + + it('returns null for the unknown platform', () => { + expect(registry.for('unknown')).toBeNull(); + }); + + it('returns null for an unregistered platform', () => { + expect(registry.for('not-a-platform')).toBeNull(); + }); +}); diff --git a/src/runners/event-process/services/signature-validator.registry.ts b/src/runners/event-process/services/signature-validator.registry.ts new file mode 100644 index 0000000..317401a --- /dev/null +++ b/src/runners/event-process/services/signature-validator.registry.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CustomLoggerService } from 'src/common/services/custom-logger.service'; +import { Platform } from 'src/shared/broker/contracts/platform.enum'; +import { ISignatureValidator } from '../validators/signature-validator.interface'; +import { EvolutionApiValidator } from '../validators/evolution-api.validator'; +import { SparkPostValidator } from '../validators/sparkpost.validator'; +import { SendGridValidator } from '../validators/sendgrid.validator'; +import { MailerSendValidator } from '../validators/mailersend.validator'; +import { ResendValidator } from '../validators/resend.validator'; +import { SesValidator } from '../validators/ses.validator'; +import { MandrillValidator } from '../validators/mandrill.validator'; + +/** + * Resolves the signature validator for a given `events.received.` + * envelope (story 3.4 / EVO-1210). Validators are plain classes (not Nest + * providers) constructed here with their per-provider secrets read from env via + * ConfigService, which keeps each validator trivially unit-testable with a + * known secret. `for` returns null for `unknown` or any unregistered platform — + * the caller drops those envelopes. + */ +@Injectable() +export class SignatureValidatorRegistry { + private readonly logger = new CustomLoggerService( + SignatureValidatorRegistry.name, + ); + private readonly validators: Map; + + constructor(config: ConfigService) { + const evolutionToken = config.get('EVOLUTION_API_WEBHOOK_TOKEN'); + const sparkpostUser = config.get('SPARKPOST_WEBHOOK_USER'); + const sparkpostPassword = config.get('SPARKPOST_WEBHOOK_PASSWORD'); + const sendgridKey = config.get('SENDGRID_WEBHOOK_VERIFICATION_KEY'); + const mailersendSecret = config.get('MAILERSEND_WEBHOOK_SECRET'); + const resendSecret = config.get('RESEND_WEBHOOK_SECRET'); + const mandrillSecret = config.get('MANDRILL_WEBHOOK_SECRET'); + const mandrillUrl = config.get('MANDRILL_WEBHOOK_URL'); + + this.validators = new Map([ + ['evolution-api', new EvolutionApiValidator(evolutionToken)], + ['sparkpost', new SparkPostValidator(sparkpostUser, sparkpostPassword)], + ['sendgrid', new SendGridValidator(sendgridKey)], + ['mailersend', new MailerSendValidator(mailersendSecret)], + ['resend', new ResendValidator(resendSecret)], + ['ses', new SesValidator()], + ['mandrill', new MandrillValidator(mandrillSecret, mandrillUrl)], + ]); + + this.warnUnconfigured({ + 'evolution-api': !evolutionToken, + sparkpost: !sparkpostUser || !sparkpostPassword, + mailersend: !mailersendSecret, + resend: !resendSecret, + mandrill: !mandrillSecret || !mandrillUrl, + }); + } + + for(platform: string): ISignatureValidator | null { + return this.validators.get(platform as Platform) ?? null; + } + + /** + * Logs once at boot for every provider whose secret is unset, so a + * fail-closed drop caused by missing config surfaces as misconfiguration + * instead of masquerading as an attack in the signature-invalid metric. + * `sendgrid` (opt-in, passes through without a key) and `ses` (cert-based, no + * shared secret) are intentionally excluded. + */ + private warnUnconfigured(missingByPlatform: Record): void { + const missing = Object.keys(missingByPlatform).filter( + (platform) => missingByPlatform[platform], + ); + if (missing.length === 0) return; + this.logger.warn('event-process.signature.unconfigured-secrets', { + action: 'event-process.signature.unconfigured-secrets', + platforms: missing, + hint: 'these providers fail-closed (drop every event) until their *_WEBHOOK_SECRET is configured', + }); + } +} diff --git a/src/runners/event-process/validators/evolution-api.validator.spec.ts b/src/runners/event-process/validators/evolution-api.validator.spec.ts new file mode 100644 index 0000000..cb9e8c7 --- /dev/null +++ b/src/runners/event-process/validators/evolution-api.validator.spec.ts @@ -0,0 +1,31 @@ +import { EvolutionApiValidator } from './evolution-api.validator'; + +describe('EvolutionApiValidator', () => { + const token = 'evo-token'; + + it('accepts a matching apikey header', () => { + expect( + new EvolutionApiValidator(token).validate('{}', { apikey: token }), + ).toBe(true); + }); + + it('accepts a matching Authorization Bearer token', () => { + expect( + new EvolutionApiValidator(token).validate('{}', { + Authorization: `Bearer ${token}`, + }), + ).toBe(true); + }); + + it('rejects a wrong token', () => { + expect( + new EvolutionApiValidator(token).validate('{}', { apikey: 'nope' }), + ).toBe(false); + }); + + it('rejects when no token is configured', () => { + expect( + new EvolutionApiValidator(undefined).validate('{}', { apikey: token }), + ).toBe(false); + }); +}); diff --git a/src/runners/event-process/validators/evolution-api.validator.ts b/src/runners/event-process/validators/evolution-api.validator.ts new file mode 100644 index 0000000..1f432a6 --- /dev/null +++ b/src/runners/event-process/validators/evolution-api.validator.ts @@ -0,0 +1,23 @@ +import { ISignatureValidator } from './signature-validator.interface'; +import { getHeader, safeEqual } from './signature-validator.util'; + +/** + * Evolution API: static shared token. Evolution does not HMAC-sign webhook + * deliveries; it forwards a configured token in the `apikey` header (its own + * auth convention — see evolution-api `auth.guard.ts`). We also accept an + * `Authorization: Bearer ` fallback, compared constant-time. + */ +export class EvolutionApiValidator implements ISignatureValidator { + readonly platform = 'evolution-api'; + + constructor(private readonly token?: string) {} + + validate(_rawPayload: string, headers: Record): boolean { + if (!this.token) return false; + const provided = + getHeader(headers, 'apikey') ?? + getHeader(headers, 'Authorization')?.replace(/^Bearer\s+/i, ''); + if (!provided) return false; + return safeEqual(this.token, provided); + } +} diff --git a/src/runners/event-process/validators/mailersend.validator.spec.ts b/src/runners/event-process/validators/mailersend.validator.spec.ts new file mode 100644 index 0000000..6433362 --- /dev/null +++ b/src/runners/event-process/validators/mailersend.validator.spec.ts @@ -0,0 +1,28 @@ +import { createHmac } from 'crypto'; +import { MailerSendValidator } from './mailersend.validator'; + +describe('MailerSendValidator', () => { + const secret = 'ms-secret'; + const body = '{"type":"activity.opened"}'; + const sign = (s: string, b: string) => + createHmac('sha256', s).update(b, 'utf8').digest('hex'); + + it('accepts a valid HMAC-SHA256 signature', () => { + const v = new MailerSendValidator(secret); + expect(v.validate(body, { Signature: sign(secret, body) })).toBe(true); + }); + + it('rejects a signature computed with the wrong secret', () => { + const v = new MailerSendValidator(secret); + expect(v.validate(body, { Signature: sign('wrong', body) })).toBe(false); + }); + + it('rejects when no secret is configured (fail-closed)', () => { + const v = new MailerSendValidator(undefined); + expect(v.validate(body, { Signature: sign(secret, body) })).toBe(false); + }); + + it('rejects when the Signature header is missing', () => { + expect(new MailerSendValidator(secret).validate(body, {})).toBe(false); + }); +}); diff --git a/src/runners/event-process/validators/mailersend.validator.ts b/src/runners/event-process/validators/mailersend.validator.ts new file mode 100644 index 0000000..a855c7e --- /dev/null +++ b/src/runners/event-process/validators/mailersend.validator.ts @@ -0,0 +1,20 @@ +import { createHmac } from 'crypto'; +import { ISignatureValidator } from './signature-validator.interface'; +import { getHeader, safeEqual } from './signature-validator.util'; + +/** MailerSend: HMAC-SHA256 (hex) of the raw body, header `Signature`. */ +export class MailerSendValidator implements ISignatureValidator { + readonly platform = 'mailersend'; + + constructor(private readonly secret?: string) {} + + validate(rawPayload: string, headers: Record): boolean { + if (!this.secret) return false; + const provided = getHeader(headers, 'Signature'); + if (!provided) return false; + const expected = createHmac('sha256', this.secret) + .update(rawPayload, 'utf8') + .digest('hex'); + return safeEqual(expected, provided); + } +} diff --git a/src/runners/event-process/validators/mandrill.validator.spec.ts b/src/runners/event-process/validators/mandrill.validator.spec.ts new file mode 100644 index 0000000..ad661ed --- /dev/null +++ b/src/runners/event-process/validators/mandrill.validator.spec.ts @@ -0,0 +1,46 @@ +import { createHmac } from 'crypto'; +import { MandrillValidator } from './mandrill.validator'; + +describe('MandrillValidator', () => { + const secret = 'md-key'; + const url = 'https://hook.evo/webhooks/mandrill'; + const body = + 'mandrill_events=' + encodeURIComponent('[{"event":"hard_bounce"}]'); + + const sign = (s: string) => { + const params = new URLSearchParams(body); + const entries = [...params.entries()].sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0, + ); + let data = url; + for (const [k, v] of entries) data += k + v; + return createHmac('sha1', s).update(data, 'utf8').digest('base64'); + }; + + it('accepts a valid HMAC-SHA1 signature', () => { + const v = new MandrillValidator(secret, url); + expect(v.validate(body, { 'X-Mandrill-Signature': sign(secret) })).toBe( + true, + ); + }); + + it('rejects a signature computed with the wrong secret', () => { + const v = new MandrillValidator(secret, url); + expect(v.validate(body, { 'X-Mandrill-Signature': sign('nope') })).toBe( + false, + ); + }); + + it('rejects when the webhook URL or secret is missing', () => { + expect( + new MandrillValidator(undefined, url).validate(body, { + 'X-Mandrill-Signature': sign(secret), + }), + ).toBe(false); + expect( + new MandrillValidator(secret, undefined).validate(body, { + 'X-Mandrill-Signature': sign(secret), + }), + ).toBe(false); + }); +}); diff --git a/src/runners/event-process/validators/mandrill.validator.ts b/src/runners/event-process/validators/mandrill.validator.ts new file mode 100644 index 0000000..dec6e29 --- /dev/null +++ b/src/runners/event-process/validators/mandrill.validator.ts @@ -0,0 +1,36 @@ +import { createHmac } from 'crypto'; +import { ISignatureValidator } from './signature-validator.interface'; +import { getHeader, safeEqual } from './signature-validator.util'; + +/** + * Mandrill: HMAC-SHA1 (base64), header `X-Mandrill-Signature`. The signed data + * is the configured webhook URL followed by each POST param's key + value in + * ascending key order, so we re-parse the form-encoded raw body and need the + * exact URL Mandrill is configured to deliver to. + */ +export class MandrillValidator implements ISignatureValidator { + readonly platform = 'mandrill'; + + constructor( + private readonly secret?: string, + private readonly webhookUrl?: string, + ) {} + + validate(rawPayload: string, headers: Record): boolean { + if (!this.secret || !this.webhookUrl) return false; + const provided = getHeader(headers, 'X-Mandrill-Signature'); + if (!provided) return false; + + const params = new URLSearchParams(rawPayload); + const entries = [...params.entries()].sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0, + ); + let signedData = this.webhookUrl; + for (const [key, value] of entries) signedData += key + value; + + const expected = createHmac('sha1', this.secret) + .update(signedData, 'utf8') + .digest('base64'); + return safeEqual(expected, provided); + } +} diff --git a/src/runners/event-process/validators/resend.validator.spec.ts b/src/runners/event-process/validators/resend.validator.spec.ts new file mode 100644 index 0000000..2d26e5c --- /dev/null +++ b/src/runners/event-process/validators/resend.validator.spec.ts @@ -0,0 +1,37 @@ +import { Webhook } from 'svix'; +import { ResendValidator } from './resend.validator'; + +describe('ResendValidator', () => { + // Svix signing secrets are base64, optionally `whsec_`-prefixed. + const secret = + 'whsec_' + + Buffer.from('resend-signing-secret-key-1234', 'utf8').toString('base64'); + const id = 'msg_2abc'; + // Use a current timestamp so it falls inside Svix's signature tolerance window. + const now = new Date(); + const timestamp = Math.floor(now.getTime() / 1000).toString(); + const payload = '{"type":"email.delivered"}'; + const signature = new Webhook(secret).sign(id, now, payload); + + const headers = { + 'svix-id': id, + 'svix-timestamp': timestamp, + 'svix-signature': signature, + }; + + it('accepts a valid Svix signature', () => { + expect(new ResendValidator(secret).validate(payload, headers)).toBe(true); + }); + + it('rejects a tampered payload', () => { + expect(new ResendValidator(secret).validate(payload + 'x', headers)).toBe( + false, + ); + }); + + it('rejects when no secret is configured', () => { + expect(new ResendValidator(undefined).validate(payload, headers)).toBe( + false, + ); + }); +}); diff --git a/src/runners/event-process/validators/resend.validator.ts b/src/runners/event-process/validators/resend.validator.ts new file mode 100644 index 0000000..2373a69 --- /dev/null +++ b/src/runners/event-process/validators/resend.validator.ts @@ -0,0 +1,24 @@ +import { Webhook } from 'svix'; +import { ISignatureValidator } from './signature-validator.interface'; +import { getHeader } from './signature-validator.util'; + +/** Resend: Svix-signed webhooks (`svix-id`, `svix-timestamp`, `svix-signature`). */ +export class ResendValidator implements ISignatureValidator { + readonly platform = 'resend'; + + constructor(private readonly secret?: string) {} + + validate(rawPayload: string, headers: Record): boolean { + if (!this.secret) return false; + try { + new Webhook(this.secret).verify(rawPayload, { + 'svix-id': getHeader(headers, 'svix-id') ?? '', + 'svix-timestamp': getHeader(headers, 'svix-timestamp') ?? '', + 'svix-signature': getHeader(headers, 'svix-signature') ?? '', + }); + return true; + } catch { + return false; + } + } +} diff --git a/src/runners/event-process/validators/sendgrid.validator.spec.ts b/src/runners/event-process/validators/sendgrid.validator.spec.ts new file mode 100644 index 0000000..415bc85 --- /dev/null +++ b/src/runners/event-process/validators/sendgrid.validator.spec.ts @@ -0,0 +1,44 @@ +import { generateKeyPairSync, createSign } from 'crypto'; +import { SendGridValidator } from './sendgrid.validator'; + +describe('SendGridValidator', () => { + const { privateKey, publicKey } = generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + const verificationKey = publicKey + .export({ format: 'der', type: 'spki' }) + .toString('base64'); + const timestamp = '1609459200'; + const body = '[{"event":"delivered"}]'; + const signature = createSign('sha256') + .update(timestamp + body, 'utf8') + .sign(privateKey) + .toString('base64'); + + const headers = { + 'X-Twilio-Email-Event-Webhook-Signature': signature, + 'X-Twilio-Email-Event-Webhook-Timestamp': timestamp, + }; + + it('passes through when no verification key is configured (opt-in)', () => { + expect(new SendGridValidator(undefined).validate(body, {})).toBe(true); + }); + + it('accepts a valid ECDSA signature when a key is configured', () => { + expect(new SendGridValidator(verificationKey).validate(body, headers)).toBe( + true, + ); + }); + + it('rejects a tampered payload', () => { + expect( + new SendGridValidator(verificationKey).validate(body + 'x', headers), + ).toBe(false); + }); + + it('rejects when signature/timestamp headers are missing', () => { + expect(new SendGridValidator(verificationKey).validate(body, {})).toBe( + false, + ); + }); +}); diff --git a/src/runners/event-process/validators/sendgrid.validator.ts b/src/runners/event-process/validators/sendgrid.validator.ts new file mode 100644 index 0000000..2dd12dc --- /dev/null +++ b/src/runners/event-process/validators/sendgrid.validator.ts @@ -0,0 +1,44 @@ +import { createPublicKey, createVerify } from 'crypto'; +import { ISignatureValidator } from './signature-validator.interface'; +import { getHeader } from './signature-validator.util'; + +const SIGNATURE_HEADER = 'X-Twilio-Email-Event-Webhook-Signature'; +const TIMESTAMP_HEADER = 'X-Twilio-Email-Event-Webhook-Timestamp'; + +/** + * SendGrid: ECDSA signature over `timestamp + rawPayload`, headers + * `X-Twilio-Email-Event-Webhook-Signature` (base64) + `…-Timestamp`. The + * verification key is an optional base64 EC public key (SPKI/DER). + * + * When no key is configured the validator passes through: the channel ships + * without signed event webhooks today, so signature verification is opt-in via + * env rather than fail-closed (unlike the other providers). + */ +export class SendGridValidator implements ISignatureValidator { + readonly platform = 'sendgrid'; + + constructor(private readonly verificationKey?: string) {} + + validate(rawPayload: string, headers: Record): boolean { + if (!this.verificationKey) return true; + const signature = getHeader(headers, SIGNATURE_HEADER); + const timestamp = getHeader(headers, TIMESTAMP_HEADER); + if (!signature || !timestamp) return false; + try { + const publicKey = createPublicKey({ + key: Buffer.from(this.verificationKey, 'base64'), + format: 'der', + type: 'spki', + }); + // SendGrid sends an ASN.1/DER-encoded ECDSA signature, which is what + // Node's verify expects by default (dsaEncoding 'der'). Not exercised + // against a real SendGrid payload here — that smoke is deferred to Growth + // per the story scope. + return createVerify('sha256') + .update(timestamp + rawPayload, 'utf8') + .verify(publicKey, Buffer.from(signature, 'base64')); + } catch { + return false; + } + } +} diff --git a/src/runners/event-process/validators/ses.validator.spec.ts b/src/runners/event-process/validators/ses.validator.spec.ts new file mode 100644 index 0000000..8f792ac --- /dev/null +++ b/src/runners/event-process/validators/ses.validator.spec.ts @@ -0,0 +1,71 @@ +const mockValidate = jest.fn(); + +jest.mock('sns-validator', () => + jest.fn().mockImplementation(() => ({ + validate: mockValidate, + })), +); + +import { SesValidator } from './ses.validator'; + +// NOTE: the real SNS signature crypto (certificate fetch + RSA verify) is +// mocked out — it cannot run offline. These tests cover the synchronous guards +// (Type, cert-URL allowlist) and the pass/fail wiring around the validator +// callback; the real crypto path is left to manual/Growth smoke per the story. +describe('SesValidator', () => { + beforeEach(() => mockValidate.mockReset()); + + const message = (over: Record = {}) => + JSON.stringify({ + Type: 'Notification', + SignatureVersion: '1', + SigningCertURL: 'https://sns.us-east-1.amazonaws.com/cert.pem', + Signature: 'base64sig', + Message: '{}', + ...over, + }); + + it('accepts a structurally valid SNS message whose signature verifies', async () => { + mockValidate.mockImplementation( + (_m: unknown, cb: (e: Error | null) => void) => cb(null), + ); + expect(await new SesValidator().validate(message())).toBe(true); + }); + + it('rejects when the signing-cert URL is not an amazonaws host (no crypto call)', async () => { + expect( + await new SesValidator().validate( + message({ SigningCertURL: 'https://evil.example.com/cert.pem' }), + ), + ).toBe(false); + expect(mockValidate).not.toHaveBeenCalled(); + }); + + // EVO-1210 B2: a forged cert hosted on a public S3 bucket is still + // *.amazonaws.com but is NOT a real SNS signing host — must be rejected. + it('rejects a cert hosted on a non-SNS amazonaws host (S3 bucket forgery)', async () => { + expect( + await new SesValidator().validate( + message({ + SigningCertURL: 'https://attacker-bucket.s3.amazonaws.com/cert.pem', + }), + ), + ).toBe(false); + expect(mockValidate).not.toHaveBeenCalled(); + }); + + it('rejects when the SNS signature does not verify', async () => { + mockValidate.mockImplementation( + (_m: unknown, cb: (e: Error | null) => void) => + cb(new Error('bad signature')), + ); + expect(await new SesValidator().validate(message())).toBe(false); + }); + + it('rejects a non-JSON payload or an unsupported SNS Type', async () => { + expect(await new SesValidator().validate('not-json')).toBe(false); + expect( + await new SesValidator().validate(message({ Type: 'Whatever' })), + ).toBe(false); + }); +}); diff --git a/src/runners/event-process/validators/ses.validator.ts b/src/runners/event-process/validators/ses.validator.ts new file mode 100644 index 0000000..2d31af8 --- /dev/null +++ b/src/runners/event-process/validators/ses.validator.ts @@ -0,0 +1,54 @@ +import { ISignatureValidator } from './signature-validator.interface'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import MessageValidator = require('sns-validator'); + +// Must match sns-validator's own default: only real SNS signing-cert hosts +// (`sns..amazonaws.com`). A looser `*.amazonaws.com` pattern is +// forgeable — an attacker can host a cert on a public S3 bucket +// (`*.s3.amazonaws.com`) — so we deliberately do NOT widen it. +const SNS_CERT_HOST = /^sns\.[a-zA-Z0-9-]{3,}\.amazonaws\.com(\.cn)?$/; +const SNS_TYPES = [ + 'Notification', + 'SubscriptionConfirmation', + 'UnsubscribeConfirmation', +]; + +/** + * Amazon SES via SNS. Validates the SNS message signature with `sns-validator` + * (which fetches + caches the signing certificate over HTTPS — hence async), + * after a synchronous guard on Type and the strict SNS signing-cert host check. + * We rely on the library's secure default host pattern (no custom override). + */ +export class SesValidator implements ISignatureValidator { + readonly platform = 'ses'; + private readonly validator = new MessageValidator(); + + async validate(rawPayload: string): Promise { + let message: Record; + try { + message = JSON.parse(rawPayload) as Record; + } catch { + return false; + } + + if (typeof message.Type !== 'string' || !SNS_TYPES.includes(message.Type)) { + return false; + } + const certUrl = message.SigningCertURL ?? message.SigningCertUrl; + if (typeof certUrl !== 'string' || !this.isAmazonUrl(certUrl)) return false; + + return new Promise((resolve) => { + this.validator.validate(message, (err) => resolve(!err)); + }); + } + + private isAmazonUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'https:' && SNS_CERT_HOST.test(url.hostname); + } catch { + return false; + } + } +} diff --git a/src/runners/event-process/validators/signature-validator.interface.ts b/src/runners/event-process/validators/signature-validator.interface.ts new file mode 100644 index 0000000..fc95d08 --- /dev/null +++ b/src/runners/event-process/validators/signature-validator.interface.ts @@ -0,0 +1,20 @@ +import { Platform } from 'src/shared/broker/contracts/platform.enum'; + +/** + * One signature validator per webhook provider (story 3.4 / EVO-1210). Each + * provider signs its payload differently — HMAC variants, HTTP Basic Auth, + * Svix, AWS SNS, static token — so the registry resolves the right one by + * `platform` and the event-process pipeline calls `validate` before doing any + * further work. + * + * `validate` may be async: the SES (SNS) validator fetches and caches the + * signing certificate over HTTPS, which cannot be done synchronously. The + * HMAC/Basic/token validators stay synchronous and just return a boolean. + */ +export interface ISignatureValidator { + readonly platform: Platform; + validate( + rawPayload: string, + headers: Record, + ): boolean | Promise; +} diff --git a/src/runners/event-process/validators/signature-validator.util.ts b/src/runners/event-process/validators/signature-validator.util.ts new file mode 100644 index 0000000..b43ffd4 --- /dev/null +++ b/src/runners/event-process/validators/signature-validator.util.ts @@ -0,0 +1,28 @@ +import { timingSafeEqual } from 'crypto'; + +/** + * Case-insensitive header lookup. The normalized envelope preserves whatever + * casing the provider sent (`Signature`, `svix-id`, `X-Mandrill-Signature`…), + * so validators must not assume a canonical case. + */ +export function getHeader( + headers: Record, + name: string, +): string | undefined { + const target = name.toLowerCase(); + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === target) return headers[key]; + } + return undefined; +} + +/** + * Constant-time string comparison. Returns false on length mismatch without + * leaking timing, so it is safe to feed attacker-controlled signatures. + */ +export function safeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a, 'utf8'); + const bb = Buffer.from(b, 'utf8'); + if (ab.length !== bb.length) return false; + return timingSafeEqual(ab, bb); +} diff --git a/src/runners/event-process/validators/sparkpost.validator.spec.ts b/src/runners/event-process/validators/sparkpost.validator.spec.ts new file mode 100644 index 0000000..0211af0 --- /dev/null +++ b/src/runners/event-process/validators/sparkpost.validator.spec.ts @@ -0,0 +1,41 @@ +import { SparkPostValidator } from './sparkpost.validator'; + +describe('SparkPostValidator', () => { + const user = 'sp-user'; + const password = 'sp-pass'; + const basic = + 'Basic ' + Buffer.from(`${user}:${password}`, 'utf8').toString('base64'); + + it('accepts a matching Basic Auth header', () => { + expect( + new SparkPostValidator(user, password).validate('{}', { + Authorization: basic, + }), + ).toBe(true); + }); + + it('rejects wrong credentials', () => { + const wrong = 'Basic ' + Buffer.from('x:y', 'utf8').toString('base64'); + expect( + new SparkPostValidator(user, password).validate('{}', { + Authorization: wrong, + }), + ).toBe(false); + }); + + it('rejects a non-Basic Authorization header', () => { + expect( + new SparkPostValidator(user, password).validate('{}', { + Authorization: 'Bearer token', + }), + ).toBe(false); + }); + + it('rejects when credentials are not configured', () => { + expect( + new SparkPostValidator(undefined, undefined).validate('{}', { + Authorization: basic, + }), + ).toBe(false); + }); +}); diff --git a/src/runners/event-process/validators/sparkpost.validator.ts b/src/runners/event-process/validators/sparkpost.validator.ts new file mode 100644 index 0000000..2f4c8a2 --- /dev/null +++ b/src/runners/event-process/validators/sparkpost.validator.ts @@ -0,0 +1,22 @@ +import { ISignatureValidator } from './signature-validator.interface'; +import { getHeader, safeEqual } from './signature-validator.util'; + +/** SparkPost: HTTP Basic Auth on the webhook request (`Authorization: Basic …`). */ +export class SparkPostValidator implements ISignatureValidator { + readonly platform = 'sparkpost'; + + constructor( + private readonly user?: string, + private readonly password?: string, + ) {} + + validate(_rawPayload: string, headers: Record): boolean { + if (!this.user || !this.password) return false; + const provided = getHeader(headers, 'Authorization'); + if (!provided?.startsWith('Basic ')) return false; + const expected = + 'Basic ' + + Buffer.from(`${this.user}:${this.password}`, 'utf8').toString('base64'); + return safeEqual(expected, provided); + } +} diff --git a/src/runners/event-receiver/services/payload-normalizer.service.spec.ts b/src/runners/event-receiver/services/payload-normalizer.service.spec.ts index 0aa4fb6..b1d0046 100644 --- a/src/runners/event-receiver/services/payload-normalizer.service.spec.ts +++ b/src/runners/event-receiver/services/payload-normalizer.service.spec.ts @@ -63,6 +63,35 @@ describe('PayloadNormalizerService', () => { expect(envelope.headers['content-type']).toBe('application/json'); }); + // EVO-1210 B1: SparkPost's only webhook auth is HTTP Basic in Authorization, + // which the event-process validator must see — so Authorization is preserved + // for sparkpost only, while other credential headers stay redacted. + it('preserves the Authorization header for sparkpost (but not its other credential headers)', () => { + const basic = + 'Basic ' + Buffer.from('sp-user:sp-pass', 'utf8').toString('base64'); + const envelope = normalizer.build({ + ...baseInput, + platform: 'sparkpost' as const, + headers: { + authorization: basic, + cookie: 'session=abc', + } as IncomingHttpHeaders, + }); + + expect(envelope.headers['authorization']).toBe(basic); + expect(envelope.headers['cookie']).toBe('[REDACTED]'); + }); + + it('still redacts Authorization for non-sparkpost platforms', () => { + const envelope = normalizer.build({ + ...baseInput, + platform: 'mailersend' as const, + headers: { authorization: 'Basic abc' } as IncomingHttpHeaders, + }); + + expect(envelope.headers['authorization']).toBe('[REDACTED]'); + }); + it('flattens array-valued headers and drops undefined ones', () => { const envelope = normalizer.build({ ...baseInput, diff --git a/src/runners/event-receiver/services/payload-normalizer.service.ts b/src/runners/event-receiver/services/payload-normalizer.service.ts index 7896aba..c131c81 100644 --- a/src/runners/event-receiver/services/payload-normalizer.service.ts +++ b/src/runners/event-receiver/services/payload-normalizer.service.ts @@ -45,7 +45,7 @@ export class PayloadNormalizerService { return { platform: input.platform, rawPayload: input.rawPayload, - headers: this.flattenHeaders(input.headers), + headers: this.flattenHeaders(input.headers, input.platform), receivedAt: new Date().toISOString(), sourceIp: input.sourceIp, ingestionId: randomUUID(), @@ -53,11 +53,14 @@ export class PayloadNormalizerService { }; } - private flattenHeaders(headers: IncomingHttpHeaders): Record { + private flattenHeaders( + headers: IncomingHttpHeaders, + platform: Platform, + ): Record { const flat: Record = {}; for (const [key, value] of Object.entries(headers)) { if (value === undefined) continue; - flat[key] = REDACTED_HEADERS.has(key.toLowerCase()) + flat[key] = this.shouldRedact(key.toLowerCase(), platform) ? REDACTED_VALUE : Array.isArray(value) ? value.join(', ') @@ -65,4 +68,16 @@ export class PayloadNormalizerService { } return flat; } + + /** + * SparkPost authenticates its webhook with HTTP Basic Auth in the + * Authorization header, and story 3.4's validator can only verify it if the + * value survives ingestion — so Authorization is the single credential header + * preserved, and only for the `sparkpost` platform. Every other credential + * header, and Authorization on any other platform, stays redacted. + */ + private shouldRedact(lowerKey: string, platform: Platform): boolean { + if (lowerKey === 'authorization' && platform === 'sparkpost') return false; + return REDACTED_HEADERS.has(lowerKey); + } } diff --git a/src/types/sns-validator.d.ts b/src/types/sns-validator.d.ts new file mode 100644 index 0000000..66db8eb --- /dev/null +++ b/src/types/sns-validator.d.ts @@ -0,0 +1,15 @@ +/** + * Minimal type shim for `sns-validator` (no bundled types, no @types package). + * Only the surface the SES signature validator uses is declared. + */ +declare module 'sns-validator' { + type SnsMessage = Record; + type ValidateCallback = (err: Error | null, message?: SnsMessage) => void; + + class MessageValidator { + constructor(hostPattern?: RegExp, encoding?: string); + validate(message: SnsMessage | string, cb: ValidateCallback): void; + } + + export = MessageValidator; +}