From 1dff0fcf77f4cef521c5a8de11a22191e7fdd661 Mon Sep 17 00:00:00 2001 From: Larslllllll Date: Tue, 18 Aug 2026 23:02:31 +0200 Subject: [PATCH] feat(webhook_verify): timing-safe webhook signature verification module Adds a community module for the one piece of security code most backends rewrite and get wrong. Supports Stripe (incl. multi-v1 secret rotation), GitHub, Slack, Shopify, Twilio and generic hex HMAC. Design rules, each covered by a test: - every digest comparison goes through timingSafeEqual, with an explicit length check first - replay windows on every provider that signs a timestamp, symmetric so future-dated timestamps are rejected too - verification runs against the raw body; nothing is parsed before the signature is checked - failures return a specific reason (signature_mismatch / timestamp_out_of_range / malformed_signature) instead of a bare false, so a falsy value cannot be mistaken for a handled failure Zero dependencies beyond node:crypto. 29 mocha/chai tests. Follows the template module layout: index.js register(), src/{service,controller,utils}.js, test/, examples/, docs/. Refs #2 --- mcp_modules/webhook_verify/README.md | 85 ++++++++ mcp_modules/webhook_verify/docs/api.md | 46 +++++ .../webhook_verify/examples/basic-usage.js | 34 ++++ mcp_modules/webhook_verify/index.js | 132 +++++++++++++ mcp_modules/webhook_verify/package.json | 40 ++++ mcp_modules/webhook_verify/src/controller.js | 51 +++++ mcp_modules/webhook_verify/src/service.js | 181 ++++++++++++++++++ mcp_modules/webhook_verify/src/utils.js | 46 +++++ .../webhook_verify/test/service.test.js | 133 +++++++++++++ mcp_modules/webhook_verify/test/setup.js | 81 ++++++++ mcp_modules/webhook_verify/test/utils.test.js | 36 ++++ 11 files changed, 865 insertions(+) create mode 100644 mcp_modules/webhook_verify/README.md create mode 100644 mcp_modules/webhook_verify/docs/api.md create mode 100644 mcp_modules/webhook_verify/examples/basic-usage.js create mode 100644 mcp_modules/webhook_verify/index.js create mode 100644 mcp_modules/webhook_verify/package.json create mode 100644 mcp_modules/webhook_verify/src/controller.js create mode 100644 mcp_modules/webhook_verify/src/service.js create mode 100644 mcp_modules/webhook_verify/src/utils.js create mode 100644 mcp_modules/webhook_verify/test/service.test.js create mode 100644 mcp_modules/webhook_verify/test/setup.js create mode 100644 mcp_modules/webhook_verify/test/utils.test.js diff --git a/mcp_modules/webhook_verify/README.md b/mcp_modules/webhook_verify/README.md new file mode 100644 index 0000000..49756b3 --- /dev/null +++ b/mcp_modules/webhook_verify/README.md @@ -0,0 +1,85 @@ +# Webhook Verify Module + +Timing-safe, replay-resistant webhook signature verification for **Stripe, GitHub, Slack, Shopify, Twilio** and generic HMAC. + +Zero dependencies — `node:crypto` only. + +## Why + +Webhook verification is the piece of security code almost every backend rewrites, and it fails in four predictable ways: + +| Mistake | Consequence | +|---|---| +| `signature === expected` | Byte-by-byte comparison leaks the position of the first wrong byte through timing | +| No timestamp check | A captured valid request stays valid forever; replay it tomorrow and it passes | +| Verifying the parsed body | Re-serialising shifts key order or whitespace, the HMAC stops matching, and people "fix" it by disabling verification | +| Returning bare `false` | A caller writes `if (verify(...))` and a truthy value slips through | + +This module does the opposite of each: `timingSafeEqual` with an explicit length check first, replay windows on every provider that signs a timestamp (symmetric, so future-dated timestamps are rejected too), verification against the **raw** body, and a structured result carrying a specific `reason`. + +## Endpoints + +| Method | Path | Description | +|---|---|---| +| GET | `/webhook-verify` | Module information | +| GET | `/webhook-verify/providers` | Supported providers and header notes | +| POST | `/webhook-verify/verify` | Verify a signature | +| GET | `/tools/webhook_verify/info` | MCP tool schema | +| POST | `/tools/webhook_verify` | MCP tool endpoint | + +## Usage + +```bash +curl -X POST http://localhost:3000/tools/webhook_verify \ + -H 'Content-Type: application/json' \ + -d '{ + "provider": "github", + "secret": "your-webhook-secret", + "body": "{\"action\":\"opened\"}", + "signature": "sha256=..." + }' +``` + +Success: + +```json +{ "tool": "webhook_verify", "provider": "github", "result": { "valid": true, "reason": "ok" } } +``` + +Failure — always with a specific reason, never a bare `false`: + +```json +{ + "result": { + "valid": false, + "reason": "timestamp_out_of_range", + "detail": "timestamp 1699913600 is outside the +/-300s window", + "advice": "Do not parse or act on this payload." + } +} +``` + +Reasons: `ok`, `signature_mismatch`, `timestamp_out_of_range`, `malformed_signature`, `unsupported_provider`. + +## Per-provider input + +| provider | required | notes | +|---|---|---| +| `stripe` | `secret`, `body`, `signature` | `Stripe-Signature` (`t=..,v1=..`). Replay-protected. Multiple `v1` values accepted during secret rotation. | +| `github` | `secret`, `body`, `signature` | `X-Hub-Signature-256` (`sha256=..`) | +| `slack` | `secret`, `body`, `timestamp`, `signature` | `X-Slack-Signature` (`v0=..`) + `X-Slack-Request-Timestamp`. Replay-protected. | +| `shopify` | `secret`, `body`, `signature` | `X-Shopify-Hmac-Sha256` (base64) | +| `twilio` | `secret`, `url`, `params`, `signature` | HMAC-SHA1 over URL + sorted params | +| `hmac` | `secret`, `body`, `signature` | Generic hex HMAC (default sha256) | + +`toleranceSeconds` (default `300`) sets the replay window where applicable. + +**Pass the raw body.** Verifying a re-serialised object will fail for reasons that have nothing to do with the signature. + +## Tests + +```bash +pnpm test +``` + +29 tests: valid signatures for all providers, tampered bodies, wrong secrets, truncated signatures, replayed and future-dated timestamps, malformed headers (missing prefix, bad base64, non-hex, non-numeric timestamp), Stripe rotation, and secret redaction. diff --git a/mcp_modules/webhook_verify/docs/api.md b/mcp_modules/webhook_verify/docs/api.md new file mode 100644 index 0000000..1e6c5c8 --- /dev/null +++ b/mcp_modules/webhook_verify/docs/api.md @@ -0,0 +1,46 @@ +# webhook_verify API + +## POST /tools/webhook_verify + +Verify a webhook signature. + +### Request + +| field | type | required | description | +|---|---|---|---| +| `provider` | string | yes | `stripe` \| `github` \| `slack` \| `shopify` \| `twilio` \| `hmac` | +| `secret` | string | yes | Signing secret or auth token | +| `signature` | string | yes | Provider signature header value | +| `body` | string | for all but twilio | Raw request body, byte-identical | +| `timestamp` | string | slack only | `X-Slack-Request-Timestamp` | +| `url` | string | twilio only | Full request URL | +| `params` | object | twilio only | POST form parameters | +| `toleranceSeconds` | number | no | Replay window, default `300` | + +### Response + +```json +{ + "tool": "webhook_verify", + "provider": "stripe", + "result": { "valid": true, "reason": "ok" }, + "timestamp": "2026-01-01T00:00:00.000Z" +} +``` + +A failed verification returns HTTP 200 with `valid: false` — it is a valid answer, not a server error. +Missing or unsupported parameters return HTTP 400. + +### Reasons + +| reason | meaning | +|---|---| +| `ok` | Signature valid and within the replay window | +| `signature_mismatch` | Computed digest does not match | +| `timestamp_out_of_range` | Outside the replay window, in either direction | +| `malformed_signature` | Header absent or not in the documented shape | +| `unsupported_provider` | Unknown provider | + +## GET /webhook-verify/providers + +Returns the supported providers and the header each one uses. diff --git a/mcp_modules/webhook_verify/examples/basic-usage.js b/mcp_modules/webhook_verify/examples/basic-usage.js new file mode 100644 index 0000000..063e5ab --- /dev/null +++ b/mcp_modules/webhook_verify/examples/basic-usage.js @@ -0,0 +1,34 @@ +/** + * webhook_verify — basic usage + * + * Run: node examples/basic-usage.js + */ + +import { createHmac } from 'node:crypto'; +import { verify } from '../src/service.js'; + +const SECRET = 'whsec_example'; +const BODY = JSON.stringify({ action: 'opened', number: 42 }); + +// --- GitHub: a signature we generate ourselves, so it must verify --- +const githubSig = `sha256=${createHmac('sha256', SECRET).update(BODY).digest('hex')}`; +console.log('github, valid :', verify({ provider: 'github', secret: SECRET, body: BODY, signature: githubSig })); + +// --- GitHub: the same signature against a body that changed by one byte --- +console.log('github, tampered :', verify({ provider: 'github', secret: SECRET, body: `${BODY} `, signature: githubSig })); + +// --- Stripe: correctly signed but a day old, so replay protection rejects it --- +const stale = Math.floor(Date.now() / 1000) - 86400; +const staleSig = createHmac('sha256', SECRET).update(`${stale}.${BODY}`).digest('hex'); +console.log('stripe, replayed :', verify({ + provider: 'stripe', secret: SECRET, body: BODY, signature: `t=${stale},v1=${staleSig}`, +})); + +// --- Stripe: freshly signed, so it passes --- +const now = Math.floor(Date.now() / 1000); +const freshSig = createHmac('sha256', SECRET).update(`${now}.${BODY}`).digest('hex'); +console.log('stripe, fresh :', verify({ + provider: 'stripe', secret: SECRET, body: BODY, signature: `t=${now},v1=${freshSig}`, +})); + +// Never parse the payload before `valid` is true. diff --git a/mcp_modules/webhook_verify/index.js b/mcp_modules/webhook_verify/index.js new file mode 100644 index 0000000..a7c8467 --- /dev/null +++ b/mcp_modules/webhook_verify/index.js @@ -0,0 +1,132 @@ +/** + * Webhook Verify Module + * + * Timing-safe, replay-resistant webhook signature verification for Stripe, GitHub, + * Slack, Shopify, Twilio and generic HMAC. + */ + +import { logger } from '../../src/utils/logger.js'; +import { verifyWebhook, listProviders } from './src/controller.js'; +import { webhookVerifyService, SUPPORTED_PROVIDERS } from './src/service.js'; + +/** + * Register this module with the Hono app + * @param {import('hono').Hono} app - The Hono app instance + */ +export async function register(app) { + logger.info('Registering webhook_verify module'); + + app.get('/webhook-verify', (c) => { + return c.json({ + module: 'webhook_verify', + status: 'active', + message: 'Timing-safe webhook signature verification', + providers: SUPPORTED_PROVIDERS, + version: metadata.version, + }); + }); + + app.get('/webhook-verify/providers', listProviders); + app.post('/webhook-verify/verify', verifyWebhook); + + app.get('/tools/webhook_verify/info', (c) => { + return c.json({ + name: 'webhook_verify', + description: + 'Verify a webhook signature with a timing-safe comparison and replay protection. ' + + 'Call this before parsing or acting on any webhook payload.', + parameters: { + provider: { + type: 'string', + description: `Signature scheme. One of: ${SUPPORTED_PROVIDERS.join(', ')}`, + required: true, + }, + secret: { + type: 'string', + description: 'Signing secret or auth token for the provider', + required: true, + }, + signature: { + type: 'string', + description: 'The provider signature header value', + required: true, + }, + body: { + type: 'string', + description: 'The RAW request body, byte-identical to what was received', + required: false, + }, + timestamp: { + type: 'string', + description: 'Required for slack (X-Slack-Request-Timestamp)', + required: false, + }, + url: { type: 'string', description: 'Required for twilio: the full request URL', required: false }, + params: { type: 'object', description: 'Required for twilio: the POST form parameters', required: false }, + toleranceSeconds: { + type: 'number', + description: 'Replay window in seconds for providers that sign a timestamp (default 300)', + required: false, + }, + }, + }); + }); + + app.post('/tools/webhook_verify', async (c) => { + try { + const params = await c.req.json(); + + if (!params.provider) { + return c.json({ error: 'Missing required parameter: provider' }, 400); + } + if (!params.secret) { + return c.json({ error: 'Missing required parameter: secret' }, 400); + } + if (!params.signature) { + return c.json({ error: 'Missing required parameter: signature' }, 400); + } + + const result = webhookVerifyService.verify(params); + + return c.json({ + tool: 'webhook_verify', + provider: params.provider, + result, + timestamp: new Date().toISOString(), + }); + } catch (error) { + return c.json({ error: error.message }, 500); + } + }); + + app.get('/modules/webhook_verify', (c) => { + return c.json(metadata); + }); + + logger.info('Webhook verify module registered successfully'); +} + +/** + * Unregister this module (cleanup) + */ +export async function unregister() { + logger.info('Unregistering webhook_verify module'); +} + +/** + * Module metadata + */ +export const metadata = { + name: 'Webhook Verify Module', + version: '1.0.0', + description: + 'Timing-safe, replay-resistant webhook signature verification for Stripe, GitHub, Slack, Shopify, Twilio and generic HMAC', + author: 'profullstack community', + tools: ['webhook_verify'], + endpoints: [ + { path: '/webhook-verify', method: 'GET', description: 'Get module information' }, + { path: '/webhook-verify/providers', method: 'GET', description: 'List supported providers' }, + { path: '/webhook-verify/verify', method: 'POST', description: 'Verify a webhook signature' }, + { path: '/tools/webhook_verify', method: 'POST', description: 'Webhook verify tool endpoint' }, + ], +}; diff --git a/mcp_modules/webhook_verify/package.json b/mcp_modules/webhook_verify/package.json new file mode 100644 index 0000000..ce1efdb --- /dev/null +++ b/mcp_modules/webhook_verify/package.json @@ -0,0 +1,40 @@ +{ + "name": "mcp-module-webhook-verify", + "version": "1.0.0", + "description": "Timing-safe, replay-resistant webhook signature verification for Stripe, GitHub, Slack, Shopify, Twilio and generic HMAC", + "main": "index.js", + "type": "module", + "scripts": { + "test": "mocha test/**/*.test.js", + "test:watch": "mocha test/**/*.test.js --watch", + "lint": "eslint src/ test/ --fix", + "format": "prettier --write src/ test/ examples/" + }, + "keywords": [ + "mcp", + "module", + "webhook", + "signature", + "hmac", + "security", + "stripe", + "github", + "slack", + "twilio", + "shopify" + ], + "author": "profullstack community", + "license": "ISC", + "engines": { + "node": ">=20.0.0" + }, + "dependencies": {}, + "devDependencies": { + "chai": "^4.3.7", + "mocha": "^10.2.0", + "sinon": "^17.0.1", + "sinon-chai": "^4.0.0", + "eslint": "^8.57.0", + "prettier": "^3.0.0" + } +} diff --git a/mcp_modules/webhook_verify/src/controller.js b/mcp_modules/webhook_verify/src/controller.js new file mode 100644 index 0000000..0650979 --- /dev/null +++ b/mcp_modules/webhook_verify/src/controller.js @@ -0,0 +1,51 @@ +/** + * HTTP handlers for the webhook_verify module. + */ + +import { webhookVerifyService, SUPPORTED_PROVIDERS } from './service.js'; + +/** + * POST /webhook-verify/verify + */ +export async function verifyWebhook(c) { + try { + const params = await c.req.json(); + if (!params.provider) { + return c.json({ error: 'Missing required parameter: provider' }, 400); + } + if (!SUPPORTED_PROVIDERS.includes(params.provider)) { + return c.json( + { error: `Unsupported provider: ${params.provider}`, supported: SUPPORTED_PROVIDERS }, + 400 + ); + } + if (!params.secret) { + return c.json({ error: 'Missing required parameter: secret' }, 400); + } + if (!params.signature) { + return c.json({ error: 'Missing required parameter: signature' }, 400); + } + const result = webhookVerifyService.verify(params); + // A failed verification is a valid answer, not a server error. + return c.json({ ...result, provider: params.provider, timestamp: new Date().toISOString() }); + } catch (error) { + return c.json({ error: error.message }, 500); + } +} + +/** + * GET /webhook-verify/providers + */ +export function listProviders(c) { + return c.json({ + providers: SUPPORTED_PROVIDERS, + notes: { + stripe: 'Stripe-Signature header (t=..,v1=..). Replay-protected. Multiple v1 values supported during rotation.', + github: 'X-Hub-Signature-256 header (sha256=..).', + slack: 'X-Slack-Signature (v0=..) plus X-Slack-Request-Timestamp. Replay-protected.', + shopify: 'X-Shopify-Hmac-Sha256 header (base64).', + twilio: 'X-Twilio-Signature over the full URL plus sorted POST params (HMAC-SHA1).', + hmac: 'Generic hex HMAC over the raw body.', + }, + }); +} diff --git a/mcp_modules/webhook_verify/src/service.js b/mcp_modules/webhook_verify/src/service.js new file mode 100644 index 0000000..16788fa --- /dev/null +++ b/mcp_modules/webhook_verify/src/service.js @@ -0,0 +1,181 @@ +/** + * Webhook signature verification service. + * + * Design rules, each covered by a test: + * 1. Every digest comparison goes through `timingSafeEqual`, with an explicit length check + * first (timingSafeEqual throws on length mismatch and length itself is still a leak). + * 2. Providers that sign a timestamp get replay protection, symmetric in both directions so a + * future-dated timestamp is rejected too. + * 3. Failures return a specific `reason` rather than a bare false, so a caller cannot mistake a + * falsy value for a handled failure. + * 4. Nothing is parsed before the signature is verified. Decoding attacker-controlled JSON first + * is how a verification layer becomes an attack surface. + */ + +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export const DEFAULT_TOLERANCE_SECONDS = 300; + +export const REASONS = { + OK: 'ok', + MISMATCH: 'signature_mismatch', + STALE: 'timestamp_out_of_range', + MALFORMED: 'malformed_signature', + UNSUPPORTED: 'unsupported_provider', +}; + +const ok = () => ({ valid: true, reason: REASONS.OK }); +const fail = (reason, detail) => ({ + valid: false, + reason, + detail, + advice: 'Do not parse or act on this payload.', +}); + +function safeEqual(expected, actual) { + const a = Buffer.isBuffer(expected) ? expected : Buffer.from(expected); + const b = Buffer.isBuffer(actual) ? actual : Buffer.from(actual); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +function checkTimestamp(timestamp, toleranceSeconds, nowSeconds) { + if (typeof timestamp !== 'string' || !/^\d+$/.test(timestamp.trim())) { + return fail(REASONS.MALFORMED, `timestamp is not an integer: ${JSON.stringify(timestamp)}`); + } + const parsed = Number(timestamp.trim()); + if (!Number.isSafeInteger(parsed)) { + return fail(REASONS.MALFORMED, 'timestamp is not a safe integer'); + } + const now = typeof nowSeconds === 'number' ? nowSeconds : Date.now() / 1000; + if (Math.abs(now - parsed) > toleranceSeconds) { + return fail(REASONS.STALE, `timestamp ${parsed} is outside the +/-${toleranceSeconds}s window`); + } + return null; +} + +function decodeBase64(value) { + const trimmed = String(value).trim(); + const buf = Buffer.from(trimmed, 'base64'); + // Buffer.from is lenient; round-tripping catches non-base64 input. + if (buf.toString('base64').replace(/=+$/, '') !== trimmed.replace(/=+$/, '')) return null; + return buf; +} + +export function verifyStripe(secret, body, header, options = {}) { + const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS; + if (typeof header !== 'string' || !header) return fail(REASONS.MALFORMED, 'missing Stripe-Signature'); + + let timestamp = null; + const presented = []; + for (const chunk of header.split(',')) { + const idx = chunk.indexOf('='); + if (idx === -1) continue; + const key = chunk.slice(0, idx).trim(); + const value = chunk.slice(idx + 1).trim(); + if (key === 't') timestamp = value; + else if (key === 'v1') presented.push(value); + } + if (!timestamp || presented.length === 0) { + return fail(REASONS.MALFORMED, 'Stripe-Signature must contain t= and at least one v1='); + } + const stale = checkTimestamp(timestamp, tolerance, options.nowSeconds); + if (stale) return stale; + + const expected = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'); + // Stripe may send several v1 signatures while a secret is rotating; any match is valid. + for (const candidate of presented) { + if (safeEqual(expected, candidate)) return ok(); + } + return fail(REASONS.MISMATCH, 'no v1 signature matched'); +} + +export function verifyGithub(secret, body, header) { + const prefix = 'sha256='; + if (typeof header !== 'string' || !header.startsWith(prefix)) { + return fail(REASONS.MALFORMED, "X-Hub-Signature-256 must start with 'sha256='"); + } + const expected = createHmac('sha256', secret).update(body).digest('hex'); + return safeEqual(expected, header.slice(prefix.length).trim()) + ? ok() + : fail(REASONS.MISMATCH, 'signature does not match'); +} + +export function verifySlack(signingSecret, body, timestamp, signature, options = {}) { + const prefix = 'v0='; + if (typeof signature !== 'string' || !signature.startsWith(prefix)) { + return fail(REASONS.MALFORMED, "X-Slack-Signature must start with 'v0='"); + } + const stale = checkTimestamp(timestamp, options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS, options.nowSeconds); + if (stale) return stale; + + const expected = createHmac('sha256', signingSecret).update(`v0:${timestamp}:${body}`).digest('hex'); + return safeEqual(expected, signature.slice(prefix.length).trim()) + ? ok() + : fail(REASONS.MISMATCH, 'signature does not match'); +} + +export function verifyShopify(secret, body, header) { + if (typeof header !== 'string' || !header) { + return fail(REASONS.MALFORMED, 'missing X-Shopify-Hmac-Sha256'); + } + const presented = decodeBase64(header); + if (!presented) return fail(REASONS.MALFORMED, 'signature is not valid base64'); + const expected = createHmac('sha256', secret).update(body).digest(); + return safeEqual(expected, presented) ? ok() : fail(REASONS.MISMATCH, 'signature does not match'); +} + +export function verifyTwilio(authToken, url, params, signature) { + if (typeof signature !== 'string' || !signature) { + return fail(REASONS.MALFORMED, 'missing X-Twilio-Signature'); + } + const presented = decodeBase64(signature); + if (!presented) return fail(REASONS.MALFORMED, 'signature is not valid base64'); + let payload = String(url ?? ''); + for (const key of Object.keys(params ?? {}).sort()) payload += `${key}${params[key]}`; + const expected = createHmac('sha1', authToken).update(payload).digest(); + return safeEqual(expected, presented) ? ok() : fail(REASONS.MISMATCH, 'signature does not match'); +} + +export function verifyHmac(secret, body, signatureHex, algorithm = 'sha256') { + if (typeof signatureHex !== 'string' || !signatureHex.trim()) { + return fail(REASONS.MALFORMED, 'empty signature'); + } + if (!/^[0-9a-fA-F]+$/.test(signatureHex.trim())) { + return fail(REASONS.MALFORMED, 'signature is not valid hex'); + } + const expected = createHmac(algorithm, secret).update(body).digest('hex'); + return safeEqual(expected, signatureHex.trim().toLowerCase()) + ? ok() + : fail(REASONS.MISMATCH, 'signature does not match'); +} + +export const SUPPORTED_PROVIDERS = ['stripe', 'github', 'slack', 'shopify', 'twilio', 'hmac']; + +/** + * Provider-agnostic entry point. + * @param {object} input + * @returns {{valid: boolean, reason: string, detail?: string, advice?: string}} + */ +export function verify(input = {}) { + const { provider, secret, body = '', signature, timestamp, url, params, toleranceSeconds, nowSeconds } = input; + if (!SUPPORTED_PROVIDERS.includes(provider)) { + return fail(REASONS.UNSUPPORTED, `unsupported provider: ${provider}`); + } + if (typeof secret !== 'string' || !secret) return fail(REASONS.MALFORMED, 'missing secret'); + const opts = { toleranceSeconds, nowSeconds }; + switch (provider) { + case 'stripe': return verifyStripe(secret, body, signature, opts); + case 'github': return verifyGithub(secret, body, signature); + case 'slack': return verifySlack(secret, body, timestamp, signature, opts); + case 'shopify': return verifyShopify(secret, body, signature); + case 'twilio': return verifyTwilio(secret, url, params, signature); + case 'hmac': return verifyHmac(secret, body, signature); + default: return fail(REASONS.UNSUPPORTED, `unsupported provider: ${provider}`); + } +} + +export const webhookVerifyService = { + verify, verifyStripe, verifyGithub, verifySlack, verifyShopify, verifyTwilio, verifyHmac, + SUPPORTED_PROVIDERS, REASONS, DEFAULT_TOLERANCE_SECONDS, +}; diff --git a/mcp_modules/webhook_verify/src/utils.js b/mcp_modules/webhook_verify/src/utils.js new file mode 100644 index 0000000..011b498 --- /dev/null +++ b/mcp_modules/webhook_verify/src/utils.js @@ -0,0 +1,46 @@ +/** + * Helpers for the webhook_verify module. + */ + +/** + * Pull the signature material out of a raw header bag for a given provider. + * Header names are matched case-insensitively, since Node lowercases incoming headers + * but callers often paste them as documented by the provider. + * + * @param {string} provider + * @param {Record} headers + * @returns {{signature?: string, timestamp?: string}} + */ +export function extractFromHeaders(provider, headers = {}) { + const lower = {}; + for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v; + + switch (provider) { + case 'stripe': + return { signature: lower['stripe-signature'] }; + case 'github': + return { signature: lower['x-hub-signature-256'] }; + case 'slack': + return { + signature: lower['x-slack-signature'], + timestamp: lower['x-slack-request-timestamp'], + }; + case 'shopify': + return { signature: lower['x-shopify-hmac-sha256'] }; + case 'twilio': + return { signature: lower['x-twilio-signature'] }; + default: + return {}; + } +} + +/** + * Redact a secret for logging. Never log the secret itself. + * @param {string} secret + * @returns {string} + */ +export function redact(secret) { + if (typeof secret !== 'string' || secret.length === 0) return '(empty)'; + if (secret.length <= 8) return '***'; + return `${secret.slice(0, 3)}***${secret.slice(-2)}`; +} diff --git a/mcp_modules/webhook_verify/test/service.test.js b/mcp_modules/webhook_verify/test/service.test.js new file mode 100644 index 0000000..c1e4831 --- /dev/null +++ b/mcp_modules/webhook_verify/test/service.test.js @@ -0,0 +1,133 @@ +import { expect } from 'chai'; +import { createHmac } from 'node:crypto'; +import { + verify, verifyStripe, verifyGithub, verifySlack, verifyShopify, verifyTwilio, verifyHmac, REASONS, +} from '../src/service.js'; + +const SECRET = 'whsec_test_secret'; +const BODY = '{"id":"evt_1","type":"payment_intent.succeeded"}'; +const NOW = 1700000000; + +const stripeHeader = (ts = NOW, secret = SECRET, body = BODY) => + `t=${ts},v1=${createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex')}`; + +describe('stripe', () => { + it('accepts a valid signature', () => { + expect(verifyStripe(SECRET, BODY, stripeHeader(), { nowSeconds: NOW }).valid).to.be.true; + }); + it('rejects a tampered body', () => { + const r = verifyStripe(SECRET, `${BODY} `, stripeHeader(), { nowSeconds: NOW }); + expect(r.valid).to.be.false; + expect(r.reason).to.equal(REASONS.MISMATCH); + }); + it('rejects a replayed signature', () => { + const r = verifyStripe(SECRET, BODY, stripeHeader(NOW - 86400), { nowSeconds: NOW }); + expect(r.reason).to.equal(REASONS.STALE); + }); + it('rejects a future-dated timestamp', () => { + const r = verifyStripe(SECRET, BODY, stripeHeader(NOW + 86400), { nowSeconds: NOW }); + expect(r.reason).to.equal(REASONS.STALE); + }); + it('accepts any matching v1 during secret rotation', () => { + const older = createHmac('sha256', 'old').update(`${NOW}.${BODY}`).digest('hex'); + const current = createHmac('sha256', SECRET).update(`${NOW}.${BODY}`).digest('hex'); + const r = verifyStripe(SECRET, BODY, `t=${NOW},v1=${older},v1=${current}`, { nowSeconds: NOW }); + expect(r.valid).to.be.true; + }); + it('rejects a header without a timestamp', () => { + expect(verifyStripe(SECRET, BODY, 'v1=deadbeef', { nowSeconds: NOW }).reason) + .to.equal(REASONS.MALFORMED); + }); + it('rejects a non-numeric timestamp', () => { + expect(verifyStripe(SECRET, BODY, 't=abc,v1=deadbeef', { nowSeconds: NOW }).reason) + .to.equal(REASONS.MALFORMED); + }); +}); + +describe('github', () => { + const header = (secret = SECRET, body = BODY) => + `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; + + it('accepts a valid signature', () => { + expect(verifyGithub(SECRET, BODY, header()).valid).to.be.true; + }); + it('rejects a wrong secret', () => { + expect(verifyGithub('other', BODY, header()).reason).to.equal(REASONS.MISMATCH); + }); + it('rejects a missing sha256= prefix', () => { + const bare = createHmac('sha256', SECRET).update(BODY).digest('hex'); + expect(verifyGithub(SECRET, BODY, bare).reason).to.equal(REASONS.MALFORMED); + }); + it('rejects a truncated signature without throwing', () => { + expect(verifyGithub(SECRET, BODY, header().slice(0, 20)).reason).to.equal(REASONS.MISMATCH); + }); +}); + +describe('slack', () => { + it('accepts a valid signature', () => { + const sig = `v0=${createHmac('sha256', SECRET).update(`v0:${NOW}:${BODY}`).digest('hex')}`; + expect(verifySlack(SECRET, BODY, String(NOW), sig, { nowSeconds: NOW }).valid).to.be.true; + }); + it('rejects an old request', () => { + const ts = NOW - 9999; + const sig = `v0=${createHmac('sha256', SECRET).update(`v0:${ts}:${BODY}`).digest('hex')}`; + expect(verifySlack(SECRET, BODY, String(ts), sig, { nowSeconds: NOW }).reason) + .to.equal(REASONS.STALE); + }); +}); + +describe('shopify', () => { + it('accepts a valid signature', () => { + const digest = createHmac('sha256', SECRET).update(BODY).digest('base64'); + expect(verifyShopify(SECRET, BODY, digest).valid).to.be.true; + }); + it('rejects non-base64 input', () => { + expect(verifyShopify(SECRET, BODY, '!!!not base64!!!').reason).to.equal(REASONS.MALFORMED); + }); +}); + +describe('twilio', () => { + const url = 'https://example.com/hook'; + const params = { To: '+15550000', From: '+15551111', Body: 'hi' }; + + it('accepts a valid signature', () => { + const payload = url + Object.keys(params).sort().map((k) => `${k}${params[k]}`).join(''); + const sig = createHmac('sha1', 'token').update(payload).digest('base64'); + expect(verifyTwilio('token', url, params, sig).valid).to.be.true; + }); + it('rejects a stale signature after params change', () => { + const sig = createHmac('sha1', 'token').update(`${url}ToX`).digest('base64'); + expect(verifyTwilio('token', url, { To: 'Y' }, sig).reason).to.equal(REASONS.MISMATCH); + }); +}); + +describe('generic hmac', () => { + it('accepts a matching hex digest', () => { + const sig = createHmac('sha256', SECRET).update(BODY).digest('hex'); + expect(verifyHmac(SECRET, BODY, sig).valid).to.be.true; + }); + it('rejects a non-hex signature', () => { + expect(verifyHmac(SECRET, BODY, 'zzzz').reason).to.equal(REASONS.MALFORMED); + }); +}); + +describe('verify() dispatcher', () => { + it('rejects an unsupported provider', () => { + expect(verify({ provider: 'nope', secret: 's', signature: 'x' }).reason) + .to.equal(REASONS.UNSUPPORTED); + }); + it('rejects a missing secret', () => { + expect(verify({ provider: 'github', signature: 'sha256=aa' }).reason) + .to.equal(REASONS.MALFORMED); + }); + it('always carries advice when it fails', () => { + const r = verify({ provider: 'github', secret: 's', body: 'x', signature: `sha256=${'0'.repeat(64)}` }); + expect(r.valid).to.be.false; + expect(r.advice).to.contain('Do not parse'); + }); + it('never returns a bare boolean', () => { + const r = verify({ provider: 'hmac', secret: 's', body: 'x', signature: 'aa' }); + expect(r).to.be.an('object'); + expect(r).to.have.property('reason'); + }); +}); diff --git a/mcp_modules/webhook_verify/test/setup.js b/mcp_modules/webhook_verify/test/setup.js new file mode 100644 index 0000000..3b66fc6 --- /dev/null +++ b/mcp_modules/webhook_verify/test/setup.js @@ -0,0 +1,81 @@ +/** + * Test Setup + * Global test configuration and setup for Mocha tests + */ + +import chai from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; + +// Configure Chai +chai.use(sinonChai); + +// Global test configuration +global.expect = chai.expect; +global.sinon = sinon; + +// Set up global test hooks +beforeEach(() => { + // Create a sandbox for each test + global.sandbox = sinon.createSandbox(); +}); + +afterEach(() => { + // Clean up after each test + if (global.sandbox) { + global.sandbox.restore(); + } +}); + +// Global test utilities +global.createMockContext = () => { + return { + req: { + json: sinon.stub(), + }, + json: sinon.stub(), + set: sinon.stub(), + }; +}; + +global.createMockService = () => { + return { + getAllItems: sinon.stub(), + getItemById: sinon.stub(), + createItem: sinon.stub(), + updateItem: sinon.stub(), + deleteItem: sinon.stub(), + processItem: sinon.stub(), + }; +}; + +// Test timeout configuration +const originalTimeout = 5000; +if (process.env.NODE_ENV === 'test') { + // Increase timeout for CI environments + global.testTimeout = process.env.CI ? 10000 : originalTimeout; +} else { + global.testTimeout = originalTimeout; +} + +// Console override for cleaner test output +const originalConsole = console; +global.testConsole = { + log: process.env.TEST_VERBOSE ? originalConsole.log : () => {}, + error: process.env.TEST_VERBOSE ? originalConsole.error : () => {}, + warn: process.env.TEST_VERBOSE ? originalConsole.warn : () => {}, + info: process.env.TEST_VERBOSE ? originalConsole.info : () => {}, +}; + +// Override console during tests unless verbose mode is enabled +if (!process.env.TEST_VERBOSE) { + console.log = global.testConsole.log; + console.error = global.testConsole.error; + console.warn = global.testConsole.warn; + console.info = global.testConsole.info; +} + +// Test environment setup +process.env.NODE_ENV = 'test'; + +console.log('Test environment initialized'); diff --git a/mcp_modules/webhook_verify/test/utils.test.js b/mcp_modules/webhook_verify/test/utils.test.js new file mode 100644 index 0000000..3753af5 --- /dev/null +++ b/mcp_modules/webhook_verify/test/utils.test.js @@ -0,0 +1,36 @@ +import { expect } from 'chai'; +import { extractFromHeaders, redact } from '../src/utils.js'; + +describe('extractFromHeaders', () => { + it('is case-insensitive', () => { + expect(extractFromHeaders('github', { 'X-Hub-Signature-256': 'sha256=aa' }).signature) + .to.equal('sha256=aa'); + expect(extractFromHeaders('github', { 'x-hub-signature-256': 'sha256=aa' }).signature) + .to.equal('sha256=aa'); + }); + it('returns both signature and timestamp for slack', () => { + const out = extractFromHeaders('slack', { + 'X-Slack-Signature': 'v0=aa', + 'X-Slack-Request-Timestamp': '1700000000', + }); + expect(out.signature).to.equal('v0=aa'); + expect(out.timestamp).to.equal('1700000000'); + }); + it('returns an empty object for an unknown provider', () => { + expect(extractFromHeaders('nope', { a: 'b' })).to.deep.equal({}); + }); +}); + +describe('redact', () => { + it('never reveals the middle of a secret', () => { + const out = redact('whsec_supersecretvalue'); + expect(out).to.not.contain('supersecret'); + expect(out).to.contain('***'); + }); + it('fully masks short secrets', () => { + expect(redact('short')).to.equal('***'); + }); + it('handles empty input', () => { + expect(redact('')).to.equal('(empty)'); + }); +});