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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,10 @@ KAFKA_SSL_CERT=
# WhatsApp Business API - Environment variables
# Token used to validate the webhook on the Facebook APP
WA_BUSINESS_TOKEN_WEBHOOK=evolution
# Required for Meta POST webhooks: App Secret from the Meta app that signs the notifications.
# This is different from the verification token above and the Cloud API access token.
# Without this secret, /webhook/meta returns 503; invalid or missing signatures return 401.
WA_BUSINESS_APP_SECRET=
WA_BUSINESS_URL=https://graph.facebook.com
WA_BUSINESS_VERSION=v20.0
WA_BUSINESS_LANGUAGE=en_US
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ lerna-debug.log*
# Project related
/instances/*
!/instances/.gitkeep
/test/
/test/*
!/test/meta-coexistence.test.ts
/src/env.yml
/store
*.env
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"db:migrate:dev": "node runWithProvider.js \"rm -rf ./prisma/migrations && cp -r ./prisma/DATABASE_PROVIDER-migrations ./prisma/migrations && npx prisma migrate dev --schema ./prisma/DATABASE_PROVIDER-schema.prisma && cp -r ./prisma/migrations/* ./prisma/DATABASE_PROVIDER-migrations\"",
"db:migrate:dev:win": "node runWithProvider.js \"xcopy /E /I prisma\\DATABASE_PROVIDER-migrations prisma\\migrations && npx prisma migrate dev --schema prisma\\DATABASE_PROVIDER-schema.prisma\"",
"postinstall": "patch-package",
"prepare": "husky"
"prepare": "husky",
"test:coexistence": "tsx --test ./test/meta-coexistence.test.ts"
},
"repository": {
"type": "git",
Expand Down Expand Up @@ -147,6 +148,7 @@
"@types/qrcode-terminal": "^0.12.2",
"commitizen": "^4.3.1",
"cz-conventional-changelog": "^3.3.0",
"esbuild": "0.27.7",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import-x": "^4.16.2",
Expand Down
26 changes: 26 additions & 0 deletions src/api/guards/meta-webhook.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ConfigService, WaBusiness } from '@config/env.config';
import { createHmac, timingSafeEqual } from 'crypto';
import { Request, RequestHandler } from 'express';

export function metaWebhookGuard(configService: ConfigService): RequestHandler {
return (req: Request & { rawBody?: Buffer }, res, next) => {
const secret = configService.get<WaBusiness>('WA_BUSINESS').APP_SECRET;
if (!secret) {
return res.status(503).json({ error: 'WA_BUSINESS_APP_SECRET não configurado' });
}

const signature = req.headers['x-hub-signature-256'];
if (typeof signature !== 'string' || !/^sha256=[a-f\d]{64}$/i.test(signature) || !Buffer.isBuffer(req.rawBody)) {
return res.status(401).json({ error: 'Assinatura do webhook inválida' });
}

// Authenticate the original bytes captured by the JSON parser, never a reserialized body.
const expected = createHmac('sha256', secret).update(req.rawBody).digest();
const supplied = Buffer.from(signature.slice('sha256='.length), 'hex');
if (!timingSafeEqual(expected, supplied)) {
return res.status(401).json({ error: 'Assinatura do webhook inválida' });
}

return next();
};
}
2 changes: 2 additions & 0 deletions src/api/integrations/channel/evohub/evohub.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MetaController } from '@api/integrations/channel/meta/meta.controller';
import { PrismaRepository } from '@api/repository/repository.service';
import { WAMonitoringService } from '@api/services/monitor.service';
import { Integration } from '@api/types/wa.types';
import { ConfigService, EvolutionHub } from '@config/env.config';
import { Logger } from '@config/logger.config';
import * as crypto from 'crypto';
Expand All @@ -17,6 +18,7 @@ import * as crypto from 'crypto';
*/
export class EvoHubController extends MetaController {
private readonly hubLogger = new Logger('EvoHubController');
protected readonly channelIntegration: string = Integration.EVOHUB;

constructor(
prismaRepository: PrismaRepository,
Expand Down
61 changes: 30 additions & 31 deletions src/api/integrations/channel/meta/meta.controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { PrismaRepository } from '@api/repository/repository.service';
import { WAMonitoringService } from '@api/services/monitor.service';
import { Integration } from '@api/types/wa.types';
import { Logger } from '@config/logger.config';
import axios from 'axios';

import { ChannelController, ChannelControllerInterface } from '../channel.controller';

export class MetaController extends ChannelController implements ChannelControllerInterface {
private readonly logger = new Logger('MetaController');
protected readonly channelIntegration: string = Integration.WHATSAPP_BUSINESS;

constructor(prismaRepository: PrismaRepository, waMonitor: WAMonitoringService) {
super(prismaRepository, waMonitor);
Expand All @@ -15,54 +17,51 @@ export class MetaController extends ChannelController implements ChannelControll
integrationEnabled: boolean;

public async receiveWebhook(data: any) {
if (data.object === 'whatsapp_business_account') {
if (data.entry[0]?.changes[0]?.field === 'message_template_status_update') {
const template = await this.prismaRepository.template.findFirst({
where: { templateId: `${data.entry[0].changes[0].value.message_template_id}` },
});
if (data?.object !== 'whatsapp_business_account') return { status: 'success' };

if (!template) {
console.log('template not found');
return;
}
for (const entry of data.entry ?? []) {
for (const change of entry.changes ?? []) {
if (change.field === 'message_template_status_update') {
const template = await this.prismaRepository.template.findFirst({
where: { templateId: `${change.value?.message_template_id}` },
});

const { webhookUrl } = template;
if (!template) {
console.log('template not found');
continue;
}

await axios.post(webhookUrl, data.entry[0].changes[0].value, {
headers: {
'Content-Type': 'application/json',
},
});
return;
}
const { webhookUrl } = template;

data.entry?.forEach(async (entry: any) => {
const numberId = entry.changes[0].value.metadata.phone_number_id;
await axios.post(webhookUrl, change.value, {
headers: {
'Content-Type': 'application/json',
},
});
continue;
}

const numberId = change.value?.metadata?.phone_number_id;

if (!numberId) {
this.logger.error('WebhookService -> receiveWebhookMeta -> numberId not found');
return {
status: 'success',
};
continue;
}

const instance = await this.prismaRepository.instance.findFirst({
where: { number: numberId },
where: { number: numberId, integration: this.channelIntegration },

@sourcery-ai sourcery-ai Bot Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Forged webhooks reach tenant instances

When an unauthenticated caller POSTs a whatsapp_business_account payload containing a known phone number ID and forged message data, receiveWebhook trusts each change’s metadata.phone_number_id to select an instance and passes the submitted payload to its channel; the Meta POST route does not verify a signature, so an unauthenticated caller can forge messages that are persisted or delivered to that tenant’s integrations.

Verify Meta’s X-Hub-Signature-256 against the raw request body before dispatching any webhook payload.

Prompt for AI agents
In `src/api/integrations/channel/meta/meta.controller.ts` at line 52:

**Forged webhooks reach tenant instances**

When an unauthenticated caller POSTs a `whatsapp_business_account` payload containing a known phone number ID and forged message data, `receiveWebhook` trusts each change’s `metadata.phone_number_id` to select an instance and passes the submitted payload to its channel; the Meta POST route does not verify a signature, so an unauthenticated caller can forge messages that are persisted or delivered to that tenant’s integrations.

Verify Meta’s `X-Hub-Signature-256` against the raw request body before dispatching any webhook payload.

✅ Addressed in 01ef596: The Meta webhook route now requires a valid X-Hub-Signature-256 HMAC computed over the captured raw request body before dispatching the payload.

});

if (!instance) {
this.logger.error('WebhookService -> receiveWebhookMeta -> instance not found');
return {
status: 'success',
};
continue;
}

await this.waMonitor.waInstances[instance.name].connectToWhatsapp(data);
const channel = this.waMonitor.waInstances[instance.name];
if (!channel) throw new Error('Instância da Cloud API indisponível para processar o webhook');

return {
status: 'success',
};
});
await channel.connectToWhatsapp({ ...data, entry: [{ ...entry, changes: [change] }] });
}
}

return {
Expand Down
3 changes: 2 additions & 1 deletion src/api/integrations/channel/meta/meta.router.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { RouterBroker } from '@api/abstract/abstract.router';
import { metaWebhookGuard } from '@api/guards/meta-webhook.guard';
import { metaController } from '@api/server.module';
import { ConfigService, WaBusiness } from '@config/env.config';
import { Router } from 'express';
Expand All @@ -12,7 +13,7 @@ export class MetaRouter extends RouterBroker {
res.send(req.query['hub.challenge']);
else res.send('Error, wrong validation token');
})
.post(this.routerPath('webhook/meta', false), async (req, res) => {
.post(this.routerPath('webhook/meta', false), metaWebhookGuard(configService), async (req, res) => {
const { body } = req;
const response = await metaController.receiveWebhook(body);

Expand Down
Loading