Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions mcp_modules/webhook_verify/README.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions mcp_modules/webhook_verify/docs/api.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions mcp_modules/webhook_verify/examples/basic-usage.js
Original file line number Diff line number Diff line change
@@ -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.
132 changes: 132 additions & 0 deletions mcp_modules/webhook_verify/index.js
Original file line number Diff line number Diff line change
@@ -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' },
],
};
40 changes: 40 additions & 0 deletions mcp_modules/webhook_verify/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
51 changes: 51 additions & 0 deletions mcp_modules/webhook_verify/src/controller.js
Original file line number Diff line number Diff line change
@@ -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.',
},
});
}
Loading
Loading