Skip to content

Commit 545e0a1

Browse files
authored
Merge pull request #2636 from evolution-foundation/feat/EVO-2098-evohub-link-existing-webhook
feat(evohub): registra webhook inbound no hub durante o link-existing
2 parents c352731 + 49bbdfe commit 545e0a1

3 files changed

Lines changed: 160 additions & 2 deletions

File tree

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,11 +258,19 @@ WA_BUSINESS_VERSION=v20.0
258258
WA_BUSINESS_LANGUAGE=en_US
259259

260260
# EvoHub channel — proxy transparente da Meta Cloud API (canal adicional)
261+
# IMPORTANTE: o webhook inbound do hub é entregue em {SERVER_URL}/webhook/evohub —
262+
# o SERVER_URL (topo deste arquivo) precisa ser alcançável PELO hub (local exige
263+
# túnel ou, com hub em Docker, http://host.docker.internal:8080).
261264
# URL = host do hub (control-plane em {URL}/api/v1, data-plane em {URL}/meta)
265+
# SaaS: https://api.evohub.ai | hub local via docker-compose: http://localhost:8086
262266
EVOLUTION_HUB_URL=https://api.evohub.ai
263267
# API-key global do deployment (control-plane: provisiona/lista/conecta canais)
268+
# OBRIGATÓRIA para as rotas /evohub/* — chave criada no hub, formato evh_pk_...
264269
EVOLUTION_HUB_API_KEY=
265270
# Secret do webhook (Fase 2: register-with-own-secret → validate HMAC X-Hub-Signature-256)
271+
# Recomendado: string aleatória forte; é enviada ao registrar o webhook no hub
272+
# (provision/link-existing) e validada no POST /webhook/evohub. Vazio = soft mode
273+
# (aceita webhook sem assinatura).
266274
EVOLUTION_HUB_WEBHOOK_SECRET=
267275
# Token do GET verify challenge (paridade defensiva com o canal Meta)
268276
EVOLUTION_HUB_TOKEN_WEBHOOK=evolution

src/api/integrations/channel/evohub/evohub.client.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ConfigService, EvolutionHub } from '@config/env.config';
22
import { Logger } from '@config/logger.config';
3+
import { BadRequestException } from '@exceptions';
34
import axios, { AxiosInstance } from 'axios';
45

56
// ---- Tipos do contrato do hub (espelham evolutionHubService.ts do frontend) ----
@@ -52,6 +53,23 @@ export interface HubChannel {
5253
meta_connection?: HubMetaConnection | null;
5354
}
5455

