Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions src/api/integrations/channel/evohub/evohub.client.ts
Original file line number Diff line number Diff line change
@@ -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) ----
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -143,6 +161,7 @@ export class EvoHubClient {
* server-side; o front NUNCA vê o token.
*/
async getChannel(id: string): Promise<HubChannel> {
if (!HUB_ID.test(id)) throw new BadRequestException(`invalid hub id: ${id}`);
const { data } = await this.http.get(`/channels/${id}`);
return data;
}
Expand All @@ -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<HubWebhookInfo[]> {
if (!HUB_ID.test(channelId)) throw new BadRequestException(`invalid hub id: ${channelId}`);
const { data } = await this.http.get(`/channels/${channelId}/webhooks`);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return this.normalizeWebhookList(data);
}

/** Every webhook owned by the API-key user: GET /api/v1/webhooks. */
async listWebhooks(): Promise<HubWebhookInfo[]> {
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<void> {
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<void> {
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<void> {
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<void> {
const secret = this.configService.get<EvolutionHub>('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<void> {
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<EvolutionHub>('EVOLUTION_HUB');
const body: Record<string, any> = {
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 ----

/**
Expand Down Expand Up @@ -203,6 +335,7 @@ export class EvoHubClient {
* Evolution; 'byo' exige channel_credentials no hub.
*/
async connectToMeta(channelId: string, req: MetaConnectRequest): Promise<MetaConnectResponse> {
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;
}
Expand Down
21 changes: 19 additions & 2 deletions src/api/integrations/channel/evohub/evohub.controlplane.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpServer>('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,
Expand Down
Loading