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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Thumbs.db
# Data and storage
data/
media/
!src/**/media/
uploads/
.wwebjs_auth/
.wwebjs_cache/
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. (#307)

### Changed

Expand Down
55 changes: 55 additions & 0 deletions src/common/media/load-remote-media.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) => ({
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);
});
});
60 changes: 60 additions & 0 deletions src/common/media/load-remote-media.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
155 changes: 152 additions & 3 deletions src/engine/adapters/baileys.adapter.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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' });

Expand Down Expand Up @@ -142,9 +147,57 @@ 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,
);
});
});

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<BaileysAdapter> => {
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);
});

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');
});
});

Expand Down Expand Up @@ -270,3 +323,99 @@ 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<BaileysAdapter> => {
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('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({});
await expect(
adapter.sendImageMessage('x', { mimetype: 'image/png', data: Buffer.from([1]) }),
).rejects.toBeInstanceOf(EngineNotReadyError);
});
});
Loading