From a65eb1b864b7630c1242a82de9e5799b80583c3f Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Tue, 21 Apr 2026 19:20:26 +0530 Subject: [PATCH] fix(zalo): add SSRF guard on outbound photo URLs [AI-assisted] (#69593) * fix: address issue * fix: address review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address build failures * fix: address PR review feedback * fix: address review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address review feedback * fix: address build feedback --- extensions/zalo/src/api.test.ts | 95 ++++++- extensions/zalo/src/api.ts | 26 +- extensions/zalo/src/monitor.lifecycle.test.ts | 4 +- .../src/monitor.polling.media-reply.test.ts | 242 ++++++++++++++++++ extensions/zalo/src/monitor.ts | 196 +++++++++++++- extensions/zalo/src/monitor.webhook.test.ts | 3 + extensions/zalo/src/monitor.webhook.ts | 3 + extensions/zalo/src/outbound-media.test.ts | 182 +++++++++++++ extensions/zalo/src/outbound-media.ts | 238 +++++++++++++++++ extensions/zalo/src/runtime-support.ts | 1 + extensions/zalo/src/send.test.ts | 29 +++ extensions/zalo/src/send.ts | 19 +- .../monitor-mocks-test-support.ts | 23 +- src/media/fetch.ts | 4 +- src/media/load-options.ts | 20 ++ src/media/web-media.ts | 27 +- src/plugin-sdk/outbound-media.ts | 8 + 17 files changed, 1087 insertions(+), 33 deletions(-) create mode 100644 extensions/zalo/src/monitor.polling.media-reply.test.ts create mode 100644 extensions/zalo/src/outbound-media.test.ts create mode 100644 extensions/zalo/src/outbound-media.ts diff --git a/extensions/zalo/src/api.test.ts b/extensions/zalo/src/api.test.ts index ffdeab84ae4..cdcef5a2dfb 100644 --- a/extensions/zalo/src/api.test.ts +++ b/extensions/zalo/src/api.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, it, vi } from "vitest"; -import { deleteWebhook, getWebhookInfo, sendChatAction, type ZaloFetch } from "./api.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const resolvePinnedHostnameWithPolicyMock = vi.fn(); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + resolvePinnedHostnameWithPolicy: (...args: unknown[]) => + resolvePinnedHostnameWithPolicyMock(...args), +})); + +import { deleteWebhook, getWebhookInfo, sendChatAction, sendPhoto, type ZaloFetch } from "./api.js"; function createOkFetcher() { return vi.fn(async () => new Response(JSON.stringify({ ok: true, result: {} }))); @@ -15,6 +23,15 @@ async function expectPostJsonRequest(run: (token: string, fetcher: ZaloFetch) => } describe("Zalo API request methods", () => { + beforeEach(() => { + resolvePinnedHostnameWithPolicyMock.mockReset(); + resolvePinnedHostnameWithPolicyMock.mockResolvedValue({ + hostname: "example.com", + addresses: ["93.184.216.34"], + lookup: vi.fn(), + }); + }); + it("uses POST for getWebhookInfo", async () => { await expectPostJsonRequest(getWebhookInfo); }); @@ -55,4 +72,78 @@ describe("Zalo API request methods", () => { vi.useRealTimers(); } }); + + it("validates outbound photo URLs against the SSRF guard before posting", async () => { + const fetcher = createOkFetcher(); + + await sendPhoto( + "test-token", + { + chat_id: "chat-123", + photo: "https://example.com/image.png", + }, + fetcher, + ); + + expect(resolvePinnedHostnameWithPolicyMock).toHaveBeenCalledWith("example.com", { + policy: {}, + }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it("blocks private-network photo URLs before they reach the Zalo API", async () => { + const fetcher = createOkFetcher(); + resolvePinnedHostnameWithPolicyMock.mockRejectedValueOnce( + new Error("Blocked hostname or private/internal/special-use IP address"), + ); + + await expect( + sendPhoto( + "test-token", + { + chat_id: "chat-123", + photo: "http://169.254.169.254/latest/meta-data/iam/security-credentials/", + }, + fetcher, + ), + ).rejects.toThrow("Blocked hostname or private/internal/special-use IP address"); + + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("rejects non-http photo URLs", async () => { + const fetcher = createOkFetcher(); + + await expect( + sendPhoto( + "test-token", + { + chat_id: "chat-123", + photo: "file:///etc/passwd", + }, + fetcher, + ), + ).rejects.toThrow("Zalo photo URL must use HTTP or HTTPS"); + + expect(resolvePinnedHostnameWithPolicyMock).not.toHaveBeenCalled(); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("rejects non-URL strings", async () => { + const fetcher = createOkFetcher(); + + await expect( + sendPhoto( + "test-token", + { + chat_id: "chat-123", + photo: "not a url", + }, + fetcher, + ), + ).rejects.toThrow("Zalo photo URL must be an absolute HTTP or HTTPS URL"); + + expect(resolvePinnedHostnameWithPolicyMock).not.toHaveBeenCalled(); + expect(fetcher).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/zalo/src/api.ts b/extensions/zalo/src/api.ts index 9a9c3544768..5a8fd3cab7d 100644 --- a/extensions/zalo/src/api.ts +++ b/extensions/zalo/src/api.ts @@ -3,7 +3,10 @@ * @see https://bot.zaloplatforms.com/docs */ +import { resolvePinnedHostnameWithPolicy, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; + const ZALO_API_BASE = "https://bot-api.zaloplatforms.com"; +const ZALO_MEDIA_SSRF_POLICY: SsrFPolicy = {}; export type ZaloFetch = (input: string, init?: RequestInit) => Promise; @@ -172,7 +175,28 @@ export async function sendPhoto( params: ZaloSendPhotoParams, fetcher?: ZaloFetch, ): Promise> { - return callZaloApi("sendPhoto", token, params, { fetch: fetcher }); + const photoUrl = params.photo.trim(); + let parsedPhotoUrl: URL; + try { + parsedPhotoUrl = new URL(photoUrl); + } catch { + throw new Error("Zalo photo URL must be an absolute HTTP or HTTPS URL"); + } + + if (parsedPhotoUrl.protocol !== "http:" && parsedPhotoUrl.protocol !== "https:") { + throw new Error("Zalo photo URL must use HTTP or HTTPS"); + } + + await resolvePinnedHostnameWithPolicy(parsedPhotoUrl.hostname, { + policy: ZALO_MEDIA_SSRF_POLICY, + }); + + return callZaloApi( + "sendPhoto", + token, + { ...params, photo: parsedPhotoUrl.href }, + { fetch: fetcher }, + ); } /** diff --git a/extensions/zalo/src/monitor.lifecycle.test.ts b/extensions/zalo/src/monitor.lifecycle.test.ts index 367e24895e4..8c5bab952d7 100644 --- a/extensions/zalo/src/monitor.lifecycle.test.ts +++ b/extensions/zalo/src/monitor.lifecycle.test.ts @@ -148,14 +148,14 @@ describe("monitorZaloProvider lifecycle", () => { }); await vi.waitFor(() => expect(setWebhookMock).toHaveBeenCalledTimes(1)); - expect(registry.httpRoutes).toHaveLength(1); + expect(registry.httpRoutes).toHaveLength(2); abort.abort(); await vi.waitFor(() => expect(deleteWebhookMock).toHaveBeenCalledTimes(1)); expect(deleteWebhookMock).toHaveBeenCalledWith("test-token", undefined, 5000); expect(settled).toBe(false); - expect(registry.httpRoutes).toHaveLength(1); + expect(registry.httpRoutes).toHaveLength(2); resolveDeleteWebhook?.(); await monitoredRun; diff --git a/extensions/zalo/src/monitor.polling.media-reply.test.ts b/extensions/zalo/src/monitor.polling.media-reply.test.ts new file mode 100644 index 00000000000..42357d884d4 --- /dev/null +++ b/extensions/zalo/src/monitor.polling.media-reply.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../../../src/plugins/registry-empty.js"; +import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; +import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js"; +import type { PluginRuntime } from "../runtime-api.js"; +import { + createLifecycleMonitorSetup, + createTextUpdate, +} from "../test-support/lifecycle-test-support.js"; +import { + getUpdatesMock, + loadLifecycleMonitorModule, + resetLifecycleTestState, + sendPhotoMock, + setLifecycleRuntimeCore, +} from "../test-support/monitor-mocks-test-support.js"; + +const prepareHostedZaloMediaUrlMock = vi.fn(); + +vi.mock("./outbound-media.js", async () => { + const actual = await vi.importActual("./outbound-media.js"); + return { + ...actual, + prepareHostedZaloMediaUrl: (...args: unknown[]) => prepareHostedZaloMediaUrlMock(...args), + }; +}); + +describe("Zalo polling media replies", () => { + const finalizeInboundContextMock = vi.fn((ctx: Record) => ctx); + const recordInboundSessionMock = vi.fn(async () => undefined); + const resolveAgentRouteMock = vi.fn(() => ({ + agentId: "main", + channel: "zalo", + accountId: "acct-zalo-polling-media", + sessionKey: "agent:main:zalo:direct:dm-chat-1", + mainSessionKey: "agent:main:main", + matchedBy: "default", + })); + const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn(); + + beforeEach(async () => { + await resetLifecycleTestState(); + prepareHostedZaloMediaUrlMock.mockReset(); + prepareHostedZaloMediaUrlMock.mockResolvedValue( + "https://example.com/hooks/zalo/media/abc123abc123abc123abc123?token=secret", + ); + dispatchReplyWithBufferedBlockDispatcherMock.mockReset(); + dispatchReplyWithBufferedBlockDispatcherMock.mockImplementation( + async (params: { + dispatcherOptions: { + deliver: (payload: { text: string; mediaUrl: string }) => Promise; + }; + }) => { + await params.dispatcherOptions.deliver({ + text: "caption text", + mediaUrl: "https://example.com/reply-image.png", + }); + }, + ); + setLifecycleRuntimeCore({ + routing: { + resolveAgentRoute: + resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"], + }, + reply: { + finalizeInboundContext: + finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], + dispatchReplyWithBufferedBlockDispatcher: + dispatchReplyWithBufferedBlockDispatcherMock as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], + }, + session: { + recordInboundSession: + recordInboundSessionMock as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"], + }, + }); + }); + + afterEach(async () => { + await resetLifecycleTestState(); + }); + + it("hosts and sends media replies while polling when a webhook URL is configured", async () => { + const registry = createEmptyPluginRegistry(); + setActivePluginRegistry(registry); + getUpdatesMock + .mockResolvedValueOnce({ + ok: true, + result: createTextUpdate({ + messageId: "polling-media-1", + userId: "user-1", + userName: "User One", + chatId: "dm-chat-1", + text: "send media", + }), + }) + .mockImplementation(() => new Promise(() => {})); + + const { monitorZaloProvider } = await loadLifecycleMonitorModule(); + const abort = new AbortController(); + const runtime = createRuntimeEnv(); + const { account, config } = createLifecycleMonitorSetup({ + accountId: "acct-zalo-polling-media", + dmPolicy: "open", + webhookUrl: "https://example.com/hooks/zalo", + }); + const run = monitorZaloProvider({ + token: "zalo-token", + account, + config, + runtime, + abortSignal: abort.signal, + }); + + try { + await vi.waitFor(() => expect(sendPhotoMock).toHaveBeenCalledTimes(1)); + + expect(registry.httpRoutes).toHaveLength(1); + expect(prepareHostedZaloMediaUrlMock).toHaveBeenCalledWith({ + mediaUrl: "https://example.com/reply-image.png", + webhookUrl: "https://example.com/hooks/zalo", + webhookPath: "/hooks/zalo", + maxBytes: 5 * 1024 * 1024, + proxyUrl: undefined, + }); + expect(sendPhotoMock).toHaveBeenCalledWith( + "zalo-token", + { + chat_id: "dm-chat-1", + photo: "https://example.com/hooks/zalo/media/abc123abc123abc123abc123?token=secret", + caption: "caption text", + }, + undefined, + ); + } finally { + abort.abort(); + await run; + } + + expect(registry.httpRoutes).toHaveLength(0); + }); + + it("sends media replies directly when webhook hosting is not configured", async () => { + const registry = createEmptyPluginRegistry(); + setActivePluginRegistry(registry); + getUpdatesMock + .mockResolvedValueOnce({ + ok: true, + result: createTextUpdate({ + messageId: "polling-media-2", + userId: "user-2", + userName: "User Two", + chatId: "dm-chat-2", + text: "send media directly", + }), + }) + .mockImplementation(() => new Promise(() => {})); + + const { monitorZaloProvider } = await loadLifecycleMonitorModule(); + const abort = new AbortController(); + const runtime = createRuntimeEnv(); + const { account, config } = createLifecycleMonitorSetup({ + accountId: "acct-zalo-polling-direct-media", + dmPolicy: "open", + webhookUrl: "", + }); + const run = monitorZaloProvider({ + token: "zalo-token", + account, + config, + runtime, + abortSignal: abort.signal, + }); + + try { + await vi.waitFor(() => expect(sendPhotoMock).toHaveBeenCalledTimes(1)); + + expect(prepareHostedZaloMediaUrlMock).not.toHaveBeenCalled(); + expect(sendPhotoMock).toHaveBeenCalledWith( + "zalo-token", + { + chat_id: "dm-chat-2", + photo: "https://example.com/reply-image.png", + caption: "caption text", + }, + undefined, + ); + } finally { + abort.abort(); + await run; + } + }); + + it("re-registers the hosted media route after the active registry swaps", async () => { + const firstRegistry = createEmptyPluginRegistry(); + setActivePluginRegistry(firstRegistry); + getUpdatesMock.mockImplementation(() => new Promise(() => {})); + + const { monitorZaloProvider } = await loadLifecycleMonitorModule(); + const firstAbort = new AbortController(); + const firstRuntime = createRuntimeEnv(); + const { account, config } = createLifecycleMonitorSetup({ + accountId: "acct-zalo-polling-media", + dmPolicy: "open", + webhookUrl: "https://example.com/hooks/zalo", + }); + const firstRun = monitorZaloProvider({ + token: "zalo-token", + account, + config, + runtime: firstRuntime, + abortSignal: firstAbort.signal, + }); + + const secondRegistry = createEmptyPluginRegistry(); + const secondAbort = new AbortController(); + const secondRuntime = createRuntimeEnv(); + let secondRun: Promise | undefined; + + try { + await vi.waitFor(() => expect(firstRegistry.httpRoutes).toHaveLength(1)); + + setActivePluginRegistry(secondRegistry); + secondRun = monitorZaloProvider({ + token: "zalo-token", + account, + config, + runtime: secondRuntime, + abortSignal: secondAbort.signal, + }); + + await vi.waitFor(() => expect(secondRegistry.httpRoutes).toHaveLength(1)); + } finally { + firstAbort.abort(); + secondAbort.abort(); + await firstRun; + await secondRun; + } + + expect(firstRegistry.httpRoutes).toHaveLength(0); + expect(secondRegistry.httpRoutes).toHaveLength(0); + }); +}); diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 0ab5233a06c..03460f7b7c3 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -26,8 +26,9 @@ import { createChannelPairingController, createChannelReplyPipeline, deliverTextOrMediaReply, - resolveWebhookPath, logTypingFailure, + registerPluginHttpRoute, + resolveWebhookPath, resolveDefaultGroupPolicy, resolveDirectDmAuthorizationOutcome, resolveInboundRouteEnvelopeBuilderWithRuntime, @@ -38,6 +39,11 @@ import { import { getZaloRuntime } from "./runtime.js"; export type { ZaloRuntimeEnv } from "./monitor.types.js"; import type { ZaloRuntimeEnv } from "./monitor.types.js"; +import { + prepareHostedZaloMediaUrl, + resolveHostedZaloMediaRoutePrefix, + tryHandleHostedZaloMediaRequest, +} from "./outbound-media.js"; export type ZaloMonitorOptions = { token: string; @@ -67,25 +73,90 @@ type ZaloProcessingContext = { config: OpenClawConfig; runtime: ZaloRuntimeEnv; core: ZaloCoreRuntime; + mediaMaxMb: number; + canHostMedia: boolean; + webhookUrl?: string; + webhookPath?: string; statusSink?: ZaloStatusSink; fetcher?: ZaloFetch; }; type ZaloPollingLoopParams = ZaloProcessingContext & { abortSignal: AbortSignal; isStopped: () => boolean; - mediaMaxMb: number; }; type ZaloUpdateProcessingParams = ZaloProcessingContext & { update: ZaloUpdate; - mediaMaxMb: number; }; let zaloWebhookModulePromise: Promise | undefined; +const hostedMediaRouteRefs = new Map void> }>(); function loadZaloWebhookModule(): Promise { zaloWebhookModulePromise ??= import("./monitor.webhook.js"); return zaloWebhookModulePromise; } + +function registerSharedHostedMediaRoute(params: { + path: string; + accountId: string; + log?: (message: string) => void; +}): () => void { + const unregister = registerPluginHttpRoute({ + auth: "plugin", + match: "prefix", + path: params.path, + replaceExisting: true, + pluginId: "zalo", + source: "zalo-hosted-media", + accountId: params.accountId, + log: params.log, + handler: async (req, res) => { + const handled = await tryHandleHostedZaloMediaRequest(req, res); + if (!handled && !res.headersSent) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Not Found"); + } + }, + }); + + const existing = hostedMediaRouteRefs.get(params.path); + if (existing) { + existing.count += 1; + existing.unregisters.push(unregister); + return () => { + const current = hostedMediaRouteRefs.get(params.path); + if (!current) { + return; + } + if (current.count > 1) { + current.count -= 1; + return; + } + hostedMediaRouteRefs.delete(params.path); + for (const unregisterHandle of current.unregisters) { + unregisterHandle(); + } + }; + } + + hostedMediaRouteRefs.set(params.path, { count: 1, unregisters: [unregister] }); + return () => { + const current = hostedMediaRouteRefs.get(params.path); + if (!current) { + return; + } + if (current.count > 1) { + current.count -= 1; + return; + } + hostedMediaRouteRefs.delete(params.path); + for (const unregisterHandle of current.unregisters) { + unregisterHandle(); + } + }; +} + type ZaloMessagePipelineParams = ZaloProcessingContext & { message: ZaloMessage; text?: string; @@ -95,7 +166,6 @@ type ZaloMessagePipelineParams = ZaloProcessingContext & { }; type ZaloImageMessageParams = ZaloProcessingContext & { message: ZaloMessage; - mediaMaxMb: number; }; type ZaloMessageAuthorizationResult = { chatId: string; @@ -148,6 +218,9 @@ export async function handleZaloWebhookRequest( runtime: target.runtime, core: target.core as ZaloCoreRuntime, mediaMaxMb: target.mediaMaxMb, + canHostMedia: target.canHostMedia, + webhookUrl: target.webhookUrl, + webhookPath: target.webhookPath, statusSink: target.statusSink, fetcher: target.fetcher, }); @@ -161,9 +234,12 @@ function startPollingLoop(params: ZaloPollingLoopParams) { config, runtime, core, + mediaMaxMb, + canHostMedia, + webhookUrl, + webhookPath, abortSignal, isStopped, - mediaMaxMb, statusSink, fetcher, } = params; @@ -175,6 +251,9 @@ function startPollingLoop(params: ZaloPollingLoopParams) { runtime, core, mediaMaxMb, + canHostMedia, + webhookUrl, + webhookPath, statusSink, fetcher, }; @@ -188,6 +267,9 @@ function startPollingLoop(params: ZaloPollingLoopParams) { try { const response = await getUpdates(token, { timeout: pollTimeout }, fetcher); + if (isStopped() || abortSignal.aborted) { + return undefined; + } if (response.ok && response.result) { statusSink?.({ lastInboundAt: Date.now() }); await processUpdate({ @@ -215,7 +297,19 @@ function startPollingLoop(params: ZaloPollingLoopParams) { async function processUpdate(params: ZaloUpdateProcessingParams): Promise { const { update, token, account, config, runtime, core, mediaMaxMb, statusSink, fetcher } = params; const { event_name, message } = update; - const sharedContext = { token, account, config, runtime, core, statusSink, fetcher }; + const sharedContext = { + token, + account, + config, + runtime, + core, + mediaMaxMb, + canHostMedia: params.canHostMedia, + webhookUrl: params.webhookUrl, + webhookPath: params.webhookPath, + statusSink, + fetcher, + }; if (!message) { return undefined; } @@ -566,6 +660,11 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr runtime, core, config, + webhookUrl: params.webhookUrl, + webhookPath: params.webhookPath, + proxyUrl: account.config.proxy, + mediaMaxBytes: params.mediaMaxMb * 1024 * 1024, + canHostMedia: params.canHostMedia, accountId: account.accountId, statusSink, fetcher, @@ -589,12 +688,32 @@ async function deliverZaloReply(params: { runtime: ZaloRuntimeEnv; core: ZaloCoreRuntime; config: OpenClawConfig; + webhookUrl?: string; + webhookPath?: string; + proxyUrl?: string; + mediaMaxBytes: number; + canHostMedia: boolean; accountId?: string; statusSink?: ZaloStatusSink; fetcher?: ZaloFetch; tableMode?: MarkdownTableMode; }): Promise { - const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params; + const { + payload, + token, + chatId, + runtime, + core, + config, + webhookUrl, + webhookPath, + proxyUrl, + mediaMaxBytes, + canHostMedia, + accountId, + statusSink, + fetcher, + } = params; const tableMode = params.tableMode ?? "code"; const reply = resolveSendableOutboundReplyParts(payload, { text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), @@ -614,7 +733,17 @@ async function deliverZaloReply(params: { } }, sendMedia: async ({ mediaUrl, caption }) => { - await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); + const sendableMediaUrl = + canHostMedia && webhookUrl && webhookPath + ? await prepareHostedZaloMediaUrl({ + mediaUrl, + webhookUrl, + webhookPath, + maxBytes: mediaMaxBytes, + proxyUrl, + }) + : mediaUrl; + await sendPhoto(token, { chat_id: chatId, photo: sendableMediaUrl, caption }, fetcher); statusSink?.({ lastOutboundAt: Date.now() }); }, onMediaError: (error) => { @@ -644,6 +773,23 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise< const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB; const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy); const mode = useWebhook ? "webhook" : "polling"; + const effectiveWebhookUrl = normalizeWebhookUrl(webhookUrl ?? account.config.webhookUrl); + const effectiveWebhookPath = + effectiveWebhookUrl || webhookPath?.trim() || account.config.webhookPath?.trim() + ? (resolveWebhookPath({ + webhookPath: webhookPath ?? account.config.webhookPath, + webhookUrl: effectiveWebhookUrl, + defaultPath: null, + }) ?? undefined) + : undefined; + const canHostMedia = Boolean(effectiveWebhookUrl && effectiveWebhookPath); + const hostedMediaRoutePath = + canHostMedia && effectiveWebhookUrl + ? resolveHostedZaloMediaRoutePrefix({ + webhookUrl: effectiveWebhookUrl, + webhookPath: effectiveWebhookPath, + }) + : undefined; let stopped = false; const stopHandlers: Array<() => void> = []; @@ -658,33 +804,49 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise< handler(); } }; + const stopOnAbort = () => { + if (!useWebhook) { + stop(); + } + }; + + abortSignal.addEventListener("abort", stopOnAbort, { once: true }); runtime.log?.( `[${account.accountId}] Zalo provider init mode=${mode} mediaMaxMb=${String(effectiveMediaMaxMb)}`, ); try { + if (hostedMediaRoutePath) { + const unregisterHostedMediaRoute = registerSharedHostedMediaRoute({ + path: hostedMediaRoutePath, + accountId: account.accountId, + log: runtime.log, + }); + stopHandlers.push(unregisterHostedMediaRoute); + } + if (useWebhook) { const { registerZaloWebhookTarget } = await loadZaloWebhookModule(); - if (!webhookUrl || !webhookSecret) { + if (!effectiveWebhookUrl || !webhookSecret) { throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode"); } - if (!webhookUrl.startsWith("https://")) { + if (!effectiveWebhookUrl.startsWith("https://")) { throw new Error("Zalo webhook URL must use HTTPS"); } if (webhookSecret.length < 8 || webhookSecret.length > 256) { throw new Error("Zalo webhook secret must be 8-256 characters"); } - const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null }); + const path = effectiveWebhookPath; if (!path) { throw new Error("Zalo webhookPath could not be derived"); } runtime.log?.( - `[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(webhookUrl)}`, + `[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(effectiveWebhookUrl)}`, ); - await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher); + await setWebhook(token, { url: effectiveWebhookUrl, secret_token: webhookSecret }, fetcher); let webhookCleanupPromise: Promise | undefined; cleanupWebhook = async () => { if (!webhookCleanupPromise) { @@ -714,9 +876,12 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise< runtime, core, path, + webhookUrl: effectiveWebhookUrl, + webhookPath: path, secret: webhookSecret, statusSink: (patch) => statusSink?.(patch), mediaMaxMb: effectiveMediaMaxMb, + canHostMedia, fetcher, }, { @@ -780,6 +945,9 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise< config, runtime, core, + canHostMedia, + webhookUrl: effectiveWebhookUrl, + webhookPath: effectiveWebhookPath, abortSignal, isStopped: () => stopped, mediaMaxMb: effectiveMediaMaxMb, @@ -794,6 +962,7 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise< ); throw err; } finally { + abortSignal.removeEventListener("abort", stopOnAbort); await cleanupWebhook?.(); stop(); runtime.log?.(`[${account.accountId}] Zalo provider stopped mode=${mode}`); @@ -803,4 +972,5 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise< export const __testing = { evaluateZaloGroupAccess, resolveZaloRuntimeGroupPolicy, + clearHostedMediaRouteRefsForTest: () => hostedMediaRouteRefs.clear(), }; diff --git a/extensions/zalo/src/monitor.webhook.test.ts b/extensions/zalo/src/monitor.webhook.test.ts index d7f02064fe1..eec052842a0 100644 --- a/extensions/zalo/src/monitor.webhook.test.ts +++ b/extensions/zalo/src/monitor.webhook.test.ts @@ -62,7 +62,10 @@ function registerTarget(params: { core: params.core ?? ({} as PluginRuntime), secret: params.secret ?? "secret", path: params.path, + webhookUrl: `https://example.com${params.path}`, + webhookPath: params.path, mediaMaxMb: 5, + canHostMedia: true, statusSink: params.statusSink, }); } diff --git a/extensions/zalo/src/monitor.webhook.ts b/extensions/zalo/src/monitor.webhook.ts index 45ab6696869..309ad83dc59 100644 --- a/extensions/zalo/src/monitor.webhook.ts +++ b/extensions/zalo/src/monitor.webhook.ts @@ -31,7 +31,10 @@ export type ZaloWebhookTarget = { core: unknown; secret: string; path: string; + webhookUrl: string; + webhookPath: string; mediaMaxMb: number; + canHostMedia: boolean; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; fetcher?: ZaloFetch; }; diff --git a/extensions/zalo/src/outbound-media.test.ts b/extensions/zalo/src/outbound-media.test.ts new file mode 100644 index 00000000000..eec82d29a24 --- /dev/null +++ b/extensions/zalo/src/outbound-media.test.ts @@ -0,0 +1,182 @@ +import { stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loadOutboundMediaFromUrlMock = vi.fn(); + +vi.mock("openclaw/plugin-sdk/outbound-media", () => ({ + loadOutboundMediaFromUrl: (...args: unknown[]) => loadOutboundMediaFromUrlMock(...args), +})); + +import { + clearHostedZaloMediaForTest, + prepareHostedZaloMediaUrl, + resolveHostedZaloMediaRoutePrefix, + tryHandleHostedZaloMediaRequest, +} from "./outbound-media.js"; + +function createMockResponse() { + const headers = new Map(); + return { + headers, + res: { + statusCode: 200, + setHeader(name: string, value: string) { + headers.set(name, value); + }, + end: vi.fn(), + }, + }; +} + +describe("zalo outbound hosted media", () => { + beforeEach(() => { + clearHostedZaloMediaForTest(); + loadOutboundMediaFromUrlMock.mockReset(); + loadOutboundMediaFromUrlMock.mockResolvedValue({ + buffer: Buffer.from("image-bytes"), + contentType: "image/png", + fileName: "photo.png", + }); + }); + + it("loads outbound media under OpenClaw control and returns a hosted URL", async () => { + const hostedUrl = await prepareHostedZaloMediaUrl({ + mediaUrl: "https://example.com/photo.png", + webhookUrl: "https://gateway.example.com/zalo-webhook", + maxBytes: 1024, + }); + + expect(loadOutboundMediaFromUrlMock).toHaveBeenCalledWith("https://example.com/photo.png", { + maxBytes: 1024, + }); + expect(hostedUrl).toMatch( + /^https:\/\/gateway\.example\.com\/zalo-webhook\/media\/[a-f0-9]+\?token=[a-f0-9]+$/, + ); + }); + + it("passes proxy-aware fetch options into hosted media downloads", async () => { + await prepareHostedZaloMediaUrl({ + mediaUrl: "https://example.com/photo.png", + webhookUrl: "https://gateway.example.com/zalo-webhook", + maxBytes: 1024, + proxyUrl: "http://proxy.example:8080", + }); + + expect(loadOutboundMediaFromUrlMock).toHaveBeenCalledWith("https://example.com/photo.png", { + maxBytes: 1024, + proxyUrl: "http://proxy.example:8080", + }); + }); + + it("creates hosted media storage with private filesystem permissions", async () => { + const hostedUrl = await prepareHostedZaloMediaUrl({ + mediaUrl: "https://example.com/photo.png", + webhookUrl: "https://gateway.example.com/zalo-webhook", + maxBytes: 1024, + }); + + if (process.platform === "win32") { + expect(hostedUrl).toContain("/zalo-webhook/media/"); + return; + } + + const { pathname } = new URL(hostedUrl); + const id = pathname.split("/").pop(); + expect(id).toBeTruthy(); + + const storageDir = join(tmpdir(), "openclaw-zalo-outbound-media"); + const [dirStats, metadataStats, bufferStats] = await Promise.all([ + stat(storageDir), + stat(join(storageDir, `${id}.json`)), + stat(join(storageDir, `${id}.bin`)), + ]); + + expect(dirStats.mode & 0o777).toBe(0o700); + expect(metadataStats.mode & 0o777).toBe(0o600); + expect(bufferStats.mode & 0o777).toBe(0o600); + }); + + it("preserves the root webhook path when deriving the hosted media route", () => { + expect( + resolveHostedZaloMediaRoutePrefix({ + webhookUrl: "https://gateway.example.com/", + }), + ).toBe("/media"); + }); + + it("serves hosted media once when the route token matches", async () => { + const hostedUrl = await prepareHostedZaloMediaUrl({ + mediaUrl: "https://example.com/photo.png", + webhookUrl: "https://gateway.example.com/zalo-webhook", + maxBytes: 1024, + }); + const { pathname, search } = new URL(hostedUrl); + const response = createMockResponse(); + + const handled = await tryHandleHostedZaloMediaRequest( + { + method: "GET", + url: `${pathname}${search}`, + } as never, + response.res as never, + ); + + expect(handled).toBe(true); + expect(response.res.statusCode).toBe(200); + expect(response.headers.get("Content-Type")).toBe("image/png"); + expect(response.res.end).toHaveBeenCalledWith(Buffer.from("image-bytes")); + + const secondResponse = createMockResponse(); + const handledAgain = await tryHandleHostedZaloMediaRequest( + { + method: "GET", + url: `${pathname}${search}`, + } as never, + secondResponse.res as never, + ); + + expect(handledAgain).toBe(true); + expect(secondResponse.res.statusCode).toBe(404); + }); + + it("rejects hosted media requests with the wrong token", async () => { + const hostedUrl = await prepareHostedZaloMediaUrl({ + mediaUrl: "https://example.com/photo.png", + webhookUrl: "https://gateway.example.com/custom/zalo", + webhookPath: "/custom/zalo-hook", + maxBytes: 1024, + }); + const pathname = new URL(hostedUrl).pathname; + const response = createMockResponse(); + + const handled = await tryHandleHostedZaloMediaRequest( + { + method: "GET", + url: `${pathname}?token=wrong`, + } as never, + response.res as never, + ); + + expect(handled).toBe(true); + expect(response.res.statusCode).toBe(401); + expect(response.res.end).toHaveBeenCalledWith("Unauthorized"); + }); + + it("rejects malformed hosted media ids before touching disk", async () => { + const response = createMockResponse(); + + const handled = await tryHandleHostedZaloMediaRequest( + { + method: "GET", + url: "/zalo-webhook/media/not-a-valid-hex-id?token=wrong", + } as never, + response.res as never, + ); + + expect(handled).toBe(true); + expect(response.res.statusCode).toBe(404); + expect(response.res.end).toHaveBeenCalledWith("Not Found"); + }); +}); diff --git a/extensions/zalo/src/outbound-media.ts b/extensions/zalo/src/outbound-media.ts new file mode 100644 index 00000000000..56d53066266 --- /dev/null +++ b/extensions/zalo/src/outbound-media.ts @@ -0,0 +1,238 @@ +import { randomBytes } from "node:crypto"; +import { rmSync } from "node:fs"; +import { chmod, mkdir, readdir, readFile, stat, unlink, writeFile } from "node:fs/promises"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; +import { resolveWebhookPath } from "./runtime-api.js"; + +const ZALO_OUTBOUND_MEDIA_TTL_MS = 2 * 60_000; +const ZALO_OUTBOUND_MEDIA_SEGMENT = "media"; +const ZALO_OUTBOUND_MEDIA_PREFIX = `/${ZALO_OUTBOUND_MEDIA_SEGMENT}/`; +const ZALO_OUTBOUND_MEDIA_DIR = join(tmpdir(), "openclaw-zalo-outbound-media"); +const ZALO_OUTBOUND_MEDIA_ID_RE = /^[a-f0-9]{24}$/; + +type HostedZaloMediaMetadata = { + routePath: string; + token: string; + contentType?: string; + expiresAt: number; +}; + +function resolveHostedZaloMediaMetadataPath(id: string): string { + return join(ZALO_OUTBOUND_MEDIA_DIR, `${id}.json`); +} + +function resolveHostedZaloMediaBufferPath(id: string): string { + return join(ZALO_OUTBOUND_MEDIA_DIR, `${id}.bin`); +} + +function createHostedZaloMediaId(): string { + return randomBytes(12).toString("hex"); +} + +function createHostedZaloMediaToken(): string { + return randomBytes(24).toString("hex"); +} + +async function ensureHostedZaloMediaDir(): Promise { + await mkdir(ZALO_OUTBOUND_MEDIA_DIR, { recursive: true, mode: 0o700 }); + await chmod(ZALO_OUTBOUND_MEDIA_DIR, 0o700).catch(() => undefined); +} + +async function deleteHostedZaloMediaEntry(id: string): Promise { + await Promise.all([ + unlink(resolveHostedZaloMediaMetadataPath(id)).catch(() => undefined), + unlink(resolveHostedZaloMediaBufferPath(id)).catch(() => undefined), + ]); +} + +async function cleanupExpiredHostedZaloMedia(nowMs = Date.now()): Promise { + let fileNames: string[]; + try { + fileNames = await readdir(ZALO_OUTBOUND_MEDIA_DIR); + } catch { + return; + } + + await Promise.all( + fileNames + .filter((fileName) => fileName.endsWith(".json")) + .map(async (fileName) => { + const id = fileName.slice(0, -5); + try { + const metadataRaw = await readFile(resolveHostedZaloMediaMetadataPath(id), "utf8"); + const metadata = JSON.parse(metadataRaw) as HostedZaloMediaMetadata; + if (metadata.expiresAt <= nowMs) { + await deleteHostedZaloMediaEntry(id); + } + } catch { + await deleteHostedZaloMediaEntry(id); + } + }), + ); +} + +async function readHostedZaloMediaEntry(id: string): Promise<{ + metadata: HostedZaloMediaMetadata; + buffer: Buffer; +} | null> { + try { + const [metadataRaw, buffer] = await Promise.all([ + readFile(resolveHostedZaloMediaMetadataPath(id), "utf8"), + readFile(resolveHostedZaloMediaBufferPath(id)), + ]); + return { + metadata: JSON.parse(metadataRaw) as HostedZaloMediaMetadata, + buffer, + }; + } catch { + return null; + } +} + +export function resolveHostedZaloMediaRoutePrefix(params: { + webhookUrl: string; + webhookPath?: string; +}): string { + const webhookRoutePath = resolveWebhookPath({ + webhookPath: params.webhookPath, + webhookUrl: params.webhookUrl, + defaultPath: null, + }); + if (!webhookRoutePath) { + throw new Error("Zalo webhookPath could not be derived for outbound media hosting"); + } + return webhookRoutePath === "/" + ? `/${ZALO_OUTBOUND_MEDIA_SEGMENT}` + : `${webhookRoutePath}/${ZALO_OUTBOUND_MEDIA_SEGMENT}`; +} + +function resolveHostedZaloMediaRoutePath(params: { + webhookUrl: string; + webhookPath?: string; +}): string { + return `${resolveHostedZaloMediaRoutePrefix(params)}/`; +} + +export async function prepareHostedZaloMediaUrl(params: { + mediaUrl: string; + webhookUrl: string; + webhookPath?: string; + maxBytes: number; + proxyUrl?: string; +}): Promise { + await ensureHostedZaloMediaDir(); + await cleanupExpiredHostedZaloMedia(); + + const media = await loadOutboundMediaFromUrl(params.mediaUrl, { + maxBytes: params.maxBytes, + ...(params.proxyUrl ? { proxyUrl: params.proxyUrl } : {}), + }); + + const routePath = resolveHostedZaloMediaRoutePath({ + webhookUrl: params.webhookUrl, + webhookPath: params.webhookPath, + }); + const id = createHostedZaloMediaId(); + const token = createHostedZaloMediaToken(); + const publicBaseUrl = new URL(params.webhookUrl).origin; + + await writeFile(resolveHostedZaloMediaBufferPath(id), media.buffer, { mode: 0o600 }); + try { + await writeFile( + resolveHostedZaloMediaMetadataPath(id), + JSON.stringify({ + routePath, + token, + contentType: media.contentType, + expiresAt: Date.now() + ZALO_OUTBOUND_MEDIA_TTL_MS, + } satisfies HostedZaloMediaMetadata), + { encoding: "utf8", mode: 0o600 }, + ); + } catch (error) { + await deleteHostedZaloMediaEntry(id); + throw error; + } + + return `${publicBaseUrl}${routePath}${id}?token=${token}`; +} + +export async function tryHandleHostedZaloMediaRequest( + req: IncomingMessage, + res: ServerResponse, +): Promise { + await cleanupExpiredHostedZaloMedia(); + + const method = req.method ?? "GET"; + if (method !== "GET" && method !== "HEAD") { + return false; + } + + let url: URL; + try { + url = new URL(req.url ?? "/", "http://localhost"); + } catch { + return false; + } + + const mediaPath = url.pathname; + const prefixIndex = mediaPath.lastIndexOf(ZALO_OUTBOUND_MEDIA_PREFIX); + if (prefixIndex < 0) { + return false; + } + + const routePath = mediaPath.slice(0, prefixIndex + ZALO_OUTBOUND_MEDIA_PREFIX.length); + const id = mediaPath.slice(prefixIndex + ZALO_OUTBOUND_MEDIA_PREFIX.length); + if (!id || !ZALO_OUTBOUND_MEDIA_ID_RE.test(id)) { + res.statusCode = 404; + res.end("Not Found"); + return true; + } + + const entry = await readHostedZaloMediaEntry(id); + if (!entry || entry.metadata.routePath !== routePath) { + res.statusCode = 404; + res.end("Not Found"); + return true; + } + + if (entry.metadata.expiresAt <= Date.now()) { + await deleteHostedZaloMediaEntry(id); + res.statusCode = 410; + res.end("Expired"); + return true; + } + + if (url.searchParams.get("token") !== entry.metadata.token) { + res.statusCode = 401; + res.end("Unauthorized"); + return true; + } + + if (entry.metadata.contentType) { + res.setHeader("Content-Type", entry.metadata.contentType); + } + res.setHeader("Cache-Control", "no-store"); + res.setHeader("X-Content-Type-Options", "nosniff"); + const bufferStats = await stat(resolveHostedZaloMediaBufferPath(id)).catch(() => null); + if (bufferStats) { + res.setHeader("Content-Length", String(bufferStats.size)); + } + + if (method === "HEAD") { + res.statusCode = 200; + res.end(); + return true; + } + + res.statusCode = 200; + res.end(entry.buffer); + await deleteHostedZaloMediaEntry(id); + return true; +} + +export function clearHostedZaloMediaForTest(): void { + rmSync(ZALO_OUTBOUND_MEDIA_DIR, { recursive: true, force: true }); +} diff --git a/extensions/zalo/src/runtime-support.ts b/extensions/zalo/src/runtime-support.ts index 41e4aae940d..233893f5cbe 100644 --- a/extensions/zalo/src/runtime-support.ts +++ b/extensions/zalo/src/runtime-support.ts @@ -76,6 +76,7 @@ export { createFixedWindowRateLimiter, createWebhookAnomalyTracker, readJsonWebhookBodyOrReject, + registerPluginHttpRoute, registerWebhookTarget, registerWebhookTargetWithPluginRoute, resolveWebhookPath, diff --git a/extensions/zalo/src/send.test.ts b/extensions/zalo/src/send.test.ts index 855325986e4..7606d1b43f7 100644 --- a/extensions/zalo/src/send.test.ts +++ b/extensions/zalo/src/send.test.ts @@ -88,4 +88,33 @@ describe("zalo send", () => { expect(sendMessageMock).not.toHaveBeenCalled(); expect(sendPhotoMock).not.toHaveBeenCalled(); }); + + it("sends cfg-backed media directly without hosted-media rewrites", async () => { + sendPhotoMock.mockResolvedValueOnce({ + ok: true, + result: { message_id: "z-photo-2" }, + }); + + const result = await sendPhotoZalo("dm-chat-5", "https://example.com/photo.jpg", { + cfg: { + channels: { + zalo: { + botToken: "zalo-token", + webhookUrl: "https://gateway.example.com/zalo-webhook", + }, + }, + } as never, + }); + + expect(sendPhotoMock).toHaveBeenCalledWith( + "zalo-token", + { + chat_id: "dm-chat-5", + photo: "https://example.com/photo.jpg", + caption: undefined, + }, + undefined, + ); + expect(result).toEqual({ ok: true, messageId: "z-photo-2" }); + }); }); diff --git a/extensions/zalo/src/send.ts b/extensions/zalo/src/send.ts index d10a91bf478..2c7e0ab8e1a 100644 --- a/extensions/zalo/src/send.ts +++ b/extensions/zalo/src/send.ts @@ -139,14 +139,15 @@ export async function sendPhotoZalo( } return await runZaloSend("Failed to send photo", () => - sendPhoto( - context.token, - { - chat_id: context.chatId, - photo: photoUrl.trim(), - caption: options.caption?.slice(0, 2000), - }, - context.fetcher, - ), + (async () => + sendPhoto( + context.token, + { + chat_id: context.chatId, + photo: photoUrl.trim(), + caption: options.caption?.slice(0, 2000), + }, + context.fetcher, + ))(), ); } diff --git a/extensions/zalo/test-support/monitor-mocks-test-support.ts b/extensions/zalo/test-support/monitor-mocks-test-support.ts index f08d296d8bf..36ebcc9b1fc 100644 --- a/extensions/zalo/test-support/monitor-mocks-test-support.ts +++ b/extensions/zalo/test-support/monitor-mocks-test-support.ts @@ -20,6 +20,8 @@ const runtimeModuleId = new URL("../src/runtime.js", import.meta.url).pathname; type UnknownMock = Mock<(...args: unknown[]) => unknown>; type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise>; +const loadedMonitorModules = new Set(); + type ZaloLifecycleMocks = { setWebhookMock: AsyncUnknownMock; deleteWebhookMock: AsyncUnknownMock; @@ -87,7 +89,11 @@ async function importMonitorModule(params: { vi.doUnmock(apiModuleId); vi.doUnmock(runtimeModuleId); } - return (await import(`${monitorModuleUrl}?t=${params.cacheBust}-${Date.now()}`)) as MonitorModule; + const module = (await import( + `${monitorModuleUrl}?t=${params.cacheBust}-${Date.now()}` + )) as MonitorModule; + loadedMonitorModules.add(module); + return module; } async function importSecretInputModule(cacheBust: string): Promise { @@ -103,6 +109,13 @@ async function importWebhookModule(cacheBust: string): Promise { export async function resetLifecycleTestState() { vi.clearAllMocks(); (await importWebhookModule("reset-webhook")).clearZaloWebhookSecurityStateForTest(); + for (const module of loadedMonitorModules) { + module.__testing.clearHostedMediaRouteRefsForTest(); + } + ( + await importMonitorModule({ cacheBust: "reset-monitor", mocked: false }) + ).__testing.clearHostedMediaRouteRefsForTest(); + loadedMonitorModules.clear(); setActivePluginRegistry(createEmptyPluginRegistry()); } @@ -152,12 +165,16 @@ export async function startWebhookLifecycleMonitor(params: { }); await vi.waitFor(() => { - if (setWebhookMock.mock.calls.length !== 1 || registry.httpRoutes.length !== 1) { + const webhookRoute = registry.httpRoutes.find((route) => route.source === "zalo-webhook"); + const hostedMediaRoute = registry.httpRoutes.find( + (route) => route.source === "zalo-hosted-media", + ); + if (setWebhookMock.mock.calls.length !== 1 || !webhookRoute || !hostedMediaRoute) { throw new Error("waiting for webhook registration"); } }); - const route = registry.httpRoutes[0]; + const route = registry.httpRoutes.find((entry) => entry.source === "zalo-webhook"); if (!route) { throw new Error("missing plugin HTTP route"); } diff --git a/src/media/fetch.ts b/src/media/fetch.ts index e32ca532e22..50a8d031fa6 100644 --- a/src/media/fetch.ts +++ b/src/media/fetch.ts @@ -46,6 +46,7 @@ type FetchMediaOptions = { readIdleTimeoutMs?: number; ssrfPolicy?: SsrFPolicy; lookupFn?: LookupFn; + dispatcherPolicy?: PinnedDispatcherPolicy; dispatcherAttempts?: FetchDispatcherAttempt[]; shouldRetryFetchError?: (error: unknown) => boolean; /** @@ -113,6 +114,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise 0 ? dispatcherAttempts - : [{ dispatcherPolicy: undefined, lookupFn }]; + : [{ dispatcherPolicy, lookupFn }]; const runGuardedFetch = async (attempt: FetchDispatcherAttempt) => await fetchWithSsrFGuard( (trustExplicitProxyDns && attempt.dispatcherPolicy?.mode === "explicit-proxy" diff --git a/src/media/load-options.ts b/src/media/load-options.ts index a4aebf1128f..451a4425f02 100644 --- a/src/media/load-options.ts +++ b/src/media/load-options.ts @@ -12,6 +12,10 @@ export type OutboundMediaLoadParams = { mediaAccess?: OutboundMediaAccess; mediaLocalRoots?: readonly string[] | "any"; mediaReadFile?: OutboundMediaReadFile; + proxyUrl?: string; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + requestInit?: RequestInit; + trustExplicitProxyDns?: boolean; optimizeImages?: boolean; /** Agent workspace directory for resolving relative MEDIA: paths. */ workspaceDir?: string; @@ -21,6 +25,10 @@ export type OutboundMediaLoadOptions = { maxBytes?: number; localRoots?: readonly string[] | "any"; readFile?: (filePath: string) => Promise; + proxyUrl?: string; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + requestInit?: RequestInit; + trustExplicitProxyDns?: boolean; hostReadCapability?: boolean; optimizeImages?: boolean; /** Agent workspace directory for resolving relative MEDIA: paths. */ @@ -81,6 +89,12 @@ export function buildOutboundMediaLoadOptions( ...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}), localRoots, readFile, + ...(params.fetchImpl ? { fetchImpl: params.fetchImpl } : {}), + ...(params.proxyUrl ? { proxyUrl: params.proxyUrl } : {}), + ...(params.requestInit ? { requestInit: params.requestInit } : {}), + ...(params.trustExplicitProxyDns !== undefined + ? { trustExplicitProxyDns: params.trustExplicitProxyDns } + : {}), hostReadCapability: true, ...(params.optimizeImages !== undefined ? { optimizeImages: params.optimizeImages } : {}), ...(workspaceDir ? { workspaceDir } : {}), @@ -89,6 +103,12 @@ export function buildOutboundMediaLoadOptions( return { ...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}), ...(localRoots ? { localRoots } : {}), + ...(params.proxyUrl ? { proxyUrl: params.proxyUrl } : {}), + ...(params.fetchImpl ? { fetchImpl: params.fetchImpl } : {}), + ...(params.requestInit ? { requestInit: params.requestInit } : {}), + ...(params.trustExplicitProxyDns !== undefined + ? { trustExplicitProxyDns: params.trustExplicitProxyDns } + : {}), ...(params.optimizeImages !== undefined ? { optimizeImages: params.optimizeImages } : {}), ...(workspaceDir ? { workspaceDir } : {}), }; diff --git a/src/media/web-media.ts b/src/media/web-media.ts index bc286026e58..ee45336de7e 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -3,7 +3,7 @@ import { resolveCanvasHttpPathToLocalPath } from "../gateway/canvas-documents.js import { logVerbose, shouldLogVerbose } from "../globals.js"; import { SafeOpenError, readLocalFileSafely } from "../infra/fs-safe.js"; import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../infra/local-file-access.js"; -import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import type { PinnedDispatcherPolicy, SsrFPolicy } from "../infra/net/ssrf.js"; import { resolveUserPath } from "../utils.js"; import { maxBytesForKind, type MediaKind } from "./constants.js"; import { fetchRemoteMedia } from "./fetch.js"; @@ -42,6 +42,10 @@ type WebMediaOptions = { maxBytes?: number; optimizeImages?: boolean; ssrfPolicy?: SsrFPolicy; + proxyUrl?: string; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + requestInit?: RequestInit; + trustExplicitProxyDns?: boolean; workspaceDir?: string; /** Allowed root directories for local path reads. "any" is deprecated; prefer sandboxValidated + readFile. */ localRoots?: readonly string[] | "any"; @@ -340,6 +344,10 @@ async function loadWebMediaInternal( maxBytes, optimizeImages = true, ssrfPolicy, + proxyUrl, + fetchImpl, + requestInit, + trustExplicitProxyDns, workspaceDir, localRoots, sandboxValidated = false, @@ -436,7 +444,22 @@ async function loadWebMediaInternal( : optimizeImages ? Math.max(maxBytes, defaultFetchCap) : maxBytes; - const fetched = await fetchRemoteMedia({ url: mediaUrl, maxBytes: fetchCap, ssrfPolicy }); + const dispatcherPolicy: PinnedDispatcherPolicy | undefined = proxyUrl + ? { + mode: "explicit-proxy", + proxyUrl, + allowPrivateProxy: true, + } + : undefined; + const fetched = await fetchRemoteMedia({ + url: mediaUrl, + fetchImpl, + requestInit, + maxBytes: fetchCap, + ssrfPolicy, + dispatcherPolicy, + trustExplicitProxyDns, + }); const { buffer, contentType, fileName } = fetched; const kind = kindFromMime(contentType); return await clampAndFinalize({ buffer, contentType, kind, fileName }); diff --git a/src/plugin-sdk/outbound-media.ts b/src/plugin-sdk/outbound-media.ts index 6ea962e631d..d843c7d3896 100644 --- a/src/plugin-sdk/outbound-media.ts +++ b/src/plugin-sdk/outbound-media.ts @@ -6,6 +6,10 @@ export type OutboundMediaLoadOptions = { mediaAccess?: OutboundMediaAccess; mediaLocalRoots?: readonly string[] | "any"; mediaReadFile?: (filePath: string) => Promise; + proxyUrl?: string; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + requestInit?: RequestInit; + trustExplicitProxyDns?: boolean; }; /** Load outbound media from a remote URL or approved local path using the shared web-media policy. */ @@ -20,6 +24,10 @@ export async function loadOutboundMediaFromUrl( mediaAccess: options.mediaAccess, mediaLocalRoots: options.mediaLocalRoots, mediaReadFile: options.mediaReadFile, + proxyUrl: options.proxyUrl, + fetchImpl: options.fetchImpl, + requestInit: options.requestInit, + trustExplicitProxyDns: options.trustExplicitProxyDns, }), ); }