Skip to content
Closed
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

- **The IPv6 SSRF blocklist now catches NAT64 and 6to4 literals.** A `64:ff9b::/96` (NAT64) or `2002::/16`
(6to4) URL embedding an internal IPv4 (loopback, RFC1918, link-local/metadata) previously slipped past the
guard; the embedded address is now extracted and checked, on hosts where such routing exists. Genuinely
public IPv6 — including a 6to4/NAT64 of a public address — is unaffected.
- **The LibreTranslate plugin client validates its target and refuses redirects.** Its outbound requests
carry the configured `api_key`; they now go through the SSRF guard and use `redirect: error`, so a redirect
can't replay the key to an internal host. A localhost LibreTranslate sidecar must be added to
`SSRF_ALLOWED_HOSTS` when SSRF protection is on.
- **Per-session `proxyUrl` is validated.** It must parse to an `http(s)`/`socks4`/`socks5` URL (credentialed
and SOCKS proxies still accepted); a malformed value is rejected at the API and ignored by the engine
instead of breaking the browser launch.

## [0.4.2] - 2026-06-19

Bug-fix and hardening release: access-control tightening, session-lifecycle resilience, data-migration
Expand Down
13 changes: 13 additions & 0 deletions src/common/security/ssrf-guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ describe('isBlockedAddress', () => {
['::ffff:7f00:1', 'IPv4-mapped loopback (hex)'],
['::ffff:0a00:0001', 'IPv4-mapped RFC1918 (hex, zero-padded)'],
['::ffff:a9fe:a9fe', 'IPv4-mapped cloud metadata 169.254.169.254 (hex)'],
['64:ff9b::a9fe:a9fe', 'NAT64 of cloud metadata 169.254.169.254'],
['64:ff9b::7f00:1', 'NAT64 of loopback 127.0.0.1'],
['64:ff9b::127.0.0.1', 'NAT64 of loopback (dotted tail)'],
['2002:7f00:1::', '6to4 of loopback 127.0.0.1'],
['2002:a9fe:a9fe::', '6to4 of cloud metadata 169.254.169.254'],
['2002:0a00:0001::', '6to4 of RFC1918 10.0.0.1'],
['2002:7f00::', '6to4 of loopback net 127.0.0.0 (low hextet compressed away)'],
['2002:a9fe::', '6to4 of metadata net 169.254.0.0 (compressed)'],
['2002:c0a8::', '6to4 of RFC1918 net 192.168.0.0 (compressed)'],
['::127.0.0.1', 'IPv4-compatible loopback (deprecated, dotted)'],
['::a9fe:a9fe', 'IPv4-compatible cloud metadata (deprecated, hex)'],
])('blocks %s (%s)', ip => {
expect(isBlockedAddress(ip)).toBe(true);
});
Expand All @@ -33,6 +44,8 @@ describe('isBlockedAddress', () => {
['172.32.0.1', 'just outside 172.16/12'],
['2001:4860:4860::8888', 'public IPv6'],
['::ffff:0808:0808', 'IPv4-mapped public 8.8.8.8 (hex)'],
['2002:0808:0808::', '6to4 of public 8.8.8.8 stays allowed'],
['64:ff9b::0808:0808', 'NAT64 of public 8.8.8.8 stays allowed'],
])('allows %s (%s)', ip => {
expect(isBlockedAddress(ip)).toBe(false);
});
Expand Down
50 changes: 50 additions & 0 deletions src/common/security/ssrf-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,38 @@ const BLOCKED_V4: ReadonlyArray<readonly [string, number]> = [
* CGNAT, multicast, IPv6 loopback/ULA/link-local, IPv4-mapped variants).
* Anything that isn't a recognizable public IP is treated as blocked (fail-closed).
*/
/** Two 16-bit hextets → dotted IPv4 string (for IPv4-in-IPv6 embeddings like ::ffff:, 6to4, NAT64). */
function hextetsToV4(hi: number, lo: number): string {
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
}

