From a68167323bb4dd7a018dec99d8d6da43518169dd Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 07:17:03 +0700 Subject: [PATCH 1/5] feat(media): add SSRF-guarded loadRemoteMediaBuffer (neutral URL->Buffer) --- .gitignore | 1 + src/common/media/load-remote-media.spec.ts | 55 ++++++++++++++++++++ src/common/media/load-remote-media.ts | 60 ++++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 src/common/media/load-remote-media.spec.ts create mode 100644 src/common/media/load-remote-media.ts diff --git a/.gitignore b/.gitignore index f89de7af..5ba7187f 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ Thumbs.db # Data and storage data/ media/ +!src/**/media/ uploads/ .wwebjs_auth/ .wwebjs_cache/ diff --git a/src/common/media/load-remote-media.spec.ts b/src/common/media/load-remote-media.spec.ts new file mode 100644 index 00000000..757909b5 --- /dev/null +++ b/src/common/media/load-remote-media.spec.ts @@ -0,0 +1,55 @@ +import { loadRemoteMediaBuffer } from './load-remote-media'; +import { SsrfBlockedError } from '../security/ssrf-guard'; + +describe('loadRemoteMediaBuffer', () => { + const realFetch = global.fetch; + afterEach(() => { + global.fetch = realFetch; + delete process.env.MEDIA_DOWNLOAD_MAX_BYTES; + }); + + // Build a Response-like with a single-chunk body stream. + const fakeResponse = (bytes: number[], headers: Record) => ({ + ok: true, + status: 200, + headers: { get: (k: string) => headers[k.toLowerCase()] ?? null }, + body: { + getReader: () => { + let done = false; + return { + read: () => + done + ? Promise.resolve({ done: true, value: undefined }) + : ((done = true), Promise.resolve({ done: false, value: new Uint8Array(bytes) })), + cancel: () => Promise.resolve(), + }; + }, + }, + }); + + it('blocks an internal URL via the SSRF guard before any fetch', async () => { + const fetchMock = jest.fn(); + global.fetch = fetchMock as typeof fetch; + await expect(loadRemoteMediaBuffer('http://127.0.0.1/x.png')).rejects.toBeInstanceOf(SsrfBlockedError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('fetches a public URL and returns the bytes + content-type', async () => { + const fetchMock = jest + .fn() + .mockResolvedValue(fakeResponse([1, 2, 3], { 'content-type': 'image/png', 'content-length': '3' })); + global.fetch = fetchMock as typeof fetch; + const res = await loadRemoteMediaBuffer('http://8.8.8.8/x.png'); + expect(res.mimetype).toBe('image/png'); + expect(Array.from(res.data)).toEqual([1, 2, 3]); + // Never follow redirects (a 3xx could reach an internal host the guard never validated). + expect(fetchMock).toHaveBeenCalledWith('http://8.8.8.8/x.png', expect.objectContaining({ redirect: 'error' })); + }); + + it('rejects a body that exceeds the byte cap', async () => { + process.env.MEDIA_DOWNLOAD_MAX_BYTES = '2'; + const fetchMock = jest.fn().mockResolvedValue(fakeResponse([1, 2, 3], { 'content-type': 'image/png' })); + global.fetch = fetchMock as typeof fetch; + await expect(loadRemoteMediaBuffer('http://8.8.8.8/x.png')).rejects.toThrow(/exceeds/i); + }); +}); diff --git a/src/common/media/load-remote-media.ts b/src/common/media/load-remote-media.ts new file mode 100644 index 00000000..4f0573d4 --- /dev/null +++ b/src/common/media/load-remote-media.ts @@ -0,0 +1,60 @@ +import { assertSafeFetchUrl } from '../security/ssrf-guard'; + +/** Default cap on a server-side media download: 50 MiB (overridable via MEDIA_DOWNLOAD_MAX_BYTES). */ +const DEFAULT_MEDIA_MAX_BYTES = 50 * 1024 * 1024; +/** Default timeout for a server-side media download: 30s (overridable via MEDIA_DOWNLOAD_TIMEOUT_MS). */ +const DEFAULT_MEDIA_TIMEOUT_MS = 30_000; + +function positiveIntFromEnv(name: string, fallback: number): number { + const parsed = Number.parseInt(process.env[name] ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +/** + * Fetch remote media as a Buffer for sending, with an SSRF host guard, a byte cap, and a timeout. + * The guard runs BEFORE any network call, so an internal/reserved URL throws `SsrfBlockedError` + * and no outbound socket is opened. `redirect: 'error'` is used because the guard only validated + * the original host — a followed 3xx could reach an internal target. The cap is enforced while + * streaming (Content-Length may be absent or wrong) to bound memory use. + * + * Engine-neutral: returns raw bytes + the response content-type, so any engine adapter can use it. + */ +export async function loadRemoteMediaBuffer(url: string): Promise<{ data: Buffer; mimetype: string }> { + await assertSafeFetchUrl(url); + + const maxBytes = positiveIntFromEnv('MEDIA_DOWNLOAD_MAX_BYTES', DEFAULT_MEDIA_MAX_BYTES); + const timeoutMs = positiveIntFromEnv('MEDIA_DOWNLOAD_TIMEOUT_MS', DEFAULT_MEDIA_TIMEOUT_MS); + + const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(timeoutMs) }); + if (!response.ok) { + throw new Error(`Media fetch failed with status ${response.status}`); + } + + const declaredLength = Number(response.headers.get('content-length') ?? ''); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new Error(`Media exceeds the ${maxBytes}-byte limit`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('Media response has no body'); + } + + const chunks: Buffer[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + throw new Error(`Media exceeds the ${maxBytes}-byte limit`); + } + chunks.push(Buffer.from(value)); + } + + const mimetype = (response.headers.get('content-type') ?? '').split(';')[0].trim(); + return { data: Buffer.concat(chunks), mimetype }; +} From 62d144011ee2732af73b2cfd76e732f95507efde Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 07:22:37 +0700 Subject: [PATCH 2/5] feat(engine): baileys media sends (image/video/audio/document/sticker) --- src/engine/adapters/baileys.adapter.spec.ts | 90 ++++++++++++++++++++- src/engine/adapters/baileys.adapter.ts | 63 +++++++++++---- 2 files changed, 136 insertions(+), 17 deletions(-) diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index c087d1f4..02301915 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -1,5 +1,9 @@ import { EventEmitter } from 'events'; +jest.mock('../../common/media/load-remote-media', () => ({ + loadRemoteMediaBuffer: jest.fn(), +})); + // A fake Baileys socket: an event emitter wearing the methods the adapter calls. class FakeSock extends EventEmitter { public ev = { @@ -39,6 +43,7 @@ import { BaileysAdapter } from './baileys.adapter'; import { EngineStatus, EngineEventCallbacks } from '../interfaces/whatsapp-engine.interface'; import { EngineNotReadyError } from '../../common/errors/engine-not-ready.error'; import { EngineNotSupportedError } from '../../common/errors/engine-not-supported.error'; +import { loadRemoteMediaBuffer } from '../../common/media/load-remote-media'; const newAdapter = (): BaileysAdapter => new BaileysAdapter({ sessionId: 'sess-1', authDir: './data/baileys' }); @@ -142,9 +147,6 @@ describe('BaileysAdapter capability gating', () => { const adapter = newAdapter(); await expect(adapter.getGroups()).rejects.toBeInstanceOf(EngineNotSupportedError); await expect(adapter.getChats()).rejects.toBeInstanceOf(EngineNotSupportedError); - await expect(adapter.sendImageMessage('x', { mimetype: 'image/png', data: 'AAA' })).rejects.toBeInstanceOf( - EngineNotSupportedError, - ); }); }); @@ -270,3 +272,85 @@ describe('BaileysAdapter inbound fan-out', () => { expect(onMessageAck).toHaveBeenCalledWith('OUT1', 'delivered'); }); }); + +describe('BaileysAdapter media sends', () => { + beforeEach(() => { + fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' }; + jest.clearAllMocks(); + fakeSock.sendMessage.mockResolvedValue({ key: { id: 'M1' }, messageTimestamp: 1700000005 }); + }); + + const ready = async (): Promise => { + const adapter = newAdapter(); + await adapter.initialize({}); + fakeSock.fire('connection.update', { connection: 'open' }); + return adapter; + }; + + it('sendImageMessage sends a Buffer image with caption + mimetype', async () => { + const adapter = await ready(); + const buf = Buffer.from([1, 2, 3]); + const res = await adapter.sendImageMessage('628111@s.whatsapp.net', { + mimetype: 'image/png', + data: buf, + caption: 'hi', + }); + expect(fakeSock.sendMessage).toHaveBeenCalledWith('628111@s.whatsapp.net', { + image: buf, + caption: 'hi', + mimetype: 'image/png', + }); + expect(res).toEqual({ id: 'M1', timestamp: 1700000005 }); + }); + + it('resolves a base64 data string to a Buffer (no URL fetch)', async () => { + const adapter = await ready(); + await adapter.sendDocumentMessage('628111@s.whatsapp.net', { + mimetype: 'application/pdf', + data: Buffer.from('PDFDATA').toString('base64'), + filename: 'doc.pdf', + }); + expect(loadRemoteMediaBuffer).not.toHaveBeenCalled(); + expect(fakeSock.sendMessage).toHaveBeenCalledWith('628111@s.whatsapp.net', { + document: Buffer.from('PDFDATA'), + mimetype: 'application/pdf', + fileName: 'doc.pdf', + }); + }); + + it('fetches a URL data string through the SSRF-guarded loader', async () => { + (loadRemoteMediaBuffer as jest.Mock).mockResolvedValue({ data: Buffer.from([9]), mimetype: 'video/mp4' }); + const adapter = await ready(); + await adapter.sendVideoMessage('628111@s.whatsapp.net', { mimetype: '', data: 'https://cdn.example/v.mp4' }); + expect(loadRemoteMediaBuffer).toHaveBeenCalledWith('https://cdn.example/v.mp4'); + expect(fakeSock.sendMessage).toHaveBeenCalledWith('628111@s.whatsapp.net', { + video: Buffer.from([9]), + caption: undefined, + mimetype: 'video/mp4', + }); + }); + + it('sendAudioMessage sets ptt:false', async () => { + const adapter = await ready(); + await adapter.sendAudioMessage('628111@s.whatsapp.net', { mimetype: 'audio/mp4', data: Buffer.from([1]) }); + expect(fakeSock.sendMessage).toHaveBeenCalledWith('628111@s.whatsapp.net', { + audio: Buffer.from([1]), + mimetype: 'audio/mp4', + ptt: false, + }); + }); + + it('sendStickerMessage sends the sticker buffer', async () => { + const adapter = await ready(); + await adapter.sendStickerMessage('628111@s.whatsapp.net', { mimetype: 'image/webp', data: Buffer.from([7]) }); + expect(fakeSock.sendMessage).toHaveBeenCalledWith('628111@s.whatsapp.net', { sticker: Buffer.from([7]) }); + }); + + it('media sends reject with EngineNotReadyError before the connection is open', async () => { + const adapter = newAdapter(); + await adapter.initialize({}); + await expect( + adapter.sendImageMessage('x', { mimetype: 'image/png', data: Buffer.from([1]) }), + ).rejects.toBeInstanceOf(EngineNotReadyError); + }); +}); diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index 0082eeec..29152e38 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -5,7 +5,7 @@ import makeWASocket, { getContentType, useMultiFileAuthState, } from '@whiskeysockets/baileys'; -import type { WAMessage, WASocket } from '@whiskeysockets/baileys'; +import type { AnyMessageContent, WAMessage, WASocket } from '@whiskeysockets/baileys'; import { buildIncomingMessageFromBaileys, mapBaileysStatus } from './baileys-message-mapper'; import type { ILogger } from '@whiskeysockets/baileys/lib/Utils/logger.js'; import { @@ -34,6 +34,7 @@ import { ChatSummary, TextStatusOptions, } from '../interfaces/whatsapp-engine.interface'; +import { loadRemoteMediaBuffer } from '../../common/media/load-remote-media'; import { EngineNotReadyError } from '../../common/errors/engine-not-ready.error'; import { EngineNotSupportedError } from '../../common/errors/engine-not-supported.error'; import { createLogger } from '../../common/services/logger.service'; @@ -249,30 +250,45 @@ export class BaileysAdapter implements IWhatsAppEngine { await this.sock!.sendPresenceUpdate(presence, chatId); } - // ----- Gated: not supported by this minimal slice (no store) ----- - /* eslint-disable @typescript-eslint/no-unused-vars */ + async sendImageMessage(chatId: string, media: MediaInput): Promise { + this.ensureReady(); + const { data, mimetype } = await this.resolveMediaBuffer(media); + return this.sendContent(chatId, { image: data, caption: media.caption, mimetype }); + } - sendImageMessage(_chatId: string, _media: MediaInput): Promise { - return this.unsupported('sendImageMessage'); + async sendVideoMessage(chatId: string, media: MediaInput): Promise { + this.ensureReady(); + const { data, mimetype } = await this.resolveMediaBuffer(media); + return this.sendContent(chatId, { video: data, caption: media.caption, mimetype }); } - sendVideoMessage(_chatId: string, _media: MediaInput): Promise { - return this.unsupported('sendVideoMessage'); + + async sendAudioMessage(chatId: string, media: MediaInput): Promise { + this.ensureReady(); + const { data, mimetype } = await this.resolveMediaBuffer(media); + return this.sendContent(chatId, { audio: data, mimetype, ptt: false }); } - sendAudioMessage(_chatId: string, _media: MediaInput): Promise { - return this.unsupported('sendAudioMessage'); + + async sendDocumentMessage(chatId: string, media: MediaInput): Promise { + this.ensureReady(); + const { data, mimetype } = await this.resolveMediaBuffer(media); + return this.sendContent(chatId, { document: data, mimetype, fileName: media.filename ?? 'file' }); } - sendDocumentMessage(_chatId: string, _media: MediaInput): Promise { - return this.unsupported('sendDocumentMessage'); + + async sendStickerMessage(chatId: string, media: MediaInput): Promise { + this.ensureReady(); + const { data } = await this.resolveMediaBuffer(media); + return this.sendContent(chatId, { sticker: data }); } + + // ----- Gated: not supported by this minimal slice (no store) ----- + /* eslint-disable @typescript-eslint/no-unused-vars */ + sendLocationMessage(_chatId: string, _location: LocationInput): Promise { return this.unsupported('sendLocationMessage'); } sendContactMessage(_chatId: string, _contact: ContactCard): Promise { return this.unsupported('sendContactMessage'); } - sendStickerMessage(_chatId: string, _media: MediaInput): Promise { - return this.unsupported('sendStickerMessage'); - } replyToMessage(_chatId: string, _quotedMsgId: string, _text: string): Promise { return this.unsupported('replyToMessage'); } @@ -481,6 +497,25 @@ export class BaileysAdapter implements IWhatsAppEngine { return typeof ts === 'number' ? ts : ts.toNumber(); } + /** Resolve a MediaInput's data (Buffer | base64 string | http(s) URL) to bytes + mimetype. */ + private async resolveMediaBuffer(media: MediaInput): Promise<{ data: Buffer; mimetype: string }> { + if (Buffer.isBuffer(media.data)) { + return { data: media.data, mimetype: media.mimetype }; + } + if (/^https?:\/\//i.test(media.data)) { + const fetched = await loadRemoteMediaBuffer(media.data); + // Caller's declared mimetype wins; fall back to the response content-type. + return { data: fetched.data, mimetype: media.mimetype || fetched.mimetype }; + } + return { data: Buffer.from(media.data, 'base64'), mimetype: media.mimetype }; + } + + /** Send a Baileys content object and shape the result like the other sends. */ + private async sendContent(chatId: string, content: AnyMessageContent): Promise { + const sent = await this.sock!.sendMessage(chatId, content); + return { id: sent?.key?.id ?? '', timestamp: this.toUnixSeconds(sent?.messageTimestamp) }; + } + private unsupported(method: string): Promise { return Promise.reject(new EngineNotSupportedError(method)); } From c9252a11eba1603df675a3fd340ac59202b157de Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 07:27:07 +0700 Subject: [PATCH 3/5] feat(engine): baileys location + contact sends; advertise media features --- CHANGELOG.md | 1 + src/engine/adapters/baileys.adapter.spec.ts | 42 +++++++++++++++++++++ src/engine/adapters/baileys.adapter.ts | 37 +++++++++++++++--- src/plugins/engines/baileys/index.spec.ts | 10 ++++- src/plugins/engines/baileys/index.ts | 3 +- 5 files changed, 83 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16411ac1..de9cc3a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ plugins instead of in core (#265). - **`auto-reply` reference extension plugin**, first-party and **registered disabled by default** — enable it via `POST /plugins/auto-reply/enable` to exercise the capability layer end-to-end. (#294) - **Baileys engine (minimal slice)** — `ENGINE_TYPE=baileys` now selects a second, browser-free WhatsApp engine built on `@whiskeysockets/baileys` (WebSocket/Noise protocol, no Chromium). This first slice supports linking (QR + pairing code), sending and receiving **text**, recipient resolution, and typing presence; all other operations return HTTP 501 until later slices add a message store. Config: `BAILEYS_AUTH_DIR` (default `./data/baileys`). Proxy is not yet supported on this engine. (#299) +- **Baileys engine — media/location/contact sends.** The Baileys engine (`ENGINE_TYPE=baileys`) can now send image/video/audio/document/sticker, location, and contact messages (slice 2a). URL media is fetched through the same SSRF-guarded path as the whatsapp-web.js engine (host guard + byte cap + timeout, no redirects). Reply/forward/react/delete remain unsupported (HTTP 501) until a later slice adds a message store. (#PR) ### Changed diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 02301915..320228bf 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -150,6 +150,48 @@ describe('BaileysAdapter capability gating', () => { }); }); +describe('BaileysAdapter location + contact sends', () => { + beforeEach(() => { + fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' }; + jest.clearAllMocks(); + fakeSock.sendMessage.mockResolvedValue({ key: { id: 'M2' }, messageTimestamp: 1700000006 }); + }); + + const ready = async (): Promise => { + const adapter = newAdapter(); + await adapter.initialize({}); + fakeSock.fire('connection.update', { connection: 'open' }); + return adapter; + }; + + it('sendLocationMessage maps lat/long + optional name/address', async () => { + const adapter = await ready(); + await adapter.sendLocationMessage('628111@s.whatsapp.net', { + latitude: 24.12, + longitude: 55.11, + description: 'Office', + address: '1 Main St', + }); + expect(fakeSock.sendMessage).toHaveBeenCalledWith('628111@s.whatsapp.net', { + location: { degreesLatitude: 24.12, degreesLongitude: 55.11, name: 'Office', address: '1 Main St' }, + }); + }); + + it('sendContactMessage builds a vCard with the waid', async () => { + const adapter = await ready(); + await adapter.sendContactMessage('628111@s.whatsapp.net', { name: 'John Doe', number: '+1 234-567' }); + const [, call] = fakeSock.sendMessage.mock.calls[0] as [ + string, + { contacts: { displayName: string; contacts: { vcard: string }[] } }, + ]; + expect(call.contacts.displayName).toBe('John Doe'); + const vcard = call.contacts.contacts[0].vcard; + expect(vcard).toContain('FN:John Doe'); + expect(vcard).toContain('waid=1234567:+1 234-567'); + expect(vcard.startsWith('BEGIN:VCARD')).toBe(true); + }); +}); + describe('BaileysAdapter messaging', () => { beforeEach(() => { fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' }; diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index 29152e38..05980eef 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -280,15 +280,28 @@ export class BaileysAdapter implements IWhatsAppEngine { return this.sendContent(chatId, { sticker: data }); } + async sendLocationMessage(chatId: string, location: LocationInput): Promise { + this.ensureReady(); + return this.sendContent(chatId, { + location: { + degreesLatitude: location.latitude, + degreesLongitude: location.longitude, + name: location.description, + address: location.address, + }, + }); + } + + async sendContactMessage(chatId: string, contact: ContactCard): Promise { + this.ensureReady(); + return this.sendContent(chatId, { + contacts: { displayName: contact.name, contacts: [{ vcard: this.buildVCard(contact) }] }, + }); + } + // ----- Gated: not supported by this minimal slice (no store) ----- /* eslint-disable @typescript-eslint/no-unused-vars */ - sendLocationMessage(_chatId: string, _location: LocationInput): Promise { - return this.unsupported('sendLocationMessage'); - } - sendContactMessage(_chatId: string, _contact: ContactCard): Promise { - return this.unsupported('sendContactMessage'); - } replyToMessage(_chatId: string, _quotedMsgId: string, _text: string): Promise { return this.unsupported('replyToMessage'); } @@ -510,6 +523,18 @@ export class BaileysAdapter implements IWhatsAppEngine { return { data: Buffer.from(media.data, 'base64'), mimetype: media.mimetype }; } + /** Build a minimal WhatsApp-compatible vCard from a neutral contact card. */ + private buildVCard(contact: ContactCard): string { + const waid = contact.number.replace(/\D/g, ''); + return [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `FN:${contact.name}`, + `TEL;type=CELL;type=VOICE;waid=${waid}:${contact.number}`, + 'END:VCARD', + ].join('\n'); + } + /** Send a Baileys content object and shape the result like the other sends. */ private async sendContent(chatId: string, content: AnyMessageContent): Promise { const sent = await this.sock!.sendMessage(chatId, content); diff --git a/src/plugins/engines/baileys/index.spec.ts b/src/plugins/engines/baileys/index.spec.ts index 42ba937a..ac5d3325 100644 --- a/src/plugins/engines/baileys/index.spec.ts +++ b/src/plugins/engines/baileys/index.spec.ts @@ -37,8 +37,14 @@ describe('BaileysPlugin.createEngine (opaque config)', () => { ); }); - it('advertises only the minimal supported feature set', () => { - expect(new BaileysPlugin().getFeatures()).toEqual(['text-messages', 'typing-indicator']); + it('advertises the slice-2a supported feature set', () => { + expect(new BaileysPlugin().getFeatures()).toEqual([ + 'text-messages', + 'typing-indicator', + 'media-messages', + 'location-messages', + 'contact-messages', + ]); }); it('reports the baileys library name', () => { diff --git a/src/plugins/engines/baileys/index.ts b/src/plugins/engines/baileys/index.ts index 21af5a7c..d0169f0a 100644 --- a/src/plugins/engines/baileys/index.ts +++ b/src/plugins/engines/baileys/index.ts @@ -47,8 +47,7 @@ export class BaileysPlugin implements IEnginePlugin { } getFeatures(): string[] { - // Minimal slice: text send/receive + typing presence. Everything else throws 501. - return ['text-messages', 'typing-indicator']; + return ['text-messages', 'typing-indicator', 'media-messages', 'location-messages', 'contact-messages']; } getEngineLibrary(): { name: string; version: string } { From 9c077cfc3db2d074c41b1fa5d04516a9d2bd99d0 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 07:35:48 +0700 Subject: [PATCH 4/5] fix(engine): sanitize vCard CRLF; sync baileys e2e feature assertion; mimetype test --- src/engine/adapters/baileys.adapter.spec.ts | 23 +++++++++++++++++++++ src/engine/adapters/baileys.adapter.ts | 9 +++++--- test/baileys-engine.e2e-spec.ts | 8 ++++++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 320228bf..b6e72e11 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -190,6 +190,15 @@ describe('BaileysAdapter location + contact sends', () => { expect(vcard).toContain('waid=1234567:+1 234-567'); expect(vcard.startsWith('BEGIN:VCARD')).toBe(true); }); + + it('sanitizes CRLF in a contact name to prevent vCard line-injection', async () => { + const adapter = await ready(); + await adapter.sendContactMessage('628111@s.whatsapp.net', { name: 'Eve\nEMAIL:evil@x.com', number: '123' }); + const [, call] = fakeSock.sendMessage.mock.calls[0] as [string, { contacts: { contacts: { vcard: string }[] } }]; + const vcard = call.contacts.contacts[0].vcard; + expect(vcard).not.toMatch(/\nEMAIL:evil@x\.com/); + expect(vcard).toContain('FN:Eve EMAIL:evil@x.com'); + }); }); describe('BaileysAdapter messaging', () => { @@ -388,6 +397,20 @@ describe('BaileysAdapter media sends', () => { expect(fakeSock.sendMessage).toHaveBeenCalledWith('628111@s.whatsapp.net', { sticker: Buffer.from([7]) }); }); + it('uses the caller-declared mimetype over the fetched content-type for a URL', async () => { + (loadRemoteMediaBuffer as jest.Mock).mockResolvedValue({ + data: Buffer.from([1]), + mimetype: 'application/octet-stream', + }); + const adapter = await ready(); + await adapter.sendImageMessage('628111@s.whatsapp.net', { mimetype: 'image/png', data: 'https://cdn.example/x' }); + expect(fakeSock.sendMessage).toHaveBeenCalledWith('628111@s.whatsapp.net', { + image: Buffer.from([1]), + caption: undefined, + mimetype: 'image/png', + }); + }); + it('media sends reject with EngineNotReadyError before the connection is open', async () => { const adapter = newAdapter(); await adapter.initialize({}); diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index 05980eef..715a2388 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -525,12 +525,15 @@ export class BaileysAdapter implements IWhatsAppEngine { /** Build a minimal WhatsApp-compatible vCard from a neutral contact card. */ private buildVCard(contact: ContactCard): string { - const waid = contact.number.replace(/\D/g, ''); + const clean = (s: string): string => s.replace(/[\r\n]+/g, ' '); + const name = clean(contact.name); + const number = clean(contact.number); + const waid = number.replace(/\D/g, ''); return [ 'BEGIN:VCARD', 'VERSION:3.0', - `FN:${contact.name}`, - `TEL;type=CELL;type=VOICE;waid=${waid}:${contact.number}`, + `FN:${name}`, + `TEL;type=CELL;type=VOICE;waid=${waid}:${number}`, 'END:VCARD', ].join('\n'); } diff --git a/test/baileys-engine.e2e-spec.ts b/test/baileys-engine.e2e-spec.ts index f6ec4f4e..e48da300 100644 --- a/test/baileys-engine.e2e-spec.ts +++ b/test/baileys-engine.e2e-spec.ts @@ -54,6 +54,12 @@ describe('Baileys engine boot (e2e)', () => { const baileys = engines.find(e => e.id === 'baileys'); expect(baileys).toBeDefined(); expect(baileys?.enabled).toBe(true); - expect(baileys?.features).toEqual(['text-messages', 'typing-indicator']); + expect(baileys?.features).toEqual([ + 'text-messages', + 'typing-indicator', + 'media-messages', + 'location-messages', + 'contact-messages', + ]); }); }); From f69daeb196335c2a4984833f952e1e718ab127e1 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 07:39:40 +0700 Subject: [PATCH 5/5] docs(changelog): reference PR #307 for baileys media sends --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de9cc3a4..cea16a1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ plugins instead of in core (#265). - **`auto-reply` reference extension plugin**, first-party and **registered disabled by default** — enable it via `POST /plugins/auto-reply/enable` to exercise the capability layer end-to-end. (#294) - **Baileys engine (minimal slice)** — `ENGINE_TYPE=baileys` now selects a second, browser-free WhatsApp engine built on `@whiskeysockets/baileys` (WebSocket/Noise protocol, no Chromium). This first slice supports linking (QR + pairing code), sending and receiving **text**, recipient resolution, and typing presence; all other operations return HTTP 501 until later slices add a message store. Config: `BAILEYS_AUTH_DIR` (default `./data/baileys`). Proxy is not yet supported on this engine. (#299) -- **Baileys engine — media/location/contact sends.** The Baileys engine (`ENGINE_TYPE=baileys`) can now send image/video/audio/document/sticker, location, and contact messages (slice 2a). URL media is fetched through the same SSRF-guarded path as the whatsapp-web.js engine (host guard + byte cap + timeout, no redirects). Reply/forward/react/delete remain unsupported (HTTP 501) until a later slice adds a message store. (#PR) +- **Baileys engine — media/location/contact sends.** The Baileys engine (`ENGINE_TYPE=baileys`) can now send image/video/audio/document/sticker, location, and contact messages (slice 2a). URL media is fetched through the same SSRF-guarded path as the whatsapp-web.js engine (host guard + byte cap + timeout, no redirects). Reply/forward/react/delete remain unsupported (HTTP 501) until a later slice adds a message store. (#307) ### Changed