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..b9114d95ca 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) ---- @@ -52,6 +53,23 @@ export interface HubChannel { meta_connection?: HubMetaConnection | null; } +// 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}$/; + +// Hub webhook (WebhookResponse — webhook.go:116). Only the fields we use. +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; @@ -143,6 +161,7 @@ export class EvoHubClient { * server-side; o front NUNCA vê o token. */ async getChannel(id: string): Promise { + if (!HUB_ID.test(id)) throw new BadRequestException(`invalid hub id: ${id}`); const { data } = await this.http.get(`/channels/${id}`); return data; } @@ -155,6 +174,119 @@ export class EvoHubClient { return this.listChannels(type); } + // ---- Webhooks (hub inbound -> evolution-api) ---- + + /** 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); + } + + /** 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); + } + + 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 — 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}`); + await this.http.post(`/webhooks/${webhookId}/associate`, { channel_id: channelId }); + } + + /** + * 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 — 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 }); + } + + /** + * 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. + * + * 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; + if (!secret) return; + await this.setWebhookSecret(webhookId, secret); + } + + /** + * 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. + * + * 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}`); + + 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; + } + + 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; + } + + 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 ---- /** @@ -203,6 +335,7 @@ export class EvoHubClient { * Evolution; 'byo' exige channel_credentials no hub. */ async connectToMeta(channelId: string, req: MetaConnectRequest): Promise { + 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; } diff --git a/src/api/integrations/channel/evohub/evohub.controlplane.router.ts b/src/api/integrations/channel/evohub/evohub.controlplane.router.ts index 022a821a6a..e4e629b060 100644 --- a/src/api/integrations/channel/evohub/evohub.controlplane.router.ts +++ b/src/api/integrations/channel/evohub/evohub.controlplane.router.ts @@ -76,8 +76,25 @@ 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 - // (flui pelo channel.controller.init() guard sem relaxá-lo — contrato §5). + // 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 { + 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) 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,