/**
* Expand a (possibly ::-compressed, possibly dotted-IPv4-tailed) IPv6 literal to its 8 numeric
* hextets, or null if malformed. Full expansion is required so a compressed all-zero embedded segment
* (e.g. 2002:7f00:: → 127.0.0.0) is read as 0x0000 rather than silently skipped.
*/
function expandIPv6(lower: string): number[] | null {
let s = lower;
// Fold a trailing dotted IPv4 (::a.b.c.d) into two hex hextets so the remainder is pure hex.
const dotted = s.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (dotted) {
const octets = dotted.slice(1, 5).map(Number);
if (octets.some(o => o > 255)) return null;
const [a, b, c, d] = octets;
s = s.slice(0, dotted.index) + `${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
}
const halves = s.split('::');
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(':') : [];
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
const gap = 8 - head.length - tail.length;
if (halves.length === 1 ? head.length !== 8 : gap < 1) return null;
const parts = [...head, ...Array<string>(Math.max(gap, 0)).fill('0'), ...tail];
if (parts.length !== 8) return null;
const nums = parts.map(h => (/^[0-9a-f]{1,4}$/.test(h) ? parseInt(h, 16) : NaN));
return nums.some(n => Number.isNaN(n)) ? null : nums;
}

export function isBlockedAddress(ip: string): boolean {
if (isIPv4(ip)) {
const n = ipv4ToInt(ip);
Expand Down Expand Up @@ -99,6 +131,24 @@ export function isBlockedAddress(ip: string): boolean {
const firstHextet = lower.split(':')[0];
if (firstHextet.startsWith('fc') || firstHextet.startsWith('fd')) return true; // ULA fc00::/7
if (/^fe[89ab]/.test(firstHextet)) return true; // link-local fe80::/10

// IPv6 forms that embed an IPv4 — 6to4 (2002::/16), NAT64 (64:ff9b::/96), and the deprecated
// IPv4-compatible ::/96 — are classified by the embedded address so they reach the IPv4 blocklist,
// mirroring the ::ffff: handling above. The literal is fully expanded first so a compressed all-zero
// embedded hextet (e.g. 2002:7f00:: → 127.0.0.0) is not skipped. A 6to4/NAT64/compat of a genuinely
// public IPv4 still returns false, so legitimate IPv6 delivery is unaffected.
const hextets = expandIPv6(lower);
if (hextets) {
if (hextets[0] === 0x2002) {
return isBlockedAddress(hextetsToV4(hextets[1], hextets[2])); // 6to4
}
if (hextets[0] === 0x64 && hextets[1] === 0xff9b) {
return isBlockedAddress(hextetsToV4(hextets[6], hextets[7])); // NAT64
}
if (hextets.slice(0, 6).every(h => h === 0) && (hextets[6] | hextets[7]) !== 0) {
return isBlockedAddress(hextetsToV4(hextets[6], hextets[7])); // IPv4-compatible ::/96
}
}
return false;
}

Expand Down
14 changes: 14 additions & 0 deletions src/engine/adapters/whatsapp-web-js.adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { MessageMedia } from 'whatsapp-web.js';
import {
WhatsAppWebJsAdapter,
extractLinkedParentJID,
isSupportedProxyUrl,
loadRemoteMedia,
resolveWebVersionPin,
wwebjsAckToDeliveryStatus,
Expand All @@ -26,6 +27,19 @@ describe('wwebjsAckToDeliveryStatus (engine ack-int -> neutral DeliveryStatus bo
});
});

describe('isSupportedProxyUrl', () => {
it.each(['http://proxy:8080', 'https://proxy:8443', 'socks4://proxy:1080', 'socks5://user:pass@proxy:1080'])(
'accepts %s',
url => {
expect(isSupportedProxyUrl(url)).toBe(true);
},
);

it.each(['not a url', 'ftp://proxy:21', 'proxy:8080', ''])('rejects %s', url => {
expect(isSupportedProxyUrl(url)).toBe(false);
});
});

describe('extractLinkedParentJID (#201)', () => {
it('returns null when no metadata is provided', () => {
expect(extractLinkedParentJID()).toBeNull();
Expand Down
28 changes: 23 additions & 5 deletions src/engine/adapters/whatsapp-web-js.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ export function wwebjsAckToDeliveryStatus(ack: number): DeliveryStatus {
return 'pending';
}

/**
* Whether a per-session proxy URL parses to a supported scheme — defense-in-depth for a stored proxy
* that bypassed DTO validation (e.g. loaded from the DB on restart). The host is NOT SSRF-blocked: a
* per-session proxy is operator-chosen egress, and a loopback proxy sidecar is a legitimate setup.
*/
export function isSupportedProxyUrl(url: string): boolean {
try {
return ['http:', 'https:', 'socks4:', 'socks5:'].includes(new URL(url).protocol);
} catch {
return false;
}
}

/**
* Fetch remote media 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`
Expand Down Expand Up @@ -179,12 +192,17 @@ export class WhatsAppWebJsAdapter extends EventEmitter implements IWhatsAppEngin
'--disable-gpu',
];

// Add proxy configuration if provided
// Add proxy configuration if provided — but only when the URL parses to a supported scheme, so
// a malformed/stored proxy value can't break the Chromium launch or smuggle a non-proxy scheme.
if (this.config.proxy) {
puppeteerArgs.push(`--proxy-server=${this.config.proxy.url}`);
this.logger.log(
`Using proxy: ${this.config.proxy.type}://${this.config.proxy.url.replace(/:[^:@]*@/, ':***@')}`,
);
if (isSupportedProxyUrl(this.config.proxy.url)) {
puppeteerArgs.push(`--proxy-server=${this.config.proxy.url}`);
this.logger.log(
`Using proxy: ${this.config.proxy.type}://${this.config.proxy.url.replace(/:[^:@]*@/, ':***@')}`,
);
} else {
this.logger.warn(`Ignoring invalid proxy URL for session ${this.config.sessionId}`);
}
}

// Pin the WA-Web version when configured (fixes the 1.34.x "stuck at authenticating"
Expand Down
36 changes: 36 additions & 0 deletions src/modules/session/dto/create-session.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { validateSync } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { CreateSessionDto } from './create-session.dto';

describe('CreateSessionDto proxyUrl validation', () => {
const errs = (proxyUrl: string): ReturnType<typeof validateSync> =>
validateSync(plainToInstance(CreateSessionDto, { name: 'my-bot', proxyUrl }));

it.each([
'http://proxy.example.com:8080',
'http://user:pass@proxy.example.com:8080',
'https://proxy.example.com:8443',
'socks5://proxy.example.com:1080',
'socks4://proxy.example.com:1080',
// Single-label hosts are common in containerized setups (e.g. a `squid` service) — must validate.
'http://localhost:8080',
'http://squid:3128',
'socks5://proxy:1080',
'http://10.0.0.1:8080',
])('accepts a valid proxy URL: %s', url => {
expect(errs(url)).toHaveLength(0);
});

it.each([
'not a url',
'proxy.example.com:8080', // no scheme
'ftp://proxy.example.com:21', // unsupported scheme
'javascript:alert(1)',
])('rejects an invalid / non-proxy-scheme proxyUrl: %s', url => {
expect(errs(url).length).toBeGreaterThan(0);
});

it('allows an omitted proxyUrl (optional)', () => {
expect(validateSync(plainToInstance(CreateSessionDto, { name: 'my-bot' }))).toHaveLength(0);
});
});
16 changes: 15 additions & 1 deletion src/modules/session/dto/create-session.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, MaxLength, MinLength, Matches, IsIn } from 'class-validator';
import { IsString, IsOptional, MaxLength, MinLength, Matches, IsIn, IsUrl } from 'class-validator';

