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;

View File

@@ -0,0 +1,294 @@
import crypto from 'crypto';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import {
validateTwilioSignature,
handleTwilioWhatsApp,
handleApproval,
} from './twilio-whatsapp.js';
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock('../redis.js', () => ({
default: {
set: vi.fn(),
get: vi.fn(),
},
}));
vi.mock('../db.js', () => ({
getPool: vi.fn(),
}));
vi.mock('../tracking-links.js', () => ({
createTrackingLink: vi.fn(),
}));
vi.mock('../clients/whatsapp.js', () => ({
sendMessage: vi.fn().mockResolvedValue({ success: true, message_id: 'SM_reply' }),
}));
import redis from '../redis.js';
import { getPool } from '../db.js';
import { createTrackingLink } from '../tracking-links.js';
import { sendMessage } from '../clients/whatsapp.js';
// ── Test helpers ─────────────────────────────────────────────────────────────
const AUTH_TOKEN = 'test_auth_token_abc123';
const WEBHOOK_URL = 'https://hermes.squaremcp.com/webhook/twilio/whatsapp';
const CUSTOMER_ID = 'cust_lodge_brothers';
const PILOT_PHONE = '+15551234567';
const MSG_SID = 'SM_abc123';
const DEFAULT_PARAMS: Record<string, string> = {
Body: '1',
From: `whatsapp:${PILOT_PHONE}`,
MessageSid: MSG_SID,
NumMedia: '0',
To: 'whatsapp:+19547385805',
};
function makeSignature(url: string, params: Record<string, string>): string {
const sortedParams = Object.keys(params).sort().map(k => k + params[k]).join('');
return crypto.createHmac('sha1', AUTH_TOKEN).update(url + sortedParams).digest('base64');
}
function makeReq(overrides: {
body?: Record<string, string>;
headers?: Record<string, string>;
hostname?: string;
} = {}): Request {
const body = overrides.body ?? { ...DEFAULT_PARAMS };
const sig = makeSignature(WEBHOOK_URL, body);
const defaultHeaders = { 'x-twilio-signature': sig };
const headers = overrides.headers !== undefined ? overrides.headers : defaultHeaders;
return {
headers,
body,
hostname: 'hermes.squaremcp.com',
} as unknown as Request;
}
function makeRes() {
const res = {
status: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis(),
type: vi.fn().mockReturnThis(),
};
return res as unknown as Response & { status: ReturnType<typeof vi.fn>; send: ReturnType<typeof vi.fn>; type: ReturnType<typeof vi.fn> };
}
function mockPool(customerRows: object[], draftRows: object[]) {
const mockQuery = vi.fn()
.mockResolvedValueOnce([customerRows])
.mockResolvedValueOnce([draftRows])
.mockResolvedValue([[]]);
(getPool as ReturnType<typeof vi.fn>).mockReturnValue({ query: mockQuery });
return mockQuery;
}
function makeDraft(overrides: Partial<{
id: number;
draft_text_wa: string;
destination_url: string | null;
created_at: Date;
}> = {}) {
return {
id: 1,
draft_text_wa: 'Check out our mortgage rates this week!',
destination_url: 'https://calendly.com/alex/30min',
created_at: new Date(),
...overrides,
};
}
// ── validateTwilioSignature ───────────────────────────────────────────────────
describe('validateTwilioSignature', () => {
it('returns true for a valid signature', () => {
const params = { ...DEFAULT_PARAMS };
const sig = makeSignature(WEBHOOK_URL, params);
expect(validateTwilioSignature(AUTH_TOKEN, WEBHOOK_URL, params, sig)).toBe(true);
});
it('returns false for an invalid signature', () => {
const params = { ...DEFAULT_PARAMS };
const sig = makeSignature(WEBHOOK_URL, params);
expect(validateTwilioSignature(AUTH_TOKEN, WEBHOOK_URL, params, sig + 'x')).toBe(false);
});
it('returns false for a wrong auth token', () => {
const params = { ...DEFAULT_PARAMS };
const sig = makeSignature(WEBHOOK_URL, params);
expect(validateTwilioSignature('wrong_token', WEBHOOK_URL, params, sig)).toBe(false);
});
it('returns false (not throws) when signature is very short — length guard', () => {
const params = { ...DEFAULT_PARAMS };
expect(validateTwilioSignature(AUTH_TOKEN, WEBHOOK_URL, params, 'short')).toBe(false);
});
it('returns false for empty signature string', () => {
expect(validateTwilioSignature(AUTH_TOKEN, WEBHOOK_URL, DEFAULT_PARAMS, '')).toBe(false);
});
});
// ── handleTwilioWhatsApp (route handler) ────────────────────────────────────
describe('handleTwilioWhatsApp', () => {
beforeEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
process.env.TWILIO_AUTH_TOKEN = AUTH_TOKEN;
process.env.PUBLIC_WEBHOOK_BASE_URL = 'https://hermes.squaremcp.com';
(redis.get as ReturnType<typeof vi.fn>).mockResolvedValue(CUSTOMER_ID);
(redis.set as ReturnType<typeof vi.fn>).mockResolvedValue('OK');
(getPool as ReturnType<typeof vi.fn>).mockReturnValue({ query: vi.fn().mockResolvedValue([[]]) });
});
it('returns 503 when TWILIO_AUTH_TOKEN is not set', async () => {
delete process.env.TWILIO_AUTH_TOKEN;
const req = makeReq();
const res = makeRes();
await handleTwilioWhatsApp(req, res as unknown as Response);
expect(res.status).toHaveBeenCalledWith(503);
});
it('returns 403 when X-Twilio-Signature header is missing', async () => {
const req = makeReq({ headers: {} });
const res = makeRes();
await handleTwilioWhatsApp(req, res as unknown as Response);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Missing signature');
});
it('returns 403 for an invalid signature', async () => {
const req = makeReq({ headers: { 'x-twilio-signature': 'bad_signature_value' } });
const res = makeRes();
await handleTwilioWhatsApp(req, res as unknown as Response);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Invalid signature');
});
it('returns 403 (not 500) for a malformed short signature', async () => {
const req = makeReq({ headers: { 'x-twilio-signature': 'x' } });
const res = makeRes();
await handleTwilioWhatsApp(req, res as unknown as Response);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Invalid signature');
});
it('returns 200 with TwiML for a valid request', async () => {
const req = makeReq();
const res = makeRes();
await handleTwilioWhatsApp(req, res as unknown as Response);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(expect.stringContaining('<Response>'));
expect(res.type).toHaveBeenCalledWith('text/xml');
});
it('uses PUBLIC_WEBHOOK_BASE_URL for signature URL construction', async () => {
// The default req uses WEBHOOK_URL derived from PUBLIC_WEBHOOK_BASE_URL
// If PUBLIC_WEBHOOK_BASE_URL is correct, the valid signature validates
const req = makeReq();
const res = makeRes();
await handleTwilioWhatsApp(req, res as unknown as Response);
expect(res.status).toHaveBeenCalledWith(200);
});
});
// ── handleApproval ──────────────────────────────────────────────────────────
describe('handleApproval', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.PUBLIC_WEBHOOK_BASE_URL = 'https://hermes.squaremcp.com';
(redis.set as ReturnType<typeof vi.fn>).mockResolvedValue('OK');
});
it('skips processing for a duplicate MessageSid', async () => {
(redis.set as ReturnType<typeof vi.fn>).mockResolvedValue(null); // NX returns null when key exists
(getPool as ReturnType<typeof vi.fn>).mockReturnValue({ query: vi.fn() });
await handleApproval(CUSTOMER_ID, PILOT_PHONE, MSG_SID, '1', 0);
expect(getPool().query).not.toHaveBeenCalled();
});
it('ignores messages from senders not matching pilot_owner_phone', async () => {
mockPool([{ pilot_owner_phone: '+19999999999' }], []);
await handleApproval(CUSTOMER_ID, PILOT_PHONE, MSG_SID, '1', 0);
expect(sendMessage).not.toHaveBeenCalled();
});
it('replies asking to text when NumMedia > 0', async () => {
mockPool([{ pilot_owner_phone: PILOT_PHONE }], []);
await handleApproval(CUSTOMER_ID, PILOT_PHONE, MSG_SID, '', 1);
expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({
to: PILOT_PHONE,
message: expect.stringContaining('1, 2, or 3'),
}));
});
it('approves the correct draft, creates tracking link, and sends confirmation', async () => {
const draft = makeDraft({ id: 42, destination_url: 'https://calendly.com/alex' });
(createTrackingLink as ReturnType<typeof vi.fn>).mockResolvedValue('tok_abc123456789012345678901');
const mockQuery = vi.fn()
.mockResolvedValueOnce([[{ pilot_owner_phone: PILOT_PHONE }]])
.mockResolvedValueOnce([[draft]])
.mockResolvedValue([[]]); // UPDATE
(getPool as ReturnType<typeof vi.fn>).mockReturnValue({ query: mockQuery });
await handleApproval(CUSTOMER_ID, PILOT_PHONE, MSG_SID, '1', 0);
expect(createTrackingLink).toHaveBeenCalledWith(expect.objectContaining({
customerId: CUSTOMER_ID,
draftId: 42,
destinationUrl: 'https://calendly.com/alex',
expiresAt: null,
}));
expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({
to: PILOT_PHONE,
message: expect.stringContaining('Track clicks'),
}));
// Verify draft was marked approved
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'approved'"),
[42]
);
});
it('replies with expiry message for drafts older than 7 days', async () => {
const oldDate = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
const draft = makeDraft({ created_at: oldDate });
mockPool([{ pilot_owner_phone: PILOT_PHONE }], [draft]);
await handleApproval(CUSTOMER_ID, PILOT_PHONE, MSG_SID, '1', 0);
expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('expired'),
}));
expect(createTrackingLink).not.toHaveBeenCalled();
});
it('replies with help listing pending drafts for unrecognized text', async () => {
const draft = makeDraft({ draft_text_wa: 'Big sale on mortgage rates this week!' });
mockPool([{ pilot_owner_phone: PILOT_PHONE }], [draft]);
await handleApproval(CUSTOMER_ID, PILOT_PHONE, MSG_SID, 'hello', 0);
expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('1, 2, or 3'),
}));
expect(createTrackingLink).not.toHaveBeenCalled();
});
it('sends a no-drafts message when there are no pending drafts', async () => {
mockPool([{ pilot_owner_phone: PILOT_PHONE }], []);
await handleApproval(CUSTOMER_ID, PILOT_PHONE, MSG_SID, '1', 0);
expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('No pending drafts'),
}));
});
});

