Skip to content

MT-22022: Add webhook signature verification helper#130

Merged
Rabsztok merged 4 commits into
mainfrom
MT-22022-webhook-signature-verification
May 27, 2026
Merged

MT-22022: Add webhook signature verification helper#130
Rabsztok merged 4 commits into
mainfrom
MT-22022-webhook-signature-verification

Conversation

@Rabsztok
Copy link
Copy Markdown
Contributor

@Rabsztok Rabsztok commented May 20, 2026

Motivation

Expose a helper so Node.js users don't have to re-implement Mailtrap's HMAC-SHA256 webhook signature check on every receiver.

Changes

  • verifyWebhookSignature(payload, signature, signingSecret) exported from the package root. HMAC-SHA256 over the raw body, constant-time compare via crypto.timingSafeEqual. Accepts payload as string | Buffer. Returns false (never throws) on empty / wrong-length / non-hex / wrong-type inputs.
  • src/__tests__/lib/webhooks/verify-signature.test.ts pins the cross-SDK fixture (payload + signing_secret + expected digest) shared verbatim across all six official Mailtrap SDKs to guarantee byte-for-byte parity.
  • examples/webhooks/verify-signature.ts — runnable usage snippet.
  • README — new "Verifying webhook signatures" subsection.

How to test

CI runs jest, eslint, and tsc. Manually:

yarn ts-node examples/webhooks/verify-signature.ts

The script should exit 0 with no output.

Companion PRs

Coordinated rollout across all six official SDKs (same algorithm, same shared fixture):

Summary by CodeRabbit

  • New Features

    • Added a webhook signature verification utility for validating incoming Mailtrap webhooks (HMAC-SHA256).
  • Documentation

    • README updated with a "Verifying webhook signatures" section and usage guidance.
  • Examples

    • Added an example HTTP server demonstrating signature verification and proper 200/401 responses.
  • Tests

    • Added comprehensive tests covering valid, invalid, and edge-case signature scenarios.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

📝 Walkthrough

Walkthrough

This PR adds webhook signature verification to the Mailtrap SDK. A new verifyWebhookSignature function validates HMAC-SHA256 signatures using constant-time comparison, handles malformed inputs gracefully without throwing, and is exported as part of the public API. Tests, documentation, and examples are included.

Changes

Webhook Signature Verification Feature

Layer / File(s) Summary
Core webhook signature verification implementation
src/lib/webhooks/verify-signature.ts
verifyWebhookSignature function computes HMAC-SHA256 over the raw payload using the signing secret, decodes the provided signature from hex, and performs constant-time comparison via timingSafeEqual. Input validation rejects malformed types, lengths, and non-hex signatures; all errors return false instead of throwing.
Module export and public API
src/index.ts
verifyWebhookSignature is imported and re-exported from the main SDK entry point, making it publicly available alongside MailtrapClient and MailtrapTransport.
Comprehensive test coverage
src/__tests__/lib/webhooks/verify-signature.test.ts
Jest test suite uses a cross-SDK HMAC fixture and validates correct signature verification, rejection of wrong secrets and tampered payloads, non-throwing behavior on invalid signatures, empty inputs, and equivalence between string and Buffer payloads.
Documentation and usage examples
README.md, examples/webhooks/verify-signature.ts
README adds a link to the new example; example script demonstrates buffering the raw request body, extracting mailtrap-signature, and calling verifyWebhookSignature, returning 401 on invalid signatures and 200 on success.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

feature request

Suggested reviewers

  • mklocek
  • VladimirTaytor
  • narekhovhannisyan

Poem