export class CreateSessionDto {
@ApiProperty({
Expand Down Expand Up @@ -31,6 +31,20 @@ export class CreateSessionDto {
@IsOptional()
@IsString()
@MaxLength(255)
// Reject a malformed/non-proxy URL at the boundary (credentialed http://user:pass@host and
// socks4/5 still validate). The host is intentionally NOT SSRF-blocked here — a per-session proxy
// is operator-chosen egress, and a loopback proxy sidecar is a legitimate setup.
// require_tld:false + allow_underscores:true so single-label container hostnames (e.g. `squid`,
// `localhost`) and IP-literal proxies validate, matching the engine's URL-parse check.
@IsUrl(
{
protocols: ['http', 'https', 'socks4', 'socks5'],
require_protocol: true,
require_tld: false,
allow_underscores: true,
},
{ message: 'proxyUrl must be a valid http(s)/socks4/socks5 URL' },
)
proxyUrl?: string;

@ApiPropertyOptional({
Expand Down
45 changes: 44 additions & 1 deletion src/plugins/extensions/translation/libretranslate.client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,50 @@ describe('LibreTranslateClient', () => {
global.fetch = impl;
};

afterEach(() => jest.restoreAllMocks());
const origAllow = process.env.SSRF_ALLOWED_HOSTS;
beforeEach(() => {
// The logic tests target host `lt`; allowlist it so the new SSRF guard lets them through.
process.env.SSRF_ALLOWED_HOSTS = 'lt';
});
afterEach(() => {
if (origAllow === undefined) delete process.env.SSRF_ALLOWED_HOSTS;
else process.env.SSRF_ALLOWED_HOSTS = origAllow;
jest.restoreAllMocks();
});

describe('SSRF guard', () => {
it('blocks a request to an internal address when SSRF protection is on (no fetch)', async () => {
delete process.env.SSRF_ALLOWED_HOSTS; // 169.254.169.254 not allowlisted
const fetchMock = jest.fn();
global.fetch = fetchMock;
const client = new LibreTranslateClient({ url: 'http://169.254.169.254:7001', timeoutMs: 1000 });
await expect(client.translate('a', 'en', 'es')).rejects.toThrow();
expect(fetchMock).not.toHaveBeenCalled();
});

it('does not trip the circuit breaker on a deterministic SSRF block', async () => {
delete process.env.SSRF_ALLOWED_HOSTS;
global.fetch = jest.fn();
const client = new LibreTranslateClient({
url: 'http://169.254.169.254:7001',
timeoutMs: 1000,
failureThreshold: 2,
});
await expect(client.translate('a', 'en', 'es')).rejects.toThrow();
await expect(client.translate('a', 'en', 'es')).rejects.toThrow();
await expect(client.translate('a', 'en', 'es')).rejects.toThrow();
expect(client.isHealthy()).toBe(true);
});

it('refuses to follow redirects (redirect: error) on a guarded request', async () => {
const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ translatedText: 'x' }) });
global.fetch = fetchMock;
const client = new LibreTranslateClient({ url: 'http://lt:7001', timeoutMs: 1000 });
await client.translate('a', 'en', 'es');
const init = (fetchMock.mock.calls[0] as [string, RequestInit])[1];
expect(init.redirect).toBe('error');
});
});

it('translate() posts q/source/target and returns translatedText', async () => {
const fetchMock = jest.fn<Promise<unknown>, [string, RequestInit?]>().mockResolvedValue({
Expand Down
18 changes: 17 additions & 1 deletion src/plugins/extensions/translation/libretranslate.client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// src/modules/translation/adapters/libretranslate.client.ts
import { Translator, DetectResult } from './core/ports';
import { createLogger } from '../../../common/services/logger.service';
import { assertSafeFetchUrl, isSsrfProtectionEnabled, SsrfBlockedError } from '../../../common/security/ssrf-guard';

export interface LibreTranslateOptions {
url: string;
Expand Down Expand Up @@ -57,22 +58,37 @@ export class LibreTranslateClient implements Translator {
throw new Error('LibreTranslate circuit open');
}

const url = `${this.base}${path}`;
const ssrfProtected = isSsrfProtectionEnabled();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs);
try {
// Validate the target host before sending the api_key-bearing body. Honors SSRF_ALLOWED_HOSTS,
// so the documented localhost LibreTranslate sidecar still works once it's allowlisted.
if (ssrfProtected) {
await assertSafeFetchUrl(url);
}
const body = method === 'POST' ? JSON.stringify({ ...payload, api_key: this.opts.apiKey }) : undefined;
const res = await fetch(`${this.base}${path}`, {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body,
signal: controller.signal,
// Refuse redirects: the guard only validated the original host; a 3xx would re-send the
// api_key to a redirect-controlled (possibly internal) target.
redirect: ssrfProtected ? 'error' : 'follow',
});
if (!res.ok) {
throw new Error(`LibreTranslate ${path} -> HTTP ${res.status}`);
}
this.consecutiveFailures = 0;
return await res.json();
} catch (err) {
// A blocked-host SSRF error is a deterministic configuration problem, not a transient upstream
// failure — don't let it trip the circuit breaker (which exists to back off a flaky server).
if (err instanceof SsrfBlockedError) {
throw err;
}
this.consecutiveFailures++;
if (this.consecutiveFailures >= this.failureThreshold) {
this.openUntil = Date.now() + this.cooldownMs;
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/extensions/translation/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"libretranslateUrl": {
"type": "string",
"title": "LibreTranslate URL",
"description": "Base URL of the LibreTranslate instance (e.g. http://libretranslate:7001).",
"description": "Base URL of the LibreTranslate instance (e.g. http://libretranslate:7001). With SSRF protection on (the default), a loopback/internal URL such as http://localhost:7001 must be added to SSRF_ALLOWED_HOSTS.",
"default": "http://localhost:7001",
"required": true
},
Expand Down
Loading