From 2d4a80ccbb9f2c32ff1871f56321cfee489dbf5b Mon Sep 17 00:00:00 2001 From: nando Date: Thu, 19 Mar 2026 00:44:48 +0000 Subject: [PATCH] whatsapp: respect no_proxy in proxy-backed web sessions --- extensions/whatsapp/src/session.test.ts | 84 ++++++------ extensions/whatsapp/src/session.ts | 166 ++++++++++++++++++------ 2 files changed, 172 insertions(+), 78 deletions(-) diff --git a/extensions/whatsapp/src/session.test.ts b/extensions/whatsapp/src/session.test.ts index 0171a57dc9e..9d82a13e4c9 100644 --- a/extensions/whatsapp/src/session.test.ts +++ b/extensions/whatsapp/src/session.test.ts @@ -5,14 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetLogger, setLoggerOverride } from "../../../src/logging.js"; import { baileys, getLastSocket, resetBaileysMocks, resetLoadConfigMock } from "./test-helpers.js"; -const { httpsRequestMock, httpsProxyAgentSpy, undiciProxyAgentSpy } = vi.hoisted(() => ({ - httpsRequestMock: vi.fn(), +const { httpsProxyAgentSpy, envHttpProxyAgentSpy, undiciFetchMock } = vi.hoisted(() => ({ httpsProxyAgentSpy: vi.fn(), - undiciProxyAgentSpy: vi.fn(), -})); - -vi.mock("node:https", () => ({ - request: httpsRequestMock, + envHttpProxyAgentSpy: vi.fn(), + undiciFetchMock: vi.fn(), })); vi.mock("https-proxy-agent", () => ({ @@ -27,16 +23,17 @@ vi.mock("https-proxy-agent", () => ({ vi.mock("undici", async (importOriginal) => { const actual = await importOriginal(); - class MockProxyAgent { - proxyUrl: string; - constructor(proxyUrl: string) { - this.proxyUrl = proxyUrl; - undiciProxyAgentSpy(proxyUrl); + class MockEnvHttpProxyAgent { + options?: Record; + constructor(options?: Record) { + this.options = options; + envHttpProxyAgentSpy(options); } } return { ...actual, - ProxyAgent: MockProxyAgent, + EnvHttpProxyAgent: MockEnvHttpProxyAgent, + fetch: undiciFetchMock, }; }); @@ -98,6 +95,8 @@ describe("web session", () => { delete process.env.https_proxy; delete process.env.HTTP_PROXY; delete process.env.HTTPS_PROXY; + delete process.env.no_proxy; + delete process.env.NO_PROXY; }); afterEach(() => { @@ -137,43 +136,54 @@ describe("web session", () => { expect(passed?.agent).toBeDefined(); expect(passed?.fetchAgent).toBeDefined(); expect(httpsProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:3128"); - expect(undiciProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:3128"); + expect(envHttpProxyAgentSpy).toHaveBeenCalled(); }); - it("refreshes the WhatsApp Web version through the proxy when Baileys falls back", async () => { + it("does not force the websocket through the proxy when NO_PROXY excludes web.whatsapp.com", async () => { + process.env.HTTPS_PROXY = "http://proxy.test:3128"; + process.env.NO_PROXY = "web.whatsapp.com"; + + await createWaSocket(false, false); + + const makeWASocket = baileys.makeWASocket as ReturnType; + const passed = makeWASocket.mock.calls[0]?.[0] as + | { agent?: unknown; fetchAgent?: unknown } + | undefined; + + expect(passed?.agent).toBeUndefined(); + expect(passed?.fetchAgent).toBeDefined(); + expect(httpsProxyAgentSpy).not.toHaveBeenCalled(); + expect(envHttpProxyAgentSpy).toHaveBeenCalled(); + }); + + it("refreshes the WhatsApp Web version with Baileys headers when Baileys falls back", async () => { process.env.HTTPS_PROXY = "http://proxy.test:3128"; vi.mocked(baileys.fetchLatestBaileysVersion).mockResolvedValueOnce({ version: [2, 3000, 1027934701], isLatest: false, }); - httpsRequestMock.mockImplementationOnce( - ( - _url: string, - _opts: Record, - cb: (res: EventEmitter & { statusCode?: number }) => void, - ) => { - const res = new EventEmitter() as EventEmitter & { statusCode?: number }; - res.statusCode = 200; - const req = new EventEmitter() as EventEmitter & { - end: () => void; - destroy: (_err?: Error) => void; - }; - req.end = () => { - cb(res); - res.emit("data", 'self.client_revision="1035441841";'); - res.emit("end"); - }; - req.destroy = () => {}; - return req; - }, - ); + undiciFetchMock.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + body: null, + text: vi.fn().mockResolvedValue('self.client_revision="1035441841";'), + } as unknown as Response); await createWaSocket(false, false); const makeWASocket = baileys.makeWASocket as ReturnType; const passed = makeWASocket.mock.calls[0]?.[0] as { version?: unknown } | undefined; expect(passed?.version).toEqual([2, 3000, 1035441841]); - expect(httpsRequestMock).toHaveBeenCalled(); + expect(undiciFetchMock).toHaveBeenCalledWith( + "https://web.whatsapp.com/sw.js", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + "sec-fetch-site": "none", + "user-agent": expect.stringContaining("Chrome/131.0.0.0"), + }), + }), + ); }); it("waits for connection open", async () => { diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index 70a296eb5fa..1aba1acf7c4 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -1,6 +1,5 @@ import { randomUUID } from "node:crypto"; import fsSync from "node:fs"; -import { request as httpsRequest } from "node:https"; import { DisconnectReason, fetchLatestBaileysVersion, @@ -15,8 +14,9 @@ import { danger, success } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger, toPinoLikeLogger } from "openclaw/plugin-sdk/runtime-env"; import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-runtime"; import qrcode from "qrcode-terminal"; -import { ProxyAgent } from "undici"; +import { EnvHttpProxyAgent } from "undici"; import { resolveEnvHttpProxyUrl } from "../../../src/infra/net/proxy-env.js"; +import { resolveProxyFetchFromEnv } from "../../../src/infra/net/proxy-fetch.js"; import { maybeRestoreCredsFromBackup, readCredsJsonRaw, @@ -39,6 +39,13 @@ export { const credsSaveQueues = new Map>(); const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000; const WHATSAPP_WEB_SW_URL = "https://web.whatsapp.com/sw.js"; +const WHATSAPP_WEB_SW_MAX_BYTES = 512 * 1024; +const WHATSAPP_WEB_VERSION_HEADERS = { + "sec-fetch-site": "none", + "user-agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", +} as const; +const WHATSAPP_WEB_SOCKET_HOST = "web.whatsapp.com"; function enqueueSaveCreds( authDir: string, @@ -108,40 +115,118 @@ function extractWhatsAppWebVersion(source: string): [number, number, number] | n return [2, 3000, revision]; } -async function fetchLatestWhatsAppWebVersionViaProxy( - proxyUrl: string, -): Promise<[number, number, number] | null> { - return await new Promise((resolve) => { - const agent = new HttpsProxyAgent(proxyUrl); - const req = httpsRequest( - WHATSAPP_WEB_SW_URL, - { - agent, - timeout: 10_000, - }, - (res) => { - const chunks: Buffer[] = []; - res.on("data", (chunk: string | Buffer) => { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); - }); - res.on("end", () => { - const statusCode = typeof res.statusCode === "number" ? res.statusCode : 0; - if (statusCode < 200 || statusCode >= 300) { - resolve(null); - return; - } - resolve(extractWhatsAppWebVersion(Buffer.concat(chunks).toString("utf8"))); - }); - }, - ); - req.on("timeout", () => { - req.destroy(new Error("Timed out fetching WhatsApp Web version")); - }); - req.on("error", () => resolve(null)); - req.end(); +function parseNoProxyRules(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env.no_proxy ?? env.NO_PROXY; + if (!raw) { + return []; + } + return raw + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0); +} + +function stripNoProxyPort(rule: string): string { + if (rule.startsWith("[") && rule.includes("]")) { + const end = rule.indexOf("]"); + return end >= 0 ? rule.slice(0, end + 1) : rule; + } + const colonIndex = rule.lastIndexOf(":"); + if (colonIndex <= 0 || rule.includes(".")) { + return colonIndex > 0 ? rule.slice(0, colonIndex) : rule; + } + return rule; +} + +function shouldBypassEnvProxyForHostname( + hostname: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const normalizedHost = hostname.trim().toLowerCase(); + if (!normalizedHost) { + return false; + } + return parseNoProxyRules(env).some((rawRule) => { + if (rawRule === "*") { + return true; + } + const rule = stripNoProxyPort(rawRule).replace(/^\*\./, ".").toLowerCase(); + if (!rule) { + return false; + } + if (rule.startsWith(".")) { + const bareRule = rule.slice(1); + return normalizedHost === bareRule || normalizedHost.endsWith(rule); + } + return normalizedHost === rule || normalizedHost.endsWith(`.${rule}`); }); } +async function readResponseTextCapped( + response: Response, + maxBytes: number, +): Promise { + const contentLengthHeader = response.headers.get("content-length"); + if (contentLengthHeader) { + const contentLength = Number.parseInt(contentLengthHeader, 10); + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + return null; + } + } + if (!response.body) { + const text = await response.text(); + return Buffer.byteLength(text, "utf8") <= maxBytes ? text : null; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let totalBytes = 0; + let text = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + try { + await reader.cancel("WhatsApp sw.js response too large"); + } catch { + // ignore reader cancellation errors + } + return null; + } + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + return text; + } finally { + reader.releaseLock(); + } +} + +async function fetchLatestWhatsAppWebVersion( + fetchImpl: typeof fetch, +): Promise<[number, number, number] | null> { + try { + const response = await fetchImpl(WHATSAPP_WEB_SW_URL, { + method: "GET", + headers: WHATSAPP_WEB_VERSION_HEADERS, + }); + if (!response.ok) { + return null; + } + const source = await readResponseTextCapped(response, WHATSAPP_WEB_SW_MAX_BYTES); + if (!source) { + return null; + } + return extractWhatsAppWebVersion(source); + } catch { + return null; + } +} + async function resolveWhatsAppWebVersion( sessionLogger: ReturnType, ): Promise<[number, number, number]> { @@ -150,12 +235,8 @@ async function resolveWhatsAppWebVersion( return latest.version; } - const proxyUrl = resolveEnvHttpProxyUrl("https"); - if (!proxyUrl) { - return latest.version; - } - - const proxyVersion = await fetchLatestWhatsAppWebVersionViaProxy(proxyUrl); + const fetchImpl = resolveProxyFetchFromEnv() ?? globalThis.fetch; + const proxyVersion = fetchImpl ? await fetchLatestWhatsAppWebVersion(fetchImpl) : null; if (!proxyVersion) { return latest.version; } @@ -192,8 +273,11 @@ export async function createWaSocket( const { state, saveCreds } = await useMultiFileAuthState(authDir); const version = await resolveWhatsAppWebVersion(sessionLogger); const proxyUrl = resolveEnvHttpProxyUrl("https"); - const wsAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined; - const fetchAgent = proxyUrl ? new ProxyAgent(proxyUrl) : undefined; + const wsAgent = + proxyUrl && !shouldBypassEnvProxyForHostname(WHATSAPP_WEB_SOCKET_HOST) + ? new HttpsProxyAgent(proxyUrl) + : undefined; + const fetchAgent = proxyUrl ? new EnvHttpProxyAgent() : undefined; const sock = makeWASocket({ auth: { creds: state.creds,