56+
// SSRF guard: hub channel/webhook ids are UUIDs (the hub runs uuid.Parse). The test is
57+
// INLINE in every method that interpolates an id into the request path, so path/URL
58+
// injection coming from req.params/req.body is rejected instead of being forwarded to the
59+
// control-plane. Keep it inline: a guard extracted into its own method protects at runtime
60+
// just the same, but CodeQL's taint analysis does not carry a throwing guard across a
61+
// function boundary and js/request-forgery keeps flagging the sink.
62+
const HUB_ID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
63+
64+
// Hub webhook (WebhookResponse — webhook.go:116). Only the fields we use.
65+
export interface HubWebhookInfo {
66+
id: string;
67+
name?: string;
68+
url: string;
69+
status?: string;
70+
all_channels?: boolean;
71+
}
72+
5573
// ---- Criar-novo (POST /api/v1/channels) ----
5674
export interface HubProvisionRequest {
5775
name: string;
@@ -143,6 +161,7 @@ export class EvoHubClient {
143161
* server-side; o front NUNCA vê o token.
144162
*/
145163
async getChannel(id: string): Promise<HubChannel> {
164+
if (!HUB_ID.test(id)) throw new BadRequestException(`invalid hub id: ${id}`);
146165
const { data } = await this.http.get(`/channels/${id}`);
147166
return data;
148167
}
@@ -155,6 +174,119 @@ export class EvoHubClient {
155174
return this.listChannels(type);
156175
}
157176

177+
// ---- Webhooks (hub inbound -> evolution-api) ----
178+
179+
/** Webhooks already ASSOCIATED with the channel: GET /api/v1/channels/:id/webhooks → { webhooks, count }. */
180+
async listChannelWebhooks(channelId: string): Promise<HubWebhookInfo[]> {
181+
if (!HUB_ID.test(channelId)) throw new BadRequestException(`invalid hub id: ${channelId}`);
182+
const { data } = await this.http.get(`/channels/${channelId}/webhooks`);
183+
return this.normalizeWebhookList(data);
184+
}
185+
186+
/** Every webhook owned by the API-key user: GET /api/v1/webhooks. */
187+
async listWebhooks(): Promise<HubWebhookInfo[]> {
188+
const { data } = await this.http.get('/webhooks');
189+
return this.normalizeWebhookList(data);
190+
}
191+
192+
private normalizeWebhookList(data: any): HubWebhookInfo[] {
193+
if (Array.isArray(data)) return data;
194+
if (Array.isArray(data?.webhooks)) return data.webhooks;
195+
if (Array.isArray(data?.data)) return data.data;
196+
return [];
197+
}
198+
199+
/** POST /api/v1/webhooks/:id/associate — associates an existing webhook with the channel. */
200+
async associateWebhook(webhookId: string, channelId: string): Promise<void> {
201+
if (!HUB_ID.test(webhookId)) throw new BadRequestException(`invalid hub id: ${webhookId}`);
202+
if (!HUB_ID.test(channelId)) throw new BadRequestException(`invalid hub id: ${channelId}`);
203+
await this.http.post(`/webhooks/${webhookId}/associate`, { channel_id: channelId });
204+
}
205+
206+
/**
207+
* PUT /api/v1/webhooks/:id/status — activates/deactivates a webhook. The hub only accepts
208+
* `active`|`inactive`; setting `active` is its official way out of the auto-`disabled`
209+
* state (webhook.go:104).
210+
*/
211+
async setWebhookStatus(webhookId: string, status: 'active' | 'inactive'): Promise<void> {
212+
if (!HUB_ID.test(webhookId)) throw new BadRequestException(`invalid hub id: ${webhookId}`);
213+
await this.http.put(`/webhooks/${webhookId}/status`, { status });
214+
}
215+
216+
/** PUT /api/v1/webhooks/:id/secret — stores the secret the hub signs the inbound with. */
217+
async setWebhookSecret(webhookId: string, secret: string): Promise<void> {
218+
if (!HUB_ID.test(webhookId)) throw new BadRequestException(`invalid hub id: ${webhookId}`);
219+
await this.http.put(`/webhooks/${webhookId}/secret`, { secret });
220+
}
221+
222+
/**
223+
* Rewrites the configured WEBHOOK_SECRET on a webhook we are REUSING. The hub only signs
224+
* the inbound delivery when the webhook has a secret stored (webhook_dispatcher.go:983),
225+
* and its WebhookResponse exposes neither the secret nor a `has_secret` flag
226+
* (webhook.go:116) — drift is undetectable from here, so we always rewrite. Without this,
227+
* a webhook created in soft mode (empty secret) or carrying a rotated-away secret is
228+
* reused as if it were ready: the hub delivers unsigned (or wrongly signed), verifyHmac
229+
* answers 401 and the channel goes deaf — the very symptom this flow exists to kill.
230+
* Worse, the webhook is SHARED across channels, so the repeated 401 makes the hub
231+
* auto-disable it and takes the inbound of every channel down with it.
232+
*
233+
* Empty secret → soft mode: the inbound accepts unsigned payloads, nothing to enforce.
234+
*/
235+
private async syncWebhookSecret(webhookId: string): Promise<void> {
236+
const secret = this.configService.get<EvolutionHub>('EVOLUTION_HUB').WEBHOOK_SECRET;
237+
if (!secret) return;
238+
await this.setWebhookSecret(webhookId, secret);
239+
}
240+
241+
/**
242+
* Idempotently guarantees the channel has an ACTIVE webhook pointing at `webhookUrl`.
243+
* The single-shot registration the provision flow gets does not exist in link-existing,
244+
* and with no webhook (or a non-`active` one) the channel SENDS but never RECEIVES.
245+
*
246+
* The hub's dispatcher only delivers when `status == 'active'` (webhook_dispatcher.go:294);
247+
* a webhook in `disabled` (auto-disabled after repeated delivery failures) or `inactive`
248+
* matches the URL but does NOT deliver, so we reactivate instead of treating it as ready —
249+
* otherwise the re-link answers 201 and the channel stays deaf. Every REUSE path rewrites
250+
* the secret before reactivating (syncWebhookSecret): a webhook with a stale secret would
251+
* take another 401 on the inbound and get auto-disabled right back. Order:
252+
* 1) already associated with the channel, same URL → enforce the secret, reactivate if needed;
253+
* 2) user-level webhook with the same URL → enforce the secret, reactivate and associate
254+
* (all_channels already covers the channel, so only ensure it is active);
255+
* 3) none → create it with `channels: [channelId]` (single-shot) and `events: []`
256+
* (empty = ALL events — webhook_service.go:98). Secret follows the
257+
* register-with-own-secret recipe, same as provision.
258+
*/
259+
async ensureChannelWebhook(channelId: string, webhookUrl: string): Promise<void> {
260+
if (!HUB_ID.test(channelId)) throw new BadRequestException(`invalid hub id: ${channelId}`);
261+
262+
const associated = await this.listChannelWebhooks(channelId);
263+
const match = associated.find((w) => w.url === webhookUrl);
264+
if (match) {
265+
await this.syncWebhookSecret(match.id);
266+
if (match.status !== 'active') await this.setWebhookStatus(match.id, 'active');
267+
return;
268+
}
269+
270+
const all = await this.listWebhooks();
271+
const existing = all.find((w) => w.url === webhookUrl);
272+
if (existing) {
273+
await this.syncWebhookSecret(existing.id);
274+
if (existing.status !== 'active') await this.setWebhookStatus(existing.id, 'active');
275+
if (!existing.all_channels) await this.associateWebhook(existing.id, channelId);
276+
return;
277+
}
278+
279+
const cfg = this.configService.get<EvolutionHub>('EVOLUTION_HUB');
280+
const body: Record<string, any> = {
281+
name: 'evolution-api inbound',
282+
url: webhookUrl,
283+
events: [],
284+
channels: [channelId],
285+
};
286+
if (cfg.WEBHOOK_SECRET) body.secret = cfg.WEBHOOK_SECRET;
287+
await this.http.post('/webhooks', body);
288+
}
289+
158290
// ---- Fase 2 ----
159291

160292
/**
@@ -203,6 +335,7 @@ export class EvoHubClient {
203335
* Evolution; 'byo' exige channel_credentials no hub.
204336
*/
205337
async connectToMeta(channelId: string, req: MetaConnectRequest): Promise<MetaConnectResponse> {
338+
if (!HUB_ID.test(channelId)) throw new BadRequestException(`invalid hub id: ${channelId}`);
206339
const { data } = await this.http.post(`/channels/${channelId}/meta-connect`, req);
207340
return data;
208341
}

src/api/integrations/channel/evohub/evohub.controlplane.router.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,25 @@ export class EvoHubControlPlaneRouter extends RouterBroker {
7676
return res.status(422).json({ error: 'hub channel missing token or phone_number_id' });
7777
}
7878

79-
// 2) cria a Instance EVOHUB pelo caminho padrão, com o token JÁ resolvido
80-
// (flui pelo channel.controller.init() guard sem relaxá-lo — contrato §5).
79+
// 2) ensure the inbound webhook on the hub BEFORE creating the Instance (provision
80+
// registers it single-shot; without this the linked channel sends but never
81+
// receives). The order is deliberate: on failure nothing was created, so the
82+
// retry is safe.
83+
const serverUrl = configService.get<HttpServer>('SERVER').URL;
84+
if (serverUrl) {
85+
try {
86+
await evoHubClient.ensureChannelWebhook(hub_channel_id, `${serverUrl}/webhook/evohub`);
87+
} catch (e) {
88+
return res.status(502).json({
89+
error: 'failed to register inbound webhook on hub',
90+
detail: e?.response?.data ?? e?.message,
91+
});
92+
}
93+
}
94+
95+
// 3) create the EVOHUB Instance through the standard path, with the token ALREADY
96+
// resolved (flows through the channel.controller.init() guard without relaxing
97+
// it — contract §5).
8198
const created = await instanceController.createInstance({
8299
instanceName: (req.body.instanceName as string) || `evohub-${phoneNumberId}`,
83100
integration: Integration.EVOHUB,

0 commit comments

Comments
 (0)