From 9095da7e98b2411a006f060683d268bba9f42c0f Mon Sep 17 00:00:00 2001 From: Matheus Pastorini Date: Mon, 13 Jul 2026 12:01:37 -0300 Subject: [PATCH 1/5] feat(evohub): registra webhook inbound no hub durante o link-existing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O fluxo link-existing criava a Instance mas não registrava webhook no hub (só o provision registrava, via single-shot do POST /channels). Resultado: canal vinculado enviava mensagens mas nunca recebia — nenhum evento inbound chegava em /webhook/evohub e não havia erro em lugar algum. - evohub.client.ts: novo ensureChannelWebhook(channelId, url) idempotente — (1) webhook já associado ao canal com a mesma URL: no-op; (2) webhook do usuário com a mesma URL: associa via POST /webhooks/:id/associate (se all_channels, nem associa); (3) cria via POST /webhooks single-shot com channels [channelId] e events [] (vazio = todos). Secret enviado quando EVOLUTION_HUB_WEBHOOK_SECRET setado (register-with-own-secret). Inclui HubWebhookInfo, listChannelWebhooks, listWebhooks e associateWebhook. - evohub.controlplane.router.ts: link-existing garante o webhook ANTES de criar a Instance — falha do hub responde 502 sem efeito colateral (retry seguro); SERVER_URL vazio mantém paridade com o provision (segue sem webhook). - .env.example: documenta o preenchimento das variáveis EvoHub (formato evh_pk_ da API key, SERVER_URL alcançável pelo hub, host local 8086, soft mode do webhook secret). Refs EVO-2098 --- .env.example | 8 +++ .../channel/evohub/evohub.client.ts | 68 +++++++++++++++++++ .../evohub/evohub.controlplane.router.ts | 17 ++++- 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index c82051095f..fa7d9e7bd3 100644 --- a/.env.example +++ b/.env.example @@ -258,11 +258,19 @@ WA_BUSINESS_VERSION=v20.0 WA_BUSINESS_LANGUAGE=en_US # EvoHub channel — proxy transparente da Meta Cloud API (canal adicional) +# IMPORTANTE: o webhook inbound do hub é entregue em {SERVER_URL}/webhook/evohub — +# o SERVER_URL (topo deste arquivo) precisa ser alcançável PELO hub (local exige +# túnel ou, com hub em Docker, http://host.docker.internal:8080). # URL = host do hub (control-plane em {URL}/api/v1, data-plane em {URL}/meta) +# SaaS: https://api.evohub.ai | hub local via docker-compose: http://localhost:8086 EVOLUTION_HUB_URL=https://api.evohub.ai # API-key global do deployment (control-plane: provisiona/lista/conecta canais) +# OBRIGATÓRIA para as rotas /evohub/* — chave criada no hub, formato evh_pk_... EVOLUTION_HUB_API_KEY= # Secret do webhook (Fase 2: register-with-own-secret → validate HMAC X-Hub-Signature-256) +# Recomendado: string aleatória forte; é enviada ao registrar o webhook no hub +# (provision/link-existing) e validada no POST /webhook/evohub. Vazio = soft mode +# (aceita webhook sem assinatura). EVOLUTION_HUB_WEBHOOK_SECRET= # Token do GET verify challenge (paridade defensiva com o canal Meta) EVOLUTION_HUB_TOKEN_WEBHOOK=evolution diff --git a/src/api/integrations/channel/evohub/evohub.client.ts b/src/api/integrations/channel/evohub/evohub.client.ts index 9313e8b3e3..6f376fee06 100644 --- a/src/api/integrations/channel/evohub/evohub.client.ts +++ b/src/api/integrations/channel/evohub/evohub.client.ts @@ -52,6 +52,15 @@ export interface HubChannel { meta_connection?: HubMetaConnection | null; } +// Webhook do hub (WebhookResponse — webhook.go:116). Só os campos que usamos. +export interface HubWebhookInfo { + id: string; + name?: string; + url: string; + status?: string; + all_channels?: boolean; +} + // ---- Criar-novo (POST /api/v1/channels) ---- export interface HubProvisionRequest { name: string; @@ -155,6 +164,65 @@ export class EvoHubClient { return this.listChannels(type); } + // ---- Webhooks (inbound do hub -> evolution-api) ---- + + /** Webhooks já ASSOCIADOS ao canal: GET /api/v1/channels/:id/webhooks → { webhooks, count }. */ + async listChannelWebhooks(channelId: string): Promise { + const { data } = await this.http.get(`/channels/${channelId}/webhooks`); + return this.normalizeWebhookList(data); + } + + /** Todos os webhooks do usuário da API-key: GET /api/v1/webhooks. */ + async listWebhooks(): Promise { + const { data } = await this.http.get('/webhooks'); + return this.normalizeWebhookList(data); + } + + private normalizeWebhookList(data: any): HubWebhookInfo[] { + if (Array.isArray(data)) return data; + if (Array.isArray(data?.webhooks)) return data.webhooks; + if (Array.isArray(data?.data)) return data.data; + return []; + } + + /** POST /api/v1/webhooks/:id/associate — associa um webhook existente ao canal. */ + async associateWebhook(webhookId: string, channelId: string): Promise { + await this.http.post(`/webhooks/${webhookId}/associate`, { channel_id: channelId }); + } + + /** + * Garante (idempotente) que o canal tem um webhook ativo apontando para + * `webhookUrl` — o caminho single-shot do provision não existe no link-existing, + * e sem webhook o canal envia mas nunca RECEBE mensagens. Ordem: + * 1) já associado ao canal com a mesma URL → no-op; + * 2) webhook do usuário com a mesma URL → associa (evita duplicar; um webhook + * all_channels com a URL já cobre o canal, também no-op); + * 3) cria novo com `channels: [channelId]` (single-shot) e `events: []` + * (vazio = TODOS os eventos — webhook_service.go:98). Secret = recipe + * register-with-own-secret, igual ao provision. + */ + async ensureChannelWebhook(channelId: string, webhookUrl: string): Promise { + const associated = await this.listChannelWebhooks(channelId); + if (associated.some((w) => w.url === webhookUrl && w.status !== 'inactive')) return; + + const all = await this.listWebhooks(); + const existing = all.find((w) => w.url === webhookUrl && w.status !== 'inactive'); + if (existing) { + if (!existing.all_channels) await this.associateWebhook(existing.id, channelId); + return; + } + + const cfg = this.configService.get('EVOLUTION_HUB'); + const body: Record = { + name: 'evolution-api inbound', + url: webhookUrl, + events: [], + channels: [channelId], + }; + if (cfg.WEBHOOK_SECRET) body.secret = cfg.WEBHOOK_SECRET; + await this.http.post('/webhooks', body); + } + // ---- Fase 2 ---- /** diff --git a/src/api/integrations/channel/evohub/evohub.controlplane.router.ts b/src/api/integrations/channel/evohub/evohub.controlplane.router.ts index 6b4a61c353..b4c9d5bbcb 100644 --- a/src/api/integrations/channel/evohub/evohub.controlplane.router.ts +++ b/src/api/integrations/channel/evohub/evohub.controlplane.router.ts @@ -76,7 +76,22 @@ export class EvoHubControlPlaneRouter extends RouterBroker { return res.status(422).json({ error: 'hub channel missing token or phone_number_id' }); } - // 2) cria a Instance EVOHUB pelo caminho padrão, com o token JÁ resolvido + // 2) garante o webhook inbound no hub ANTES de criar a Instance (o provision + // registra via single-shot; sem isso o canal vinculado envia mas nunca + // recebe). Ordem proposital: se falhar, nada foi criado e o retry é seguro. + const serverUrl = configService.get('SERVER').URL; + if (serverUrl) { + try { + await evoHubClient.ensureChannelWebhook(hub_channel_id, `${serverUrl}/webhook/evohub`); + } catch (e) { + return res.status(502).json({ + error: 'failed to register inbound webhook on hub', + detail: e?.response?.data ?? e?.message, + }); + } + } + + // 3) cria a Instance EVOHUB pelo caminho padrão, com o token JÁ resolvido // (flui pelo channel.controller.init() guard sem relaxá-lo — contrato §5). const created = await instanceController.createInstance({ instanceName: (req.body.instanceName as string) || `evohub-${phoneNumberId}`, From 489febfa7946e624f2e9870164d9836a8095fc5e Mon Sep 17 00:00:00 2001 From: Guilherme Gomes Date: Mon, 13 Jul 2026 18:43:27 -0300 Subject: [PATCH 2/5] fix(evohub): reactivate non-active hub webhooks and guard hub ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureChannelWebhook treated any webhook whose status was not exactly 'inactive' as good enough and no-oped. But the hub also auto-moves a webhook to status 'disabled' after repeated delivery failures, and its dispatcher delivers ONLY when status == 'active' (webhook_dispatcher.go:294). So a disabled webhook matched the URL, was accepted, and the channel stayed deaf — the exact "silently not receiving" symptom this feature exists to fix, in a common scenario (evolution-api was down long enough for the hub to disable the webhook; the operator re-links and gets 201 but still receives nothing). Now the guard keys on status === 'active' and reactivates a matching but non-active webhook via PUT /webhooks/:id/status (the hub's official path out of 'disabled'), for both the channel-associated and the user-level webhook. Also validates channel/webhook ids as UUID before interpolating them into the hub request path (assertHubId), closing the CodeQL js/request-forgery paths the new listChannelWebhooks method added and hardening getChannel/connectToMeta. Hub ids are UUIDs (uuid.Parse server-side), so this rejects nothing legitimate. Verified: tsc/eslint/build green, plus a stubbed-http harness driving ensureChannelWebhook through the disabled/active/missing/user-inactive/ all_channels branches and the id guard (10/10 assertions). --- .../channel/evohub/evohub.client.ts | 51 ++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/src/api/integrations/channel/evohub/evohub.client.ts b/src/api/integrations/channel/evohub/evohub.client.ts index 6f376fee06..1f234e6be2 100644 --- a/src/api/integrations/channel/evohub/evohub.client.ts +++ b/src/api/integrations/channel/evohub/evohub.client.ts @@ -1,5 +1,6 @@ import { ConfigService, EvolutionHub } from '@config/env.config'; import { Logger } from '@config/logger.config'; +import { BadRequestException } from '@exceptions'; import axios, { AxiosInstance } from 'axios'; // ---- Tipos do contrato do hub (espelham evolutionHubService.ts do frontend) ---- @@ -137,6 +138,15 @@ export class EvoHubClient { return this.normalizeChannelList(data); } + // SSRF guard: channel/webhook ids do hub são UUID (o hub faz uuid.Parse). Validamos + // ANTES de interpolar o id no path da request ao hub — recusa path/URL injection + // vinda de req.params/req.body em vez de repassá-la para o control-plane. + private assertHubId(id: string): void { + if (!/^[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}$/.test(id)) { + throw new BadRequestException(`invalid hub id: ${id}`); + } + } + // Normaliza a resposta de lista do hub para HubChannel[] (channels|data|array nu). private normalizeChannelList(data: any): HubChannel[] { if (Array.isArray(data)) return data; @@ -152,6 +162,7 @@ export class EvoHubClient { * server-side; o front NUNCA vê o token. */ async getChannel(id: string): Promise { + this.assertHubId(id); const { data } = await this.http.get(`/channels/${id}`); return data; } @@ -168,6 +179,7 @@ export class EvoHubClient { /** Webhooks já ASSOCIADOS ao canal: GET /api/v1/channels/:id/webhooks → { webhooks, count }. */ async listChannelWebhooks(channelId: string): Promise { + this.assertHubId(channelId); const { data } = await this.http.get(`/channels/${channelId}/webhooks`); return this.normalizeWebhookList(data); } @@ -187,27 +199,51 @@ export class EvoHubClient { /** POST /api/v1/webhooks/:id/associate — associa um webhook existente ao canal. */ async associateWebhook(webhookId: string, channelId: string): Promise { + this.assertHubId(webhookId); + this.assertHubId(channelId); await this.http.post(`/webhooks/${webhookId}/associate`, { channel_id: channelId }); } /** - * Garante (idempotente) que o canal tem um webhook ativo apontando para + * PUT /api/v1/webhooks/:id/status — reativa/desativa um webhook. O hub só aceita + * `active`|`inactive`; reativar com `active` é o caminho oficial para tirar um + * webhook do estado auto-`disabled` (webhook.go:104). + */ + async setWebhookStatus(webhookId: string, status: 'active' | 'inactive'): Promise { + this.assertHubId(webhookId); + await this.http.put(`/webhooks/${webhookId}/status`, { status }); + } + + /** + * Garante (idempotente) que o canal tem um webhook ATIVO apontando para * `webhookUrl` — o caminho single-shot do provision não existe no link-existing, - * e sem webhook o canal envia mas nunca RECEBE mensagens. Ordem: - * 1) já associado ao canal com a mesma URL → no-op; - * 2) webhook do usuário com a mesma URL → associa (evita duplicar; um webhook - * all_channels com a URL já cobre o canal, também no-op); + * e sem webhook (ou com webhook não-`active`) o canal envia mas nunca RECEBE. + * + * O dispatcher do hub só entrega quando `status == 'active'` (webhook_dispatcher.go:294); + * um webhook em `disabled` (auto-desativado após falhas de entrega) ou `inactive` + * casa a URL mas NÃO entrega, então reativamos em vez de tratar como pronto — + * senão o re-link responde 201 e o canal segue surdo. Ordem: + * 1) já associado ao canal com a mesma URL → reativa se não estiver `active`; + * 2) webhook do usuário com a mesma URL → reativa e associa (all_channels já + * cobre o canal, então só garante que está ativo); * 3) cria novo com `channels: [channelId]` (single-shot) e `events: []` * (vazio = TODOS os eventos — webhook_service.go:98). Secret = recipe * register-with-own-secret, igual ao provision. */ async ensureChannelWebhook(channelId: string, webhookUrl: string): Promise { + this.assertHubId(channelId); + const associated = await this.listChannelWebhooks(channelId); - if (associated.some((w) => w.url === webhookUrl && w.status !== 'inactive')) return; + const match = associated.find((w) => w.url === webhookUrl); + if (match) { + if (match.status !== 'active') await this.setWebhookStatus(match.id, 'active'); + return; + } const all = await this.listWebhooks(); - const existing = all.find((w) => w.url === webhookUrl && w.status !== 'inactive'); + const existing = all.find((w) => w.url === webhookUrl); if (existing) { + if (existing.status !== 'active') await this.setWebhookStatus(existing.id, 'active'); if (!existing.all_channels) await this.associateWebhook(existing.id, channelId); return; } @@ -271,6 +307,7 @@ export class EvoHubClient { * Evolution; 'byo' exige channel_credentials no hub. */ async connectToMeta(channelId: string, req: MetaConnectRequest): Promise { + this.assertHubId(channelId); const { data } = await this.http.post(`/channels/${channelId}/meta-connect`, req); return data; } From 63797722d04e024cca6018c91690ca96d14103a2 Mon Sep 17 00:00:00 2001 From: Guilherme Gomes Date: Mon, 13 Jul 2026 19:17:19 -0300 Subject: [PATCH 3/5] fix(evohub): rewrite the configured webhook secret when reusing a hub webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureChannelWebhook matched an existing hub webhook by URL alone and reused it as-is. The hub only signs the inbound delivery when the webhook has a secret stored (webhook_dispatcher.go:983) and its WebhookResponse exposes neither the secret nor a has_secret flag (webhook.go:116), so drift is undetectable from the client side. A deployment that ran EvoHub phase 1 in soft mode (empty secret — the documented default) has a hub webhook with NO secret. The moment the operator follows the guidance this branch adds to .env.example and sets EVOLUTION_HUB_WEBHOOK_SECRET, the next link-existing reuses that secret-less webhook, the hub delivers unsigned, verifyHmac answers 401 and the channel is deaf — the very symptom this feature exists to kill. Rotating the secret does the same. Worse: the webhook is shared across channels, so the repeated 401 makes the hub auto-disable it and every EvoHub channel stops receiving. Now every reuse path rewrites the configured secret (PUT /webhooks/:id/secret) BEFORE reactivating the webhook — reactivating first would just take another 401 and get auto-disabled again. Empty secret stays a no-op: the inbound accepts unsigned payloads in soft mode. Verified: tsc/eslint/build green, plus a stubbed-http harness driving the real client through reuse-with-secret, disabled-webhook ordering, soft mode, user-level webhook and the id guard (12/12 assertions). Refs EVO-2098 --- .../channel/evohub/evohub.client.ts | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/api/integrations/channel/evohub/evohub.client.ts b/src/api/integrations/channel/evohub/evohub.client.ts index 1f234e6be2..5cfbd41de9 100644 --- a/src/api/integrations/channel/evohub/evohub.client.ts +++ b/src/api/integrations/channel/evohub/evohub.client.ts @@ -214,6 +214,31 @@ export class EvoHubClient { await this.http.put(`/webhooks/${webhookId}/status`, { status }); } + /** PUT /api/v1/webhooks/:id/secret — grava o secret usado para assinar o inbound. */ + async setWebhookSecret(webhookId: string, secret: string): Promise { + this.assertHubId(webhookId); + await this.http.put(`/webhooks/${webhookId}/secret`, { secret }); + } + + /** + * Reescreve o WEBHOOK_SECRET configurado num webhook que estamos REUSANDO. O hub só + * assina o inbound quando o webhook tem secret gravado (webhook_dispatcher.go:983) e + * o WebhookResponse NÃO expõe o secret nem um `has_secret` (webhook.go:116) — não há + * como detectar drift, então reescrevemos sempre. Sem isso, um webhook criado em soft + * mode (secret vazio) ou com secret antigo é reusado como se estivesse pronto, o hub + * entrega sem assinatura/com assinatura errada, o verifyHmac responde 401 e o canal + * fica surdo — o mesmo sintoma que este fluxo existe para matar. Pior: o webhook é + * COMPARTILHADO entre os canais, então o 401 repetido leva o hub a auto-desabilitá-lo + * e derruba o inbound de todos eles. + * + * Secret vazio → soft mode: o inbound aceita sem assinatura, nada a garantir. + */ + private async syncWebhookSecret(webhookId: string): Promise { + const secret = this.configService.get('EVOLUTION_HUB').WEBHOOK_SECRET; + if (!secret) return; + await this.setWebhookSecret(webhookId, secret); + } + /** * Garante (idempotente) que o canal tem um webhook ATIVO apontando para * `webhookUrl` — o caminho single-shot do provision não existe no link-existing, @@ -222,10 +247,12 @@ export class EvoHubClient { * O dispatcher do hub só entrega quando `status == 'active'` (webhook_dispatcher.go:294); * um webhook em `disabled` (auto-desativado após falhas de entrega) ou `inactive` * casa a URL mas NÃO entrega, então reativamos em vez de tratar como pronto — - * senão o re-link responde 201 e o canal segue surdo. Ordem: - * 1) já associado ao canal com a mesma URL → reativa se não estiver `active`; - * 2) webhook do usuário com a mesma URL → reativa e associa (all_channels já - * cobre o canal, então só garante que está ativo); + * senão o re-link responde 201 e o canal segue surdo. Em todo REUSO o secret é + * reescrito antes da reativação (syncWebhookSecret) — um webhook com secret + * defasado tomaria 401 no inbound e voltaria a ser auto-desabilitado. Ordem: + * 1) já associado ao canal com a mesma URL → garante secret e reativa se preciso; + * 2) webhook do usuário com a mesma URL → garante secret, reativa e associa + * (all_channels já cobre o canal, então só garante que está ativo); * 3) cria novo com `channels: [channelId]` (single-shot) e `events: []` * (vazio = TODOS os eventos — webhook_service.go:98). Secret = recipe * register-with-own-secret, igual ao provision. @@ -236,6 +263,7 @@ export class EvoHubClient { const associated = await this.listChannelWebhooks(channelId); const match = associated.find((w) => w.url === webhookUrl); if (match) { + await this.syncWebhookSecret(match.id); if (match.status !== 'active') await this.setWebhookStatus(match.id, 'active'); return; } @@ -243,6 +271,7 @@ export class EvoHubClient { const all = await this.listWebhooks(); const existing = all.find((w) => w.url === webhookUrl); if (existing) { + await this.syncWebhookSecret(existing.id); if (existing.status !== 'active') await this.setWebhookStatus(existing.id, 'active'); if (!existing.all_channels) await this.associateWebhook(existing.id, channelId); return; From 1230663fd30861ccab3edd4cc8df5239bf3dd2cb Mon Sep 17 00:00:00 2001 From: Guilherme Gomes Date: Mon, 13 Jul 2026 19:25:04 -0300 Subject: [PATCH 4/5] fix(evohub): inline the hub-id guard at each request sink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UUID guard lived in assertHubId(), a separate method. It blocks path/URL injection at runtime just fine, but CodeQL's taint analysis does not carry a throwing guard across a function boundary, so js/request-forgery kept flagging every method that interpolates a hub id into the request path — the alerts the guard was added to close stayed open. Same check, now inline at each sink (HUB_ID.test), which is what both the scanner and the runtime accept. Behaviour is unchanged: hub ids are UUIDs (uuid.Parse server-side), so nothing legitimate is rejected. Refs EVO-2098 --- .../channel/evohub/evohub.client.ts | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/api/integrations/channel/evohub/evohub.client.ts b/src/api/integrations/channel/evohub/evohub.client.ts index 5cfbd41de9..be0e8cc9b6 100644 --- a/src/api/integrations/channel/evohub/evohub.client.ts +++ b/src/api/integrations/channel/evohub/evohub.client.ts @@ -53,6 +53,14 @@ export interface HubChannel { meta_connection?: HubMetaConnection | null; } +// SSRF guard: channel/webhook ids do hub são UUID (o hub faz uuid.Parse). O teste roda +// INLINE em cada método que interpola o id no path da request — recusa path/URL injection +// vinda de req.params/req.body em vez de repassá-la ao control-plane. Extrair o guard para +// um método (`assertHubId`) protege igual em runtime, mas a análise de fluxo do CodeQL não +// o reconhece como barreira através da fronteira de função e o js/request-forgery continua +// acusando o sink; o teste inline é o que satisfaz scanner e runtime ao mesmo tempo. +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}$/; + // Webhook do hub (WebhookResponse — webhook.go:116). Só os campos que usamos. export interface HubWebhookInfo { id: string; @@ -138,15 +146,6 @@ export class EvoHubClient { return this.normalizeChannelList(data); } - // SSRF guard: channel/webhook ids do hub são UUID (o hub faz uuid.Parse). Validamos - // ANTES de interpolar o id no path da request ao hub — recusa path/URL injection - // vinda de req.params/req.body em vez de repassá-la para o control-plane. - private assertHubId(id: string): void { - if (!/^[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}$/.test(id)) { - throw new BadRequestException(`invalid hub id: ${id}`); - } - } - // Normaliza a resposta de lista do hub para HubChannel[] (channels|data|array nu). private normalizeChannelList(data: any): HubChannel[] { if (Array.isArray(data)) return data; @@ -162,7 +161,7 @@ export class EvoHubClient { * server-side; o front NUNCA vê o token. */ async getChannel(id: string): Promise { - this.assertHubId(id); + if (!HUB_ID.test(id)) throw new BadRequestException(`invalid hub id: ${id}`); const { data } = await this.http.get(`/channels/${id}`); return data; } @@ -179,7 +178,7 @@ export class EvoHubClient { /** Webhooks já ASSOCIADOS ao canal: GET /api/v1/channels/:id/webhooks → { webhooks, count }. */ async listChannelWebhooks(channelId: string): Promise { - this.assertHubId(channelId); + if (!HUB_ID.test(channelId)) throw new BadRequestException(`invalid hub id: ${channelId}`); const { data } = await this.http.get(`/channels/${channelId}/webhooks`); return this.normalizeWebhookList(data); } @@ -199,8 +198,8 @@ export class EvoHubClient { /** POST /api/v1/webhooks/:id/associate — associa um webhook existente ao canal. */ async associateWebhook(webhookId: string, channelId: string): Promise { - this.assertHubId(webhookId); - this.assertHubId(channelId); + if (!HUB_ID.test(webhookId)) throw new BadRequestException(`invalid hub id: ${webhookId}`); + if (!HUB_ID.test(channelId)) throw new BadRequestException(`invalid hub id: ${channelId}`); await this.http.post(`/webhooks/${webhookId}/associate`, { channel_id: channelId }); } @@ -210,13 +209,13 @@ export class EvoHubClient { * webhook do estado auto-`disabled` (webhook.go:104). */ async setWebhookStatus(webhookId: string, status: 'active' | 'inactive'): Promise { - this.assertHubId(webhookId); + if (!HUB_ID.test(webhookId)) throw new BadRequestException(`invalid hub id: ${webhookId}`); await this.http.put(`/webhooks/${webhookId}/status`, { status }); } /** PUT /api/v1/webhooks/:id/secret — grava o secret usado para assinar o inbound. */ async setWebhookSecret(webhookId: string, secret: string): Promise { - this.assertHubId(webhookId); + if (!HUB_ID.test(webhookId)) throw new BadRequestException(`invalid hub id: ${webhookId}`); await this.http.put(`/webhooks/${webhookId}/secret`, { secret }); } @@ -258,7 +257,7 @@ export class EvoHubClient { * register-with-own-secret, igual ao provision. */ async ensureChannelWebhook(channelId: string, webhookUrl: string): Promise { - this.assertHubId(channelId); + if (!HUB_ID.test(channelId)) throw new BadRequestException(`invalid hub id: ${channelId}`); const associated = await this.listChannelWebhooks(channelId); const match = associated.find((w) => w.url === webhookUrl); @@ -336,7 +335,7 @@ export class EvoHubClient { * Evolution; 'byo' exige channel_credentials no hub. */ async connectToMeta(channelId: string, req: MetaConnectRequest): Promise { - this.assertHubId(channelId); + if (!HUB_ID.test(channelId)) throw new BadRequestException(`invalid hub id: ${channelId}`); const { data } = await this.http.post(`/channels/${channelId}/meta-connect`, req); return data; } From 49bbdfe8a6485932fc2a6b40d7a43da27381e4b6 Mon Sep 17 00:00:00 2001 From: Guilherme Gomes Date: Mon, 13 Jul 2026 19:29:48 -0300 Subject: [PATCH 5/5] docs(evohub): write the comments this branch adds in English Repo convention is English code comments; the EvoHub comments introduced by this branch were in Portuguese. No behaviour change. Refs EVO-2098 --- .../channel/evohub/evohub.client.ts | 80 +++++++++---------- .../evohub/evohub.controlplane.router.ts | 12 +-- 2 files changed, 47 insertions(+), 45 deletions(-) diff --git a/src/api/integrations/channel/evohub/evohub.client.ts b/src/api/integrations/channel/evohub/evohub.client.ts index be0e8cc9b6..b9114d95ca 100644 --- a/src/api/integrations/channel/evohub/evohub.client.ts +++ b/src/api/integrations/channel/evohub/evohub.client.ts @@ -53,15 +53,15 @@ export interface HubChannel { meta_connection?: HubMetaConnection | null; } -// SSRF guard: channel/webhook ids do hub são UUID (o hub faz uuid.Parse). O teste roda -// INLINE em cada método que interpola o id no path da request — recusa path/URL injection -// vinda de req.params/req.body em vez de repassá-la ao control-plane. Extrair o guard para -// um método (`assertHubId`) protege igual em runtime, mas a análise de fluxo do CodeQL não -// o reconhece como barreira através da fronteira de função e o js/request-forgery continua -// acusando o sink; o teste inline é o que satisfaz scanner e runtime ao mesmo tempo. +// SSRF guard: hub channel/webhook ids are UUIDs (the hub runs uuid.Parse). The test is +// INLINE in every method that interpolates an id into the request path, so path/URL +// injection coming from req.params/req.body is rejected instead of being forwarded to the +// control-plane. Keep it inline: a guard extracted into its own method protects at runtime +// just the same, but CodeQL's taint analysis does not carry a throwing guard across a +// function boundary and js/request-forgery keeps flagging the sink. 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}$/; -// Webhook do hub (WebhookResponse — webhook.go:116). Só os campos que usamos. +// Hub webhook (WebhookResponse — webhook.go:116). Only the fields we use. export interface HubWebhookInfo { id: string; name?: string; @@ -174,16 +174,16 @@ export class EvoHubClient { return this.listChannels(type); } - // ---- Webhooks (inbound do hub -> evolution-api) ---- + // ---- Webhooks (hub inbound -> evolution-api) ---- - /** Webhooks já ASSOCIADOS ao canal: GET /api/v1/channels/:id/webhooks → { webhooks, count }. */ + /** Webhooks already ASSOCIATED with the channel: GET /api/v1/channels/:id/webhooks → { webhooks, count }. */ async listChannelWebhooks(channelId: string): Promise { if (!HUB_ID.test(channelId)) throw new BadRequestException(`invalid hub id: ${channelId}`); const { data } = await this.http.get(`/channels/${channelId}/webhooks`); return this.normalizeWebhookList(data); } - /** Todos os webhooks do usuário da API-key: GET /api/v1/webhooks. */ + /** Every webhook owned by the API-key user: GET /api/v1/webhooks. */ async listWebhooks(): Promise { const { data } = await this.http.get('/webhooks'); return this.normalizeWebhookList(data); @@ -196,7 +196,7 @@ export class EvoHubClient { return []; } - /** POST /api/v1/webhooks/:id/associate — associa um webhook existente ao canal. */ + /** POST /api/v1/webhooks/:id/associate — associates an existing webhook with the channel. */ async associateWebhook(webhookId: string, channelId: string): Promise { if (!HUB_ID.test(webhookId)) throw new BadRequestException(`invalid hub id: ${webhookId}`); if (!HUB_ID.test(channelId)) throw new BadRequestException(`invalid hub id: ${channelId}`); @@ -204,33 +204,33 @@ export class EvoHubClient { } /** - * PUT /api/v1/webhooks/:id/status — reativa/desativa um webhook. O hub só aceita - * `active`|`inactive`; reativar com `active` é o caminho oficial para tirar um - * webhook do estado auto-`disabled` (webhook.go:104). + * PUT /api/v1/webhooks/:id/status — activates/deactivates a webhook. The hub only accepts + * `active`|`inactive`; setting `active` is its official way out of the auto-`disabled` + * state (webhook.go:104). */ async setWebhookStatus(webhookId: string, status: 'active' | 'inactive'): Promise { if (!HUB_ID.test(webhookId)) throw new BadRequestException(`invalid hub id: ${webhookId}`); await this.http.put(`/webhooks/${webhookId}/status`, { status }); } - /** PUT /api/v1/webhooks/:id/secret — grava o secret usado para assinar o inbound. */ + /** PUT /api/v1/webhooks/:id/secret — stores the secret the hub signs the inbound with. */ async setWebhookSecret(webhookId: string, secret: string): Promise { if (!HUB_ID.test(webhookId)) throw new BadRequestException(`invalid hub id: ${webhookId}`); await this.http.put(`/webhooks/${webhookId}/secret`, { secret }); } /** - * Reescreve o WEBHOOK_SECRET configurado num webhook que estamos REUSANDO. O hub só - * assina o inbound quando o webhook tem secret gravado (webhook_dispatcher.go:983) e - * o WebhookResponse NÃO expõe o secret nem um `has_secret` (webhook.go:116) — não há - * como detectar drift, então reescrevemos sempre. Sem isso, um webhook criado em soft - * mode (secret vazio) ou com secret antigo é reusado como se estivesse pronto, o hub - * entrega sem assinatura/com assinatura errada, o verifyHmac responde 401 e o canal - * fica surdo — o mesmo sintoma que este fluxo existe para matar. Pior: o webhook é - * COMPARTILHADO entre os canais, então o 401 repetido leva o hub a auto-desabilitá-lo - * e derruba o inbound de todos eles. + * Rewrites the configured WEBHOOK_SECRET on a webhook we are REUSING. The hub only signs + * the inbound delivery when the webhook has a secret stored (webhook_dispatcher.go:983), + * and its WebhookResponse exposes neither the secret nor a `has_secret` flag + * (webhook.go:116) — drift is undetectable from here, so we always rewrite. Without this, + * a webhook created in soft mode (empty secret) or carrying a rotated-away secret is + * reused as if it were ready: the hub delivers unsigned (or wrongly signed), verifyHmac + * answers 401 and the channel goes deaf — the very symptom this flow exists to kill. + * Worse, the webhook is SHARED across channels, so the repeated 401 makes the hub + * auto-disable it and takes the inbound of every channel down with it. * - * Secret vazio → soft mode: o inbound aceita sem assinatura, nada a garantir. + * Empty secret → soft mode: the inbound accepts unsigned payloads, nothing to enforce. */ private async syncWebhookSecret(webhookId: string): Promise { const secret = this.configService.get('EVOLUTION_HUB').WEBHOOK_SECRET; @@ -239,22 +239,22 @@ export class EvoHubClient { } /** - * Garante (idempotente) que o canal tem um webhook ATIVO apontando para - * `webhookUrl` — o caminho single-shot do provision não existe no link-existing, - * e sem webhook (ou com webhook não-`active`) o canal envia mas nunca RECEBE. + * Idempotently guarantees the channel has an ACTIVE webhook pointing at `webhookUrl`. + * The single-shot registration the provision flow gets does not exist in link-existing, + * and with no webhook (or a non-`active` one) the channel SENDS but never RECEIVES. * - * O dispatcher do hub só entrega quando `status == 'active'` (webhook_dispatcher.go:294); - * um webhook em `disabled` (auto-desativado após falhas de entrega) ou `inactive` - * casa a URL mas NÃO entrega, então reativamos em vez de tratar como pronto — - * senão o re-link responde 201 e o canal segue surdo. Em todo REUSO o secret é - * reescrito antes da reativação (syncWebhookSecret) — um webhook com secret - * defasado tomaria 401 no inbound e voltaria a ser auto-desabilitado. Ordem: - * 1) já associado ao canal com a mesma URL → garante secret e reativa se preciso; - * 2) webhook do usuário com a mesma URL → garante secret, reativa e associa - * (all_channels já cobre o canal, então só garante que está ativo); - * 3) cria novo com `channels: [channelId]` (single-shot) e `events: []` - * (vazio = TODOS os eventos — webhook_service.go:98). Secret = recipe - * register-with-own-secret, igual ao provision. + * The hub's dispatcher only delivers when `status == 'active'` (webhook_dispatcher.go:294); + * a webhook in `disabled` (auto-disabled after repeated delivery failures) or `inactive` + * matches the URL but does NOT deliver, so we reactivate instead of treating it as ready — + * otherwise the re-link answers 201 and the channel stays deaf. Every REUSE path rewrites + * the secret before reactivating (syncWebhookSecret): a webhook with a stale secret would + * take another 401 on the inbound and get auto-disabled right back. Order: + * 1) already associated with the channel, same URL → enforce the secret, reactivate if needed; + * 2) user-level webhook with the same URL → enforce the secret, reactivate and associate + * (all_channels already covers the channel, so only ensure it is active); + * 3) none → create it with `channels: [channelId]` (single-shot) and `events: []` + * (empty = ALL events — webhook_service.go:98). Secret follows the + * register-with-own-secret recipe, same as provision. */ async ensureChannelWebhook(channelId: string, webhookUrl: string): Promise { if (!HUB_ID.test(channelId)) throw new BadRequestException(`invalid hub id: ${channelId}`); diff --git a/src/api/integrations/channel/evohub/evohub.controlplane.router.ts b/src/api/integrations/channel/evohub/evohub.controlplane.router.ts index 3a703ba5c2..e4e629b060 100644 --- a/src/api/integrations/channel/evohub/evohub.controlplane.router.ts +++ b/src/api/integrations/channel/evohub/evohub.controlplane.router.ts @@ -76,9 +76,10 @@ export class EvoHubControlPlaneRouter extends RouterBroker { return res.status(422).json({ error: 'hub channel missing token or phone_number_id' }); } - // 2) garante o webhook inbound no hub ANTES de criar a Instance (o provision - // registra via single-shot; sem isso o canal vinculado envia mas nunca - // recebe). Ordem proposital: se falhar, nada foi criado e o retry é seguro. + // 2) ensure the inbound webhook on the hub BEFORE creating the Instance (provision + // registers it single-shot; without this the linked channel sends but never + // receives). The order is deliberate: on failure nothing was created, so the + // retry is safe. const serverUrl = configService.get('SERVER').URL; if (serverUrl) { try { @@ -91,8 +92,9 @@ export class EvoHubControlPlaneRouter extends RouterBroker { } } - // 3) cria a Instance EVOHUB pelo caminho padrão, com o token JÁ resolvido - // (flui pelo channel.controller.init() guard sem relaxá-lo — contrato §5). + // 3) create the EVOHUB Instance through the standard path, with the token ALREADY + // resolved (flows through the channel.controller.init() guard without relaxing + // it — contract §5). const created = await instanceController.createInstance({ instanceName: (req.body.instanceName as string) || `evohub-${phoneNumberId}`, integration: Integration.EVOHUB,