From e0c0eed305e4f369293fc53cf4daea7846a676df Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Fri, 19 Jun 2026 17:47:36 +0700 Subject: [PATCH] =?UTF-8?q?fix(security):=20harden=20outbound=20requests?= =?UTF-8?q?=20=E2=80=94=20IPv6=20SSRF=20embeddings,=20LibreTranslate=20gua?= =?UTF-8?q?rd,=20proxy=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isBlockedAddress now classifies IPv6 literals that embed an IPv4 — 6to4 (2002::/16), NAT64 (64:ff9b::/96) and the deprecated IPv4-compatible ::/96 — by the embedded address, expanding the literal to full hextets so a compressed all-zero segment (e.g. 2002:7f00:: -> 127.0.0.0) is not skipped. The LibreTranslate plugin client validates its target through the SSRF guard and refuses redirects, so its api_key-bearing request can't be replayed to an internal host; a blocked-host error is treated as a config error and no longer trips the circuit breaker. A loopback LibreTranslate sidecar must be allowlisted via SSRF_ALLOWED_HOSTS when SSRF protection is on. Per-session proxyUrl is validated as an http(s)/socks4/socks5 URL at the API (single-label/container hostnames and credentialed/SOCKS proxies accepted) and re-checked by the engine before launching the browser. --- CHANGELOG.md | 14 ++++++ src/common/security/ssrf-guard.spec.ts | 13 +++++ src/common/security/ssrf-guard.ts | 50 +++++++++++++++++++ .../adapters/whatsapp-web-js.adapter.spec.ts | 14 ++++++ .../adapters/whatsapp-web-js.adapter.ts | 28 +++++++++-- .../session/dto/create-session.dto.spec.ts | 36 +++++++++++++ src/modules/session/dto/create-session.dto.ts | 16 +++++- .../translation/libretranslate.client.spec.ts | 45 ++++++++++++++++- .../translation/libretranslate.client.ts | 18 ++++++- .../extensions/translation/manifest.json | 2 +- 10 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 src/modules/session/dto/create-session.dto.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 66bcc8f6..33cedb51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/common/security/ssrf-guard.spec.ts b/src/common/security/ssrf-guard.spec.ts index 42b11edf..cc82fb3c 100644 --- a/src/common/security/ssrf-guard.spec.ts +++ b/src/common/security/ssrf-guard.spec.ts @@ -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); }); @@ -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); }); diff --git a/src/common/security/ssrf-guard.ts b/src/common/security/ssrf-guard.ts index 94507527..e6fcaeac 100644 --- a/src/common/security/ssrf-guard.ts +++ b/src/common/security/ssrf-guard.ts @@ -71,6 +71,38 @@ const BLOCKED_V4: ReadonlyArray = [ * 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(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); @@ -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; } diff --git a/src/engine/adapters/whatsapp-web-js.adapter.spec.ts b/src/engine/adapters/whatsapp-web-js.adapter.spec.ts index 3519d676..f7cb46f5 100644 --- a/src/engine/adapters/whatsapp-web-js.adapter.spec.ts +++ b/src/engine/adapters/whatsapp-web-js.adapter.spec.ts @@ -2,6 +2,7 @@ import { MessageMedia } from 'whatsapp-web.js'; import { WhatsAppWebJsAdapter, extractLinkedParentJID, + isSupportedProxyUrl, loadRemoteMedia, resolveWebVersionPin, wwebjsAckToDeliveryStatus, @@ -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(); diff --git a/src/engine/adapters/whatsapp-web-js.adapter.ts b/src/engine/adapters/whatsapp-web-js.adapter.ts index 4c940c3d..fb136174 100644 --- a/src/engine/adapters/whatsapp-web-js.adapter.ts +++ b/src/engine/adapters/whatsapp-web-js.adapter.ts @@ -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` @@ -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" diff --git a/src/modules/session/dto/create-session.dto.spec.ts b/src/modules/session/dto/create-session.dto.spec.ts new file mode 100644 index 00000000..c83e8eca --- /dev/null +++ b/src/modules/session/dto/create-session.dto.spec.ts @@ -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 => + 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); + }); +}); diff --git a/src/modules/session/dto/create-session.dto.ts b/src/modules/session/dto/create-session.dto.ts index a3e73251..691a7a9e 100644 --- a/src/modules/session/dto/create-session.dto.ts +++ b/src/modules/session/dto/create-session.dto.ts @@ -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({ @@ -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({ diff --git a/src/plugins/extensions/translation/libretranslate.client.spec.ts b/src/plugins/extensions/translation/libretranslate.client.spec.ts index f9afa159..6430dcdc 100644 --- a/src/plugins/extensions/translation/libretranslate.client.spec.ts +++ b/src/plugins/extensions/translation/libretranslate.client.spec.ts @@ -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, [string, RequestInit?]>().mockResolvedValue({ diff --git a/src/plugins/extensions/translation/libretranslate.client.ts b/src/plugins/extensions/translation/libretranslate.client.ts index 4861cc74..b7f771dc 100644 --- a/src/plugins/extensions/translation/libretranslate.client.ts +++ b/src/plugins/extensions/translation/libretranslate.client.ts @@ -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; @@ -57,15 +58,25 @@ 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}`); @@ -73,6 +84,11 @@ export class LibreTranslateClient implements Translator { 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; diff --git a/src/plugins/extensions/translation/manifest.json b/src/plugins/extensions/translation/manifest.json index eb016a9f..1542ebc6 100644 --- a/src/plugins/extensions/translation/manifest.json +++ b/src/plugins/extensions/translation/manifest.json @@ -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 },