feat(webhook): Twilio WhatsApp inbound route + Meta webhook hardening
Add POST /webhook/twilio/whatsapp for the pilot approval loop — Alex replies 1/2/3 to the Twilio number to approve post drafts. Includes HMAC-SHA1 signature validation, Redis dedup (wa_msg_seen:MessageSid), pilot_owner_phone allowlist, staleness check (7d), tracking link creation, and draft status update. Also fix two security bugs in the existing Meta webhook handler: fail-open when WHATSAPP_APP_SECRET unset (now 503), and missing length guard before timingSafeEqual (was RangeError → 500, now 403). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
34
src/index.ts
34
src/index.ts
@@ -15,6 +15,7 @@ import {
|
||||
import { tools, handleToolCall, stripAccountParam } from './tools.js';
|
||||
import { getManifest, getOpenApiSpec, getOpenApiSpecMail, getOpenApiSpecSocial, getOpenApiSpecChatGPT } from './manifest.js';
|
||||
import { routeWhatsAppWebhook, registerWhatsAppNumber, type RoutedWebhookEvent } from './multitenancy/webhook-router.js';
|
||||
import { handleTwilioWhatsApp, initTwilioWebhookMapping } from './webhooks/twilio-whatsapp.js';
|
||||
import { storeCredential, type Platform } from './multitenancy/credential-store.js';
|
||||
import { meterMiddleware, resolveCustomerByApiKey, resolveCustomerById, type Customer } from './billing/middleware.js';
|
||||
import {
|
||||
@@ -1347,18 +1348,23 @@ app.get('/webhook/whatsapp', (req, res) => {
|
||||
const WHATSAPP_APP_SECRET = process.env.WHATSAPP_APP_SECRET ?? '';
|
||||
|
||||
app.post('/webhook/whatsapp', express.raw({ type: '*/*' }), async (req, res) => {
|
||||
// Verify Meta HMAC signature when app secret is configured
|
||||
if (WHATSAPP_APP_SECRET) {
|
||||
const sig = req.headers['x-hub-signature-256'] as string | undefined;
|
||||
if (!sig) {
|
||||
res.status(403).send('Missing signature');
|
||||
return;
|
||||
}
|
||||
const expected = 'sha256=' + crypto.createHmac('sha256', WHATSAPP_APP_SECRET).update(req.body as Buffer).digest('hex');
|
||||
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
|
||||
res.status(403).send('Invalid signature');
|
||||
return;
|
||||
}
|
||||
// Fail-closed: refuse to process unsigned requests if app secret is not configured
|
||||
if (!WHATSAPP_APP_SECRET) {
|
||||
console.error('[webhook/whatsapp] WHATSAPP_APP_SECRET not set — refusing to process');
|
||||
res.status(503).send('Webhook validation not configured');
|
||||
return;
|
||||
}
|
||||
const sig = req.headers['x-hub-signature-256'] as string | undefined;
|
||||
if (!sig) {
|
||||
res.status(403).send('Missing signature');
|
||||
return;
|
||||
}
|
||||
const expected = 'sha256=' + crypto.createHmac('sha256', WHATSAPP_APP_SECRET).update(req.body as Buffer).digest('hex');
|
||||
const sigBuf = Buffer.from(sig);
|
||||
const expectedBuf = Buffer.from(expected);
|
||||
if (sigBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(sigBuf, expectedBuf)) {
|
||||
res.status(403).send('Invalid signature');
|
||||
return;
|
||||
}
|
||||
|
||||
// Always acknowledge immediately to prevent Meta retries (20s window)
|
||||
@@ -1383,6 +1389,9 @@ app.post('/webhook/twilio/voice', (_req, res) => {
|
||||
);
|
||||
});
|
||||
|
||||
// ── Twilio WhatsApp inbound — pilot approval loop ──
|
||||
app.post('/webhook/twilio/whatsapp', handleTwilioWhatsApp);
|
||||
|
||||
// ── Auth endpoints ──────────────────────────────────────────────
|
||||
|
||||
app.post('/api/auth/signup', express.json(), async (req, res) => {
|
||||
@@ -2301,6 +2310,7 @@ app.get('/health', (_req, res) => {
|
||||
|
||||
async function main() {
|
||||
await initDatabase();
|
||||
await initTwilioWebhookMapping();
|
||||
|
||||
// Ensure the pre-registered SquareMCP OAuth app exists for the browser DCR flow
|
||||
const oauthClientId = process.env.OAUTH_CLIENT_ID;
|
||||
|
||||
Reference in New Issue
Block a user