Skip to content
Open
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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,12 @@ coverage/
*.lcov
coverage-final.json
clover.xml

# Local environment files. .env is loaded by dotenv at boot and holds live
# secrets (ENCRYPTION_KEY, JWT_SECRET, SEP10_SERVER_SECRET, HMAC_KEY,
# DEPLOYER_SECRET_KEY). The committed example file is the only environment
# file that belongs in git.
.env
.env.local
.env.*.local
!.env.example
65 changes: 14 additions & 51 deletions src/controllers/bot.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Request, Response } from 'express';
import { config } from '../config/env';
import type { Request, Response } from 'express'; import { config } from '../config/env';
import { enqueueMessage, type MessageJobData } from '../queue/message.queue';
import { observabilityService } from '../services/observability.service';
import { readFirstMessage, type WhatsAppWebhookBody } from '../webhook/webhook-body';

/**
* Hard upper bound on how long we wait for a message to be enqueued before
Expand All @@ -10,43 +10,6 @@ import { observabilityService } from '../services/observability.service';
*/
const ENQUEUE_TIMEOUT_MS = 10_000;

/**
* Minimal, defensively-typed shape of the WhatsApp Cloud API webhook payload.
* Every field is optional because the payload is attacker-controllable: the
* webhook is public, so we must treat the body as untrusted and never assume a
* field exists. We only model the slice we actually read.
*/
interface WhatsAppContact {
wa_id?: string;
profile?: {
name?: string;
};
}

interface WhatsAppTextMessage {
from?: string;
id?: string;
text?: { body?: string };
}

interface WhatsAppChangeValue {
messages?: WhatsAppTextMessage[];
contacts?: WhatsAppContact[];
}

interface WhatsAppChange {
value?: WhatsAppChangeValue;
}

interface WhatsAppEntry {
changes?: WhatsAppChange[];
}

interface WhatsAppWebhookBody {
object?: unknown;
entry?: WhatsAppEntry[];
}

export interface IncomingTextMessage {
from: string;
msgBody: string;
Expand All @@ -66,7 +29,7 @@ export class BotController {
}

if (mode === 'subscribe' && token === config.VERIFY_TOKEN) {
console.log('WEBHOOK_VERIFIED');
console.log('WEBHOOKVERIFIED');
res.status(200).send(typeof challenge === 'string' ? challenge : '');
return;
}
Expand All @@ -76,26 +39,26 @@ export class BotController {

/**
* Safely extract the first inbound text message from a (possibly malformed
* or malicious) webhook body. Optional chaining is used end-to-end so a
* partial or garbage payload can never throw a TypeError.
* Returns null when there is no actionable text message.
* or malicious) webhook body. The shared accessor uses optional chaining
* end-to-end so a partial or garbage payload can never throw a TypeError.
* This controller applies the stricter validation: non-empty strings and a
* required message id. Returns null when there is no actionable text
* message.
*/
private extractTextMessage(body: WhatsAppWebhookBody): IncomingTextMessage | null {
const value = body.entry?.[0]?.changes?.[0]?.value;
const message = value?.messages?.[0];
const contacts = value?.contacts;

const from = message?.from;
const msgBody = message?.text?.body;
const id = message?.id;
const message = readFirstMessage(body);

const from = message.from;
const msgBody = message.text?.body;
const id = message.id;

if (typeof from !== 'string' || typeof msgBody !== 'string' || msgBody.length === 0 || typeof id !== 'string') {
return null;
}

// Try to extract locale if available in contacts metadata for this sender (e.g., from WhatsApp profile)
let locale: string | undefined;
const contact = contacts?.find(c => c.wa_id === from);
const contact = message.contacts?.find(c => c.wa_id === from);
if (contact && (contact as any).profile?.locale) {
locale = (contact as any).profile.locale;
}
Expand Down
3 changes: 2 additions & 1 deletion src/middleware/command-rate-limit.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { redisClient } from '../lib/redis';
import { WhatsAppService } from '../services/whatsapp.service';
import { t, initI18n } from '../services/locale.service';
import { abuseDetectionService } from '../services/abuse-detection.service';
import { readFirstMessage } from '../lib/whatsapp-webhook';

const whatsappService = new WhatsAppService();

Expand Down Expand Up @@ -60,7 +61,7 @@ export function extractMessageDetails(req: any): { phoneNumber: string | null; c
}
}

const message = body?.entry?.[0]?.changes?.[0]?.value?.messages?.[0];
const message = readFirstMessage(body);
const from = message?.from;
const msgBody = message?.text?.body;

Expand Down
29 changes: 3 additions & 26 deletions src/middleware/webhook-signature.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import crypto from 'crypto';
import crypto from 'cripto';
import type { Request, Response, NextFunction } from 'express';
import { config } from '../config/env';
import { redisClient } from '../lib/redis';
import { observabilityService } from '../services/observability.service';
import { readFirstMessage } from '../types/whatsapp-webhook';

export interface RequestWithRawBody extends Request {
rawBody?: Buffer;
Expand All @@ -12,27 +13,6 @@ const STALE_WEBHOOK_SECONDS = 300;
const REPLAY_TTL_SECONDS = 600;
const REDIS_KEY_PREFIX = 'webhook:id:';

interface WhatsAppMessage {
id?: string;
timestamp?: string;
}

interface WhatsAppStatus {
id?: string;
timestamp?: string;
}

interface WhatsAppWebhookBody {
entry?: Array<{
changes?: Array<{
value?: {
messages?: WhatsAppMessage[];
statuses?: WhatsAppStatus[];
};
}>;
}>;
}

function getSourceIp(req: Request): string {
return req.ip || req.socket?.remoteAddress || 'unknown';
}
Expand All @@ -43,16 +23,13 @@ function truncateSignature(sig: string): string {
}

function extractReplayInfo(body: unknown): { id: string; timestamp: number } | null {
const payload = body as WhatsAppWebhookBody;
const value = payload?.entry?.[0]?.changes?.[0]?.value;
const { message, status } = readFirstMessage(body);

const message = value?.messages?.[0];
if (message?.id && message?.timestamp) {
const ts = parseInt(message.timestamp, 10);
if (!isNaN(ts)) return { id: message.id, timestamp: ts };
}

const status = value?.statuses?.[0];
if (status?.id && status?.timestamp) {
const ts = parseInt(status.timestamp, 10);
if (!isNaN(ts)) return { id: status.id, timestamp: ts };
Expand Down
5 changes: 5 additions & 0 deletions src/services/whatsapp.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios from 'axios';
import { config } from '../config/env';
import { WhatsAppWebhookBody } from '../types/whatsapp-webhook.types';

export class WhatsAppService {
private apiUrl = `https://graph.facebook.com/v17.0/${config.WHATSAPP_PHONE_NUMBER_ID}/messages`;
Expand All @@ -25,4 +26,8 @@ export class WhatsAppService {
console.error('Error sending WhatsApp message:', error);
}
}

public parseWebhookBody(body: WhatsAppWebhookBody) {
return body;
}
}