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:
Garfield
2026-06-10 21:30:16 -04:00
parent 5effb41af4
commit e5152eef12
3 changed files with 515 additions and 12 deletions

View File

@@ -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;