diff --git a/README.md b/README.md index fdb166c..1907710 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ Email API: - Sending stats (aggregated and by domain, category, ESP, date) – [`stats/everything.ts`](examples/stats/everything.ts) - Email logs (list with filters, get by message ID) – [`email-logs/everything.ts`](examples/email-logs/everything.ts) - Webhooks CRUD – [`webhooks/everything.ts`](examples/webhooks/everything.ts) +- Verifying webhook signatures – [`webhooks/verify-signature.ts`](examples/webhooks/verify-signature.ts) Email Sandbox (Testing): @@ -269,6 +270,30 @@ General API: - API tokens CRUD & reset – [`general/api-tokens.ts`](examples/general/api-tokens.ts) - Sub-accounts (list & create) – [`sub-accounts/everything.ts`](examples/sub-accounts/everything.ts) +## Verifying webhook signatures + +Mailtrap signs every outbound webhook with HMAC-SHA256 and sends the lowercase hex digest in the `Mailtrap-Signature` header. Verify the signature against the raw request body using the `signing_secret` returned when you created the webhook: + +```ts +import { verifyWebhookSignature } from "mailtrap"; + +// `rawBody` must be the unparsed request body bytes (string or Buffer) — do +// NOT re-serialize the parsed JSON, as that may reorder keys and invalidate +// the signature. +const valid = verifyWebhookSignature( + rawBody, + req.header("Mailtrap-Signature") ?? "", + process.env.MAILTRAP_WEBHOOK_SIGNING_SECRET ?? "" +); + +if (!valid) { + res.status(401).send(); + return; +} +``` + +The helper performs a constant-time comparison and returns `false` (rather than throwing) for empty, missing, or malformed signatures. + ## Contributing Bug reports and pull requests are welcome on [GitHub](https://github.com/railsware/mailtrap-nodejs). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md). diff --git a/examples/webhooks/verify-signature.ts b/examples/webhooks/verify-signature.ts new file mode 100644 index 0000000..6b1e6ab --- /dev/null +++ b/examples/webhooks/verify-signature.ts @@ -0,0 +1,15 @@ +import { createHmac } from "crypto"; + +// In a real project, this import would be `import { verifyWebhookSignature } from "mailtrap";` +import { verifyWebhookSignature } from "../../src"; + +// --- Direct verification (e.g. for unit tests or custom routers) ---------- +const payload = '{"event":"delivery","message_id":"abc-123"}'; +const signingSecret = "8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e"; +const signature = createHmac("sha256", signingSecret) + .update(payload) + .digest("hex"); + +if (!verifyWebhookSignature(payload, signature, signingSecret)) { + throw new Error("Signature verification failed!"); +} diff --git a/src/__tests__/lib/webhooks/verify-signature.test.ts b/src/__tests__/lib/webhooks/verify-signature.test.ts new file mode 100644 index 0000000..af12834 --- /dev/null +++ b/src/__tests__/lib/webhooks/verify-signature.test.ts @@ -0,0 +1,149 @@ +import { createHmac } from "crypto"; + +import verifyWebhookSignature, { + SIGNATURE_HEX_LENGTH, +} from "../../../lib/webhooks/verify-signature"; + +// --------------------------------------------------------------------------- +// Cross-SDK fixture +// +// The (payload, signing_secret, expected_signature) triple below is the +// canonical fixture shared verbatim by every official Mailtrap SDK +// (mailtrap-ruby, mailtrap-python, mailtrap-php, mailtrap-nodejs, +// mailtrap-java, mailtrap-dotnet). Any change here MUST be mirrored in the +// equivalent test files in the other SDKs so the helpers stay byte-for-byte +// compatible across languages. +// --------------------------------------------------------------------------- +const FIXTURE_PAYLOAD = + '{"event":"delivery","sending_stream":"transactional","category":"welcome","message_id":"a8b1d8f6-1f8d-4a3c-9b2e-1a2b3c4d5e6f","email":"recipient@example.com","event_id":"f1e2d3c4-b5a6-7890-1234-567890abcdef","timestamp":1716070000}'; +const FIXTURE_SIGNING_SECRET = "8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e"; +const FIXTURE_EXPECTED_SIGNATURE = + "6d262e2611cd09be1f948382b5c611d63b0e585c4c9c5e40139d6ac3876d5433"; + +describe("lib/webhooks/verify-signature: ", () => { + describe("verifyWebhookSignature(): ", () => { + // --- 1. Valid signature for given payload + secret --------------------- + it("returns true for valid signature, payload and secret.", () => { + expect( + verifyWebhookSignature( + FIXTURE_PAYLOAD, + FIXTURE_EXPECTED_SIGNATURE, + FIXTURE_SIGNING_SECRET + ) + ).toBe(true); + }); + + // --- 2. Wrong secret --------------------------------------------------- + it("returns false with a wrong signing secret.", () => { + expect( + verifyWebhookSignature( + FIXTURE_PAYLOAD, + FIXTURE_EXPECTED_SIGNATURE, + "ffffffffffffffffffffffffffffffff" + ) + ).toBe(false); + }); + + // --- 3. Payload tampered (one byte changed) ---------------------------- + it("returns false when the payload is tampered.", () => { + const tampered = FIXTURE_PAYLOAD.replace("delivery", "Delivery"); + + expect( + verifyWebhookSignature( + tampered, + FIXTURE_EXPECTED_SIGNATURE, + FIXTURE_SIGNING_SECRET + ) + ).toBe(false); + }); + + // --- 4. Signature with wrong length ------------------------------------ + it("returns false without throwing when the signature is too short.", () => { + const tooShort = FIXTURE_EXPECTED_SIGNATURE.slice(0, 31); + + expect(() => + verifyWebhookSignature( + FIXTURE_PAYLOAD, + tooShort, + FIXTURE_SIGNING_SECRET + ) + ).not.toThrow(); + + expect( + verifyWebhookSignature( + FIXTURE_PAYLOAD, + tooShort, + FIXTURE_SIGNING_SECRET + ) + ).toBe(false); + }); + + // --- 5. Signature with non-hex characters ------------------------------ + it("returns false without throwing for a non-hex signature.", () => { + const notHex = "z".repeat(SIGNATURE_HEX_LENGTH); + + expect(() => + verifyWebhookSignature(FIXTURE_PAYLOAD, notHex, FIXTURE_SIGNING_SECRET) + ).not.toThrow(); + + expect( + verifyWebhookSignature(FIXTURE_PAYLOAD, notHex, FIXTURE_SIGNING_SECRET) + ).toBe(false); + }); + + // --- 6. Empty signature string ----------------------------------------- + it("returns false for an empty signature string.", () => { + expect( + verifyWebhookSignature(FIXTURE_PAYLOAD, "", FIXTURE_SIGNING_SECRET) + ).toBe(false); + }); + + // --- 7. Empty signing_secret ------------------------------------------- + it("returns false for an empty signing secret.", () => { + expect( + verifyWebhookSignature(FIXTURE_PAYLOAD, FIXTURE_EXPECTED_SIGNATURE, "") + ).toBe(false); + }); + + // --- 8. Empty payload + non-empty signature ---------------------------- + it("returns false for an empty payload.", () => { + expect( + verifyWebhookSignature( + "", + FIXTURE_EXPECTED_SIGNATURE, + FIXTURE_SIGNING_SECRET + ) + ).toBe(false); + }); + + // --- 9. Known-good cross-SDK fixture ----------------------------------- + it("matches the hardcoded HMAC-SHA256 digest for the shared fixture.", () => { + // Recompute the digest in-place so a regression in Node's crypto module + // or the fixture itself fails loudly: this is the byte-for-byte + // contract every other Mailtrap SDK must satisfy. + const computed = createHmac("sha256", FIXTURE_SIGNING_SECRET) + .update(FIXTURE_PAYLOAD) + .digest("hex"); + + expect(computed).toBe(FIXTURE_EXPECTED_SIGNATURE); + expect( + verifyWebhookSignature( + FIXTURE_PAYLOAD, + FIXTURE_EXPECTED_SIGNATURE, + FIXTURE_SIGNING_SECRET + ) + ).toBe(true); + }); + + // --- Bonus: accepts a Buffer payload ----------------------------------- + it("accepts a Buffer payload equivalently to a UTF-8 string.", () => { + expect( + verifyWebhookSignature( + Buffer.from(FIXTURE_PAYLOAD, "utf-8"), + FIXTURE_EXPECTED_SIGNATURE, + FIXTURE_SIGNING_SECRET + ) + ).toBe(true); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 501e193..5bc2175 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import MailtrapClient from "./lib/MailtrapClient"; import MailtrapTransport from "./lib/transport"; +import verifyWebhookSignature from "./lib/webhooks/verify-signature"; export * from "./types/mailtrap"; -export { MailtrapClient, MailtrapTransport }; +export { MailtrapClient, MailtrapTransport, verifyWebhookSignature }; diff --git a/src/lib/webhooks/verify-signature.ts b/src/lib/webhooks/verify-signature.ts new file mode 100644 index 0000000..790e099 --- /dev/null +++ b/src/lib/webhooks/verify-signature.ts @@ -0,0 +1,92 @@ +import { createHmac, timingSafeEqual } from "crypto"; + +/** + * Hex-encoded HMAC-SHA256 signature length (SHA-256 produces 32 bytes / 64 hex chars). + */ +export const SIGNATURE_HEX_LENGTH = 64; + +/** + * Verifies the HMAC-SHA256 signature of a Mailtrap webhook payload. + * + * Mailtrap signs every outbound webhook by computing + * `HMAC-SHA256(signing_secret, raw_request_body)` and sending the lowercase + * hex digest in the `Mailtrap-Signature` HTTP header. Compute the same digest + * on your side and compare it in constant time. + * + * The comparison is performed with {@link timingSafeEqual} to avoid timing + * side-channels. + * + * The function never throws on inputs that could plausibly arrive over the + * wire (empty strings, wrong-length signatures, non-hex characters, missing + * secret) — it simply returns `false`. This makes it safe to call directly + * from a request handler without wrapping in try/catch. + * + * @param payload - The raw request body, exactly as received. Accepts a + * UTF-8 `string` or a raw `Buffer`. **Do not** parse and re-serialize the + * JSON — re-encoding may reorder keys or alter whitespace and invalidate + * the signature. With Express, use + * `express.raw({ type: 'application/json' })` (or equivalent) on the + * webhook route so the body is preserved verbatim. + * @param signature - The value of the `Mailtrap-Signature` HTTP header + * (lowercase hex string). + * @param signingSecret - The webhook's `signing_secret`, returned by the + * Webhooks API on webhook creation. + * @returns `true` if the signature is valid for the given payload and + * secret, `false` otherwise. + * + * @see https://docs.mailtrap.io/email-api-smtp/advanced/webhooks#verifying-the-signature + */ +export default function verifyWebhookSignature( + payload: string | Buffer, + signature: string, + signingSecret: string +): boolean { + if (typeof signature !== "string" || signature.length === 0) { + return false; + } + if (typeof signingSecret !== "string" || signingSecret.length === 0) { + return false; + } + if (typeof payload !== "string" && !Buffer.isBuffer(payload)) { + return false; + } + if (payload.length === 0) { + return false; + } + if (signature.length !== SIGNATURE_HEX_LENGTH) { + return false; + } + + let expected: string; + try { + expected = createHmac("sha256", signingSecret) + .update(payload) + .digest("hex"); + } catch { + return false; + } + + let expectedBuffer: Buffer; + let providedBuffer: Buffer; + try { + expectedBuffer = Buffer.from(expected, "hex"); + providedBuffer = Buffer.from(signature, "hex"); + } catch { + return false; + } + + // `Buffer.from(str, 'hex')` silently drops trailing non-hex characters + // rather than throwing. Re-check the buffer lengths so a signature with + // non-hex characters (which produces a shorter decoded buffer) is rejected + // as a length mismatch instead of being silently accepted/rejected by + // `timingSafeEqual` — which itself throws on mismatched lengths. + if (expectedBuffer.length !== providedBuffer.length) { + return false; + } + + try { + return timingSafeEqual(expectedBuffer, providedBuffer); + } catch { + return false; + } +}