11import { ConfigService , EvolutionHub } from '@config/env.config' ;
22import { Logger } from '@config/logger.config' ;
3+ import { BadRequestException } from '@exceptions' ;
34import 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 - 9 a - f A - F ] { 8 } - [ 0 - 9 a - f A - F ] { 4 } - [ 0 - 9 a - f A - F ] { 4 } - [ 0 - 9 a - f A - F ] { 4 } - [ 0 - 9 a - f A - 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) ----
5674export 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 }
0 commit comments