diff --git a/src/api/abstract/abstract.router.ts b/src/api/abstract/abstract.router.ts index f96d8aeafe..9f2066be18 100644 --- a/src/api/abstract/abstract.router.ts +++ b/src/api/abstract/abstract.router.ts @@ -50,8 +50,17 @@ export abstract class RouterBroker { const isInstanceCreate = request.originalUrl.includes('/instance/create'); + // On instance-scoped routes the URL param (:instanceName) is the + // authenticated identity, so query/body must not override it (CVE-2435, + // #2549). On param-less routes (e.g. GET /instance/fetchInstances) there + // is no URL-derived identity — there the instanceId/instanceName query + // params ARE the legitimate filter and must be preserved, otherwise the + // controller falls through to returning ALL instances. + const hasUrlInstance = Boolean(request.params?.instanceName); + if (request?.query && Object.keys(request.query).length > 0) { - Object.assign(instance, sanitizeUntrustedInput(request.query as Record)); + const query = request.query as Record; + Object.assign(instance, hasUrlInstance ? sanitizeUntrustedInput(query) : query); } if (isInstanceCreate) { diff --git a/src/api/dto/sendMessage.dto.ts b/src/api/dto/sendMessage.dto.ts index b3d87e5e08..d2dfb926ec 100644 --- a/src/api/dto/sendMessage.dto.ts +++ b/src/api/dto/sendMessage.dto.ts @@ -101,10 +101,14 @@ export class SendAudioDto extends Metadata { audio: string; } -export type TypeButton = 'reply' | 'copy' | 'url' | 'call' | 'pix'; +export type TypeButton = 'reply' | 'copy' | 'url' | 'call' | 'pix' | 'flow'; export type KeyType = 'phone' | 'email' | 'cpf' | 'cnpj' | 'random'; +export type FlowAction = 'navigate' | 'data_exchange'; + +export type FlowMode = 'published' | 'draft'; + export class Button { type: TypeButton; displayText?: string; @@ -116,6 +120,14 @@ export class Button { name?: string; keyType?: KeyType; key?: string; + // WhatsApp Flows (galaxy_message) fields + flowId?: string; + flowToken?: string; + flowCta?: string; + flowAction?: FlowAction; + flowActionPayload?: Record; + flowMessageVersion?: string; + flowMode?: FlowMode; } export class SendButtonsDto extends Metadata { diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index aeb692c592..43c6f24e63 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -3788,6 +3788,16 @@ export class BaileysStartupService extends ChannelStartupService { ], share_payment_status: false, }), + flow: () => + toString({ + flow_message_version: button.flowMessageVersion ?? '3', + flow_token: button.flowToken ?? this.generateRandomId(), + flow_id: button.flowId, + flow_cta: button.flowCta ?? button.displayText, + flow_action: button.flowAction ?? 'navigate', + ...(button.flowActionPayload ? { flow_action_payload: button.flowActionPayload } : {}), + mode: button.flowMode ?? 'published', + }), }; return json[button.type]?.() || ''; @@ -3799,6 +3809,7 @@ export class BaileysStartupService extends ChannelStartupService { ['url', 'cta_url'], ['call', 'cta_call'], ['pix', 'payment_info'], + ['flow', 'galaxy_message'], ]); private readonly mapKeyType = new Map([ @@ -3820,6 +3831,7 @@ export class BaileysStartupService extends ChannelStartupService { ).length; const hasReplyButtons = replyCount > 0; const hasPixButton = data.buttons.some((btn) => btn.type === 'pix'); + const hasFlowButton = data.buttons.some((btn) => btn.type === 'flow'); const hasCTAButtons = ctaCount > 0; /* ========================= @@ -3827,9 +3839,67 @@ export class BaileysStartupService extends ChannelStartupService { * * WhatsApp's native_flow with name="mixed" (see buildInteractiveBizNode) * renders quick_reply + cta_url + cta_call + cta_copy together. PIX - * (`payment_info`) uses a different template and must travel alone. + * (`payment_info`) and Flows (`galaxy_message`) use their own templates + * and must travel alone. * ========================= */ + // WhatsApp Flows (galaxy_message) — renders its own interactive form and + // must be the only button in the message. + if (hasFlowButton) { + if (data.buttons.length > 1) { + throw new BadRequestException('Only one flow button is allowed'); + } + if (hasReplyButtons || hasCTAButtons || hasPixButton) { + throw new BadRequestException('Flow button cannot be mixed with other button types'); + } + + const flowButton = data.buttons[0]; + if (!flowButton.flowId) { + throw new BadRequestException('flowId is required for a flow button'); + } + + const message: proto.IMessage = { + interactiveMessage: { + body: { + text: (() => { + let text = `*${data.title}*`; + if (data?.description) { + text += `\n\n${data.description}`; + } + return text; + })(), + }, + footer: data?.footer ? { text: data.footer } : undefined, + nativeFlowMessage: { + buttons: [ + { + name: this.mapType.get('flow'), + buttonParamsJson: this.toJSONString(flowButton), + }, + ], + messageParamsJson: JSON.stringify({ + from: 'api', + templateId: v4(), + }), + }, + }, + }; + + return await this.sendMessageWithTyping( + data.number, + message, + { + delay: data?.delay, + presence: 'composing', + quoted: data?.quoted, + mentionsEveryOne: data?.mentionsEveryOne, + mentioned: data?.mentioned, + }, + false, + [buildInteractiveBizNode()], + ); + } + // Per-type ceilings on a mixed message — keep within WhatsApp's // rendered limits (quick_reply ≤ 3, CTA ≤ 2) and cap the total so the // UI doesn't truncate. diff --git a/src/validate/message.schema.ts b/src/validate/message.schema.ts index db76fe1c85..262a082985 100644 --- a/src/validate/message.schema.ts +++ b/src/validate/message.schema.ts @@ -422,7 +422,7 @@ export const buttonsMessageSchema: JSONSchema7 = { properties: { type: { type: 'string', - enum: ['reply', 'copy', 'url', 'call', 'pix'], + enum: ['reply', 'copy', 'url', 'call', 'pix', 'flow'], }, displayText: { type: 'string' }, id: { type: 'string' }, @@ -432,6 +432,13 @@ export const buttonsMessageSchema: JSONSchema7 = { name: { type: 'string' }, keyType: { type: 'string', enum: ['phone', 'email', 'cpf', 'cnpj', 'random'] }, key: { type: 'string' }, + flowId: { type: 'string' }, + flowToken: { type: 'string' }, + flowCta: { type: 'string' }, + flowAction: { type: 'string', enum: ['navigate', 'data_exchange'] }, + flowActionPayload: { type: 'object' }, + flowMessageVersion: { type: 'string' }, + flowMode: { type: 'string', enum: ['published', 'draft'] }, }, required: ['type'], ...isNotEmpty('id', 'url', 'phoneNumber'),