View File

@@ -0,0 +1,199 @@
import crypto from 'crypto';
import type { Request, Response } from 'express';
import redis from '../redis.js';
import { getPool } from '../db.js';
import { createTrackingLink } from '../tracking-links.js';
import { sendMessage } from '../clients/whatsapp.js';
import type { RowDataPacket } from 'mysql2/promise';
const TWIML_ACK = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
export async function initTwilioWebhookMapping(): Promise<void> {
const twilioNumber = (process.env.TWILIO_WHATSAPP_NUMBER ?? '').replace(/^whatsapp:/, '');
const pilotCustomerId = process.env.PILOT_CUSTOMER_ID ?? '';
if (!twilioNumber || !pilotCustomerId) {
console.warn('[twilio-webhook] TWILIO_WHATSAPP_NUMBER or PILOT_CUSTOMER_ID unset — customer mapping not seeded');
return;
}
try {
await redis.set(`twilio_wa_number:${twilioNumber}`, pilotCustomerId);
console.log(`[twilio-webhook] mapped ${twilioNumber}${pilotCustomerId}`);
} catch (err) {
console.error('[twilio-webhook] Redis unavailable at startup — customer mapping not seeded; inbound messages will be dropped until next restart:', err);
}
}
export function validateTwilioSignature(
authToken: string,
url: string,
params: Record<string, string>,
sig: string
): boolean {
const sortedParams = Object.keys(params).sort().map(k => k + params[k]).join('');
const expected = crypto.createHmac('sha1', authToken).update(url + sortedParams).digest('base64');
const expectedBuf = Buffer.from(expected);
const sigBuf = Buffer.from(sig);
if (expectedBuf.length !== sigBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, sigBuf);
}
interface PostDraft extends RowDataPacket {
id: number;
draft_text_wa: string;
destination_url: string | null;
created_at: Date;
}
interface CustomerRow extends RowDataPacket {
pilot_owner_phone: string | null;
}
export async function handleApproval(
customerId: string,
from: string,
messageId: string,
body: string,
numMedia: number
): Promise<void> {
const dedupKey = `wa_msg_seen:${messageId}`;
const isNew = await redis.set(dedupKey, '1', { NX: true, EX: 86400 });
if (!isNew) {
console.log(`[twilio-webhook] duplicate MessageSid=${messageId}, skipping`);
return;
}
const pool = getPool();
const [customers] = await pool.query<CustomerRow[]>(
'SELECT pilot_owner_phone FROM customers WHERE id = ?',
[customerId]
);
const pilotPhone = customers[0]?.pilot_owner_phone ?? '';
if (!pilotPhone || from !== pilotPhone) {
console.log(`[twilio-webhook] sender ${from} not in allowlist, customerId=${customerId}, MessageSid=${messageId}`);
return;
}
if (numMedia > 0) {
await sendMessage({ to: from, message: 'Please text 1, 2, or 3 to approve a draft. Voice notes and images are not supported here.' });
return;
}
const [rows] = await pool.query<PostDraft[]>(
`SELECT id, draft_text_wa, destination_url, created_at
FROM post_drafts
WHERE tenant_id = ? AND status = 'pending'
ORDER BY created_at DESC`,
[customerId]
);
const drafts = rows as PostDraft[];
if (drafts.length === 0) {
await sendMessage({ to: from, message: 'No pending drafts right now. New ones arrive on Monday.' });
return;
}
const newestDate = new Date(drafts[0].created_at);
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
if (newestDate < sevenDaysAgo) {
await sendMessage({ to: from, message: 'Those drafts expired — new ones arrive Monday.' });
return;
}
const newestDay = newestDate.toDateString();
const batch = drafts.filter(d => new Date(d.created_at).toDateString() === newestDay).slice(0, 3);
const trimmed = body.trim();
if (!['1', '2', '3'].includes(trimmed)) {
const list = batch.map((d, i) => `${i + 1}. ${[...d.draft_text_wa].slice(0, 80).join('')}`).join('\n');
await sendMessage({ to: from, message: `Reply 1, 2, or 3 to approve a draft:\n\n${list}` });
return;
}
const index = parseInt(trimmed, 10) - 1;
const draft = batch[index];
if (!draft) {
await sendMessage({ to: from, message: `Only ${batch.length} draft${batch.length === 1 ? '' : 's'} available. Reply 1${batch.length}.` });
return;
}
let trackedUrl: string | null = null;
if (draft.destination_url) {
try {
const token = await createTrackingLink({
customerId,
draftId: draft.id,
destinationUrl: draft.destination_url,
expiresAt: null,
});
const baseUrl = (process.env.PUBLIC_WEBHOOK_BASE_URL ?? '').replace(/\/$/, '');
trackedUrl = `${baseUrl}/t/${token}`;
} catch (err) {
console.error(`[twilio-webhook] createTrackingLink failed, draft=${draft.id}, customerId=${customerId}:`, err);
}
}
await pool.query(
"UPDATE post_drafts SET status = 'approved', approved_at = NOW() WHERE id = ?",
[draft.id]
);
const confirmMsg = trackedUrl
? `Draft ${index + 1} approved and live! Track clicks: ${trackedUrl}`
: `Draft ${index + 1} approved and live!`;
await sendMessage({ to: from, message: confirmMsg });
console.log(`[twilio-webhook] approved draft=${draft.id}, customerId=${customerId}, MessageSid=${messageId}`);
}
export async function handleTwilioWhatsApp(req: Request, res: Response): Promise<void> {
const authToken = process.env.TWILIO_AUTH_TOKEN ?? '';
if (!authToken) {
console.error('[twilio-webhook] TWILIO_AUTH_TOKEN not set — refusing to process');
res.status(503).send('Webhook validation not configured');
return;
}
const sig = req.headers['x-twilio-signature'] as string | undefined;
if (!sig) {
res.status(403).send('Missing signature');
return;
}
const baseUrl = (process.env.PUBLIC_WEBHOOK_BASE_URL ?? '').replace(/\/$/, '');
const webhookUrl = baseUrl
? `${baseUrl}/webhook/twilio/whatsapp`
: (() => {
console.warn('[twilio-webhook] PUBLIC_WEBHOOK_BASE_URL not set — using req.hostname fallback; signature validation WILL fail behind K8s ingress');
return `https://${req.hostname}/webhook/twilio/whatsapp`;
})();
const params = req.body as Record<string, string>;
if (!validateTwilioSignature(authToken, webhookUrl, params, sig)) {
res.status(403).send('Invalid signature');
return;
}
res.type('text/xml').status(200).send(TWIML_ACK);
const from = (params.From ?? '').replace(/^whatsapp:/, '');
const to = (params.To ?? '').replace(/^whatsapp:/, '');
const messageId = params.MessageSid ?? '';
const body = params.Body ?? '';
const numMedia = parseInt(params.NumMedia ?? '0', 10);
if (!from || !messageId) {
console.error('[twilio-webhook] missing From or MessageSid in payload');
return;
}
const customerId = await redis.get(`twilio_wa_number:${to}`).catch(() => null);
if (!customerId) {
console.warn(`[twilio-webhook] no customer mapping for Twilio number ${to}`);
return;
}
handleApproval(customerId, from, messageId, body, numMedia).catch(err => {
console.error(`[twilio-webhook] unhandled error, MessageSid=${messageId}, customerId=${customerId}:`, err);
});
}