🐰 I buffer bytes in moonlit night,
I HMAC them with gentle might,
I hex-compare with steady paw,
No errors thrown, just verdicts raw.
Your webhooks safe — hop, dance, delight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: adding a webhook signature verification helper function, which is the primary focus of all modifications.
Description check ✅ Passed The description provides comprehensive motivation, detailed changes, and testing instructions; it aligns well with the template sections and effectively communicates the PR's purpose and scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch MT-22022-webhook-signature-verification

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Rabsztok
Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/webhooks/verify-signature.ts`:
- Around line 50-52: The payload-type check in verifySignature (the function
handling parameters payload, signature, secret) currently allows empty
strings/Buffers; update the guard so that it also rejects empty payloads by
returning false when payload is an empty string (payload.length === 0) or an
empty Buffer (payload.length === 0) in addition to the existing
typeof/Buffer.isBuffer checks; keep the existing type check logic but add the
emptiness condition so empty inputs explicitly return false.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9d01f3a3-563c-4243-8f35-779cff4476d0

📥 Commits

Reviewing files that changed from the base of the PR and between 9a8e14f and 2bad438.

📒 Files selected for processing (5)
  • README.md
  • examples/webhooks/verify-signature.ts
  • src/__tests__/lib/webhooks/verify-signature.test.ts
  • src/index.ts
  • src/lib/webhooks/verify-signature.ts

Comment thread src/lib/webhooks/verify-signature.ts
Add `verifyWebhookSignature(payload, signature, signingSecret)` exported
from the package root for verifying Mailtrap webhook signatures using
HMAC-SHA256 over the raw request body with constant-time hex comparison
via `crypto.timingSafeEqual`.

Returns false (no throw) for missing/empty/malformed/wrong-length
signatures so a single guard at the request handler covers every bad-input
case. Accepts `string | Buffer` payloads; signed input must be the raw
body bytes — the README and example warn against `express.json()` and show
the `express.raw({type: 'application/json'})` pattern.

Includes the shared cross-SDK test fixture (payload + secret + expected
signature) that all six Mailtrap SDKs use to stay byte-for-byte
compatible, plus a runnable Express receiver example and README
subsection.

See https://railsware.atlassian.net/browse/MT-22022
@Rabsztok Rabsztok force-pushed the MT-22022-webhook-signature-verification branch from 2bad438 to eaab34a Compare May 20, 2026 14:18
@Rabsztok Rabsztok requested review from IgorDobryn and mklocek May 26, 2026 07:48
Comment thread examples/webhooks/verify-signature.ts Outdated
Co-authored-by: Marcin Klocek <marcin.klocek@railsware.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
examples/webhooks/verify-signature.ts (1)

13-16: ⚡ Quick win

Keep payload as Buffer and normalize mailtrap-signature header type.

Passing raw Buffer avoids encoding assumptions, and explicit header normalization handles string[] correctly.

Suggested patch
-    const payload = Buffer.concat(chunks).toString("utf-8");
-    const signature = (req.headers["mailtrap-signature"] as string) ?? "";
+    const payload = Buffer.concat(chunks);
+    const rawSignature = req.headers["mailtrap-signature"];
+    const signature = Array.isArray(rawSignature)
+      ? rawSignature[0] ?? ""
+      : rawSignature ?? "";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/webhooks/verify-signature.ts` around lines 13 - 16, Keep the payload
as a Buffer and normalize the "mailtrap-signature" header before calling
verifyWebhookSignature: replace payload =
Buffer.concat(chunks).toString("utf-8") with the raw Buffer (const payload =
Buffer.concat(chunks)) and normalize the header value from
req.headers["mailtrap-signature"] (handle string | string[] by using
Array.isArray to pick the first element or join them) into a single string
(falling back to ""), then pass that normalized signature and the Buffer payload
into verifyWebhookSignature(payload, signature, SIGNING_SECRET).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/webhooks/verify-signature.ts`:
- Around line 10-24: Add a streaming size limit to the webhook body collection:
in the req.on("data") handler for the chunks array, track accumulated bytes and
if it exceeds a configurable max (e.g., MAX_PAYLOAD_BYTES) immediately stop
reading, respond with 413 and end the request, and ensure you clean up listeners
/ destroy the socket to avoid continuing to buffer; only call Buffer.concat(...)
and verifyWebhookSignature(payload, signature, SIGNING_SECRET) when under the
limit. Also stop casting the header to string — normalize
req.headers["mailtrap-signature"] (which may be string | string[] | undefined)
into a single string (e.g., take the first element or join with ",") before
passing it to verifyWebhookSignature so you handle all header shapes safely.

---

Nitpick comments:
In `@examples/webhooks/verify-signature.ts`:
- Around line 13-16: Keep the payload as a Buffer and normalize the
"mailtrap-signature" header before calling verifyWebhookSignature: replace
payload = Buffer.concat(chunks).toString("utf-8") with the raw Buffer (const
payload = Buffer.concat(chunks)) and normalize the header value from
req.headers["mailtrap-signature"] (handle string | string[] by using
Array.isArray to pick the first element or join them) into a single string
(falling back to ""), then pass that normalized signature and the Buffer payload
into verifyWebhookSignature(payload, signature, SIGNING_SECRET).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5ae47283-e3f8-43c0-a6e5-7f20368975fe

📥 Commits

Reviewing files that changed from the base of the PR and between 2bad438 and 8ac33aa.

📒 Files selected for processing (5)
  • README.md
  • examples/webhooks/verify-signature.ts
  • src/__tests__/lib/webhooks/verify-signature.test.ts
  • src/index.ts
  • src/lib/webhooks/verify-signature.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/index.ts
  • src/lib/webhooks/verify-signature.ts

Comment on lines +10 to +24
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const payload = Buffer.concat(chunks).toString("utf-8");
const signature = (req.headers["mailtrap-signature"] as string) ?? "";

if (!verifyWebhookSignature(payload, signature, SIGNING_SECRET)) {
res.writeHead(401, { "Content-Type": "text/plain" });
res.end("Invalid signature");
return;
}

res.writeHead(200, { "Content-Type": "text/plain" });
res.end();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add a request body size limit to prevent memory DoS.
The example buffers the entire webhook body without bounds; a large request can exhaust memory. Enforce a max byte limit while streaming (e.g., return 413 and stop reading) before Buffer.concat(...) and signature verification.

  • Also normalize req.headers["mailtrap-signature"] to handle string[] | undefined instead of casting to string.
Suggested patch
+const MAX_BODY_BYTES = 1024 * 1024; // 1MB example limit
+
 const server = createServer((req: IncomingMessage, res: ServerResponse) => {
   // Use the raw request body — parsing and re-serializing the JSON may
   // reorder keys or alter whitespace and invalidate the signature.
   const chunks: Buffer[] = [];
+  let total = 0;
   req.on("data", (chunk: Buffer) => chunks.push(chunk));
+  req.on("data", (chunk: Buffer) => {
+    total += chunk.length;
+    if (total &gt; MAX_BODY_BYTES) {
+      res.writeHead(413, { "Content-Type": "text/plain" });
+      res.end("Payload too large");
+      req.destroy();
+    }
+  });
   req.on("end", () =&gt; {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/webhooks/verify-signature.ts` around lines 10 - 24, Add a streaming
size limit to the webhook body collection: in the req.on("data") handler for the
chunks array, track accumulated bytes and if it exceeds a configurable max
(e.g., MAX_PAYLOAD_BYTES) immediately stop reading, respond with 413 and end the
request, and ensure you clean up listeners / destroy the socket to avoid
continuing to buffer; only call Buffer.concat(...) and
verifyWebhookSignature(payload, signature, SIGNING_SECRET) when under the limit.
Also stop casting the header to string — normalize
req.headers["mailtrap-signature"] (which may be string | string[] | undefined)
into a single string (e.g., take the first element or join with ",") before
passing it to verifyWebhookSignature so you handle all header shapes safely.

@Rabsztok Rabsztok merged commit 5c15f0e into main May 27, 2026
4 checks passed
@Rabsztok Rabsztok deleted the MT-22022-webhook-signature-verification branch May 27, 2026 05:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants