From 50e1eb56d76114c71bc90dc60e624876df5b4c30 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 4 Apr 2026 02:48:52 +0900 Subject: [PATCH] fix(security): harden discord proxy and bundled channel activation (#60455) * fix(security): tighten discord proxy and mobile tls guards * fix(plugins): enforce allowlists for bundled channels * fix(types): align callers with removed legacy config aliases * fix(security): preserve bundled channel opt-in and ipv6 proxies --- .../ai/openclaw/app/node/ConnectionManager.kt | 3 +- .../openclaw/app/ui/GatewayConfigResolver.kt | 6 +-- .../app/node/ConnectionManagerTest.kt | 12 +++-- .../app/ui/GatewayConfigResolverTest.kt | 8 ++++ extensions/discord/src/client.proxy.test.ts | 48 +++++++++++++++++-- .../discord/src/monitor/gateway-plugin.ts | 2 + .../src/monitor/provider.proxy.test.ts | 41 ++++++++++++++-- .../src/monitor/provider.rest-proxy.test.ts | 37 ++++++++++++-- extensions/discord/src/proxy-fetch.ts | 38 +++++++++++++++ .../discord/src/send.webhook.proxy.test.ts | 44 ++++++++++++++--- .../doctor-legacy-config.migrations.test.ts | 8 ++-- src/media-understanding/shared.test.ts | 29 +++++++++++ src/plugins/config-policy.ts | 6 +-- src/plugins/config-state.ts | 15 ++++-- src/plugins/contracts/tts.contract.test.ts | 6 ++- src/plugins/loader.test.ts | 7 ++- 16 files changed, 266 insertions(+), 44 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt index 23b37eded54..f155fce9e64 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt @@ -8,7 +8,6 @@ import ai.openclaw.app.gateway.GatewayConnectOptions import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.gateway.GatewayTlsParams import ai.openclaw.app.gateway.isLoopbackGatewayHost -import ai.openclaw.app.gateway.isPrivateLanGatewayHost import ai.openclaw.app.LocationMode import ai.openclaw.app.VoiceWakeMode @@ -35,7 +34,7 @@ class ConnectionManager( val stableId = endpoint.stableId val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() } val isManual = stableId.startsWith("manual|") - val cleartextAllowedHost = isPrivateLanGatewayHost(endpoint.host) + val cleartextAllowedHost = isLoopbackGatewayHost(endpoint.host) if (isManual) { if (!manualTlsEnabled && cleartextAllowedHost) return null diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt index f3e42e7ad5e..0cee61706b7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt @@ -1,6 +1,6 @@ package ai.openclaw.app.ui -import ai.openclaw.app.gateway.isPrivateLanGatewayHost +import ai.openclaw.app.gateway.isLoopbackGatewayHost import java.util.Base64 import java.util.Locale import java.net.URI @@ -56,7 +56,7 @@ internal data class GatewayScannedSetupCodeResult( private val gatewaySetupJson = Json { ignoreUnknownKeys = true } private const val remoteGatewaySecurityRule = - "Tailscale and public mobile nodes require wss:// or Tailscale Serve. ws:// is allowed for private LAN, localhost, and the Android emulator." + "Non-loopback mobile nodes require wss:// or Tailscale Serve. ws:// is allowed only for localhost and the Android emulator." private const val remoteGatewaySecurityFix = "Use a private LAN host/address, or enable Tailscale Serve / expose a wss:// gateway URL." @@ -143,7 +143,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { "wss", "https" -> true else -> true } - if (!tls && !isPrivateLanGatewayHost(host)) { + if (!tls && !isLoopbackGatewayHost(host)) { return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL) } val defaultPort = diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt index ca09101d136..c9a92e2ccea 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt @@ -108,7 +108,7 @@ class ConnectionManagerTest { } @Test - fun resolveTlsParamsForEndpoint_manualPrivateLanCanStayCleartextWhenToggleIsOff() { + fun resolveTlsParamsForEndpoint_manualPrivateLanRequiresTlsWhenToggleIsOff() { val endpoint = GatewayEndpoint.manual(host = "192.168.1.20", port = 18789) val params = @@ -118,7 +118,9 @@ class ConnectionManagerTest { manualTlsEnabled = false, ) - assertNull(params) + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) } @Test @@ -146,7 +148,7 @@ class ConnectionManagerTest { } @Test - fun resolveTlsParamsForEndpoint_discoveryPrivateLanWithoutHintsCanStayCleartext() { + fun resolveTlsParamsForEndpoint_discoveryPrivateLanWithoutHintsRequiresTls() { val endpoint = GatewayEndpoint( stableId = "_openclaw-gw._tcp.|local.|Test", @@ -164,7 +166,9 @@ class ConnectionManagerTest { manualTlsEnabled = false, ) - assertNull(params) + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) } @Test diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt index 3fea8261ca4..cc9bff3aad6 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt @@ -289,6 +289,14 @@ class GatewayConfigResolverTest { assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error) } + @Test + fun parseGatewayEndpointResultFlagsInsecureLanCleartextGateway() { + val parsed = parseGatewayEndpointResult("ws://192.168.1.20:18789") + + assertNull(parsed.config) + assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error) + } + @Test fun decodeGatewaySetupCodeParsesBootstrapToken() { val setupCode = diff --git a/extensions/discord/src/client.proxy.test.ts b/extensions/discord/src/client.proxy.test.ts index 1f752ab1f85..4d83539344d 100644 --- a/extensions/discord/src/client.proxy.test.ts +++ b/extensions/discord/src/client.proxy.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { createDiscordRestClient } from "./client.js"; @@ -19,12 +19,16 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { }); describe("createDiscordRestClient proxy support", () => { + beforeEach(() => { + makeProxyFetchMock.mockClear(); + }); + it("injects a custom fetch into RequestClient when a Discord proxy is configured", () => { const cfg = { channels: { discord: { token: "Bot test-token", - proxy: "http://proxy.test:8080", + proxy: "http://127.0.0.1:8080", }, }, } as OpenClawConfig; @@ -71,7 +75,45 @@ describe("createDiscordRestClient proxy support", () => { options?: { fetch?: typeof fetch }; }; - expect(makeProxyFetchMock).toHaveBeenCalledWith("bad-proxy"); + expect(makeProxyFetchMock).not.toHaveBeenCalledWith("bad-proxy"); expect(requestClient.options?.fetch).toBeUndefined(); }); + + it("falls back to direct fetch when the Discord proxy URL is remote", () => { + const cfg = { + channels: { + discord: { + token: "Bot test-token", + proxy: "http://proxy.test:8080", + }, + }, + } as OpenClawConfig; + + const { rest } = createDiscordRestClient({}, cfg); + const requestClient = rest as unknown as { + options?: { fetch?: typeof fetch }; + }; + + expect(makeProxyFetchMock).not.toHaveBeenCalledWith("http://proxy.test:8080"); + expect(requestClient.options?.fetch).toBeUndefined(); + }); + + it("accepts IPv6 loopback Discord proxy URLs", () => { + const cfg = { + channels: { + discord: { + token: "Bot test-token", + proxy: "http://[::1]:8080", + }, + }, + } as OpenClawConfig; + + const { rest } = createDiscordRestClient({}, cfg); + const requestClient = rest as unknown as { + options?: { fetch?: typeof fetch }; + }; + + expect(makeProxyFetchMock).toHaveBeenCalledWith("http://[::1]:8080"); + expect(requestClient.options?.fetch).toEqual(expect.any(Function)); + }); }); diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index a66715ec6c6..f97babae40c 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -6,6 +6,7 @@ import { danger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import * as undici from "undici"; import * as ws from "ws"; +import { validateDiscordProxyUrl } from "../proxy-fetch.js"; const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot"; const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/"; @@ -317,6 +318,7 @@ export function createDiscordGatewayPlugin(params: { } try { + validateDiscordProxyUrl(proxy); const HttpsProxyAgentCtor = params.__testing?.HttpsProxyAgentCtor ?? httpsProxyAgent.HttpsProxyAgent; const ProxyAgentCtor = params.__testing?.ProxyAgentCtor ?? undici.ProxyAgent; diff --git a/extensions/discord/src/monitor/provider.proxy.test.ts b/extensions/discord/src/monitor/provider.proxy.test.ts index 3ddd68284ad..4fdf8c939cb 100644 --- a/extensions/discord/src/monitor/provider.proxy.test.ts +++ b/extensions/discord/src/monitor/provider.proxy.test.ts @@ -265,7 +265,7 @@ describe("createDiscordGatewayPlugin", () => { const runtime = createRuntime(); const plugin = createDiscordGatewayPlugin({ - discordConfig: { proxy: "http://proxy.test:8080" }, + discordConfig: { proxy: "http://127.0.0.1:8080" }, runtime, __testing: createProxyTestingOverrides(), }); @@ -276,7 +276,7 @@ describe("createDiscordGatewayPlugin", () => { .createWebSocket; createWebSocket("wss://gateway.discord.gg"); - expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080"); + expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://127.0.0.1:8080"); expect(webSocketSpy).toHaveBeenCalledWith( "wss://gateway.discord.gg", expect.objectContaining({ agent: getLastAgent() }), @@ -301,24 +301,55 @@ describe("createDiscordGatewayPlugin", () => { it("uses proxy fetch for gateway metadata lookup before registering", async () => { const runtime = createRuntime(); const plugin = createDiscordGatewayPlugin({ - discordConfig: { proxy: "http://proxy.test:8080" }, + discordConfig: { proxy: "http://127.0.0.1:8080" }, runtime, __testing: createProxyTestingOverrides(), }); await registerGatewayClientWithMetadata({ plugin, fetchMock: undiciFetchMock }); - expect(restProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080"); + expect(restProxyAgentSpy).toHaveBeenCalledWith("http://127.0.0.1:8080"); expect(undiciFetchMock).toHaveBeenCalledWith( "https://discord.com/api/v10/gateway/bot", expect.objectContaining({ headers: { Authorization: "Bot token-123" }, - dispatcher: expect.objectContaining({ proxyUrl: "http://proxy.test:8080" }), + dispatcher: expect.objectContaining({ proxyUrl: "http://127.0.0.1:8080" }), }), ); expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1); }); + it("accepts IPv6 loopback proxy URLs for gateway metadata and websocket setup", async () => { + const runtime = createRuntime(); + const plugin = createDiscordGatewayPlugin({ + discordConfig: { proxy: "http://[::1]:8080" }, + runtime, + __testing: createProxyTestingOverrides(), + }); + + const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown }) + .createWebSocket; + createWebSocket("wss://gateway.discord.gg"); + await registerGatewayClientWithMetadata({ plugin, fetchMock: undiciFetchMock }); + + expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://[::1]:8080"); + expect(restProxyAgentSpy).toHaveBeenCalledWith("http://[::1]:8080"); + expect(runtime.error).not.toHaveBeenCalled(); + }); + + it("falls back to the default gateway plugin when proxy is remote", async () => { + const runtime = createRuntime(); + + const plugin = createDiscordGatewayPlugin({ + discordConfig: { proxy: "http://proxy.test:8080" }, + runtime, + }); + + expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("loopback host")); + expect(runtime.log).not.toHaveBeenCalled(); + }); + it("maps body read failures to fetch failed", async () => { await expectGatewayRegisterFallback({ ok: true, diff --git a/extensions/discord/src/monitor/provider.rest-proxy.test.ts b/extensions/discord/src/monitor/provider.rest-proxy.test.ts index 2cb75e80db5..73121d672bc 100644 --- a/extensions/discord/src/monitor/provider.rest-proxy.test.ts +++ b/extensions/discord/src/monitor/provider.rest-proxy.test.ts @@ -42,15 +42,15 @@ describe("resolveDiscordRestFetch", () => { } as const; undiciFetchMock.mockClear().mockResolvedValue(new Response("ok", { status: 200 })); proxyAgentSpy.mockClear(); - const fetcher = resolveDiscordRestFetch("http://proxy.test:8080", runtime); + const fetcher = resolveDiscordRestFetch("http://127.0.0.1:8080", runtime); await fetcher("https://discord.com/api/v10/oauth2/applications/@me"); - expect(proxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080"); + expect(proxyAgentSpy).toHaveBeenCalledWith("http://127.0.0.1:8080"); expect(undiciFetchMock).toHaveBeenCalledWith( "https://discord.com/api/v10/oauth2/applications/@me", expect.objectContaining({ - dispatcher: expect.objectContaining({ proxyUrl: "http://proxy.test:8080" }), + dispatcher: expect.objectContaining({ proxyUrl: "http://127.0.0.1:8080" }), }), ); expect(runtime.log).toHaveBeenCalledWith("discord: rest proxy enabled"); @@ -69,4 +69,35 @@ describe("resolveDiscordRestFetch", () => { expect(runtime.error).toHaveBeenCalled(); expect(runtime.log).not.toHaveBeenCalled(); }); + + it("falls back to global fetch when proxy URL is remote", () => { + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as const; + + const fetcher = resolveDiscordRestFetch("http://proxy.test:8080", runtime); + + expect(fetcher).toBe(fetch); + expect(proxyAgentSpy).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("loopback host")); + expect(runtime.log).not.toHaveBeenCalled(); + }); + + it("uses undici proxy fetch when the proxy URL is IPv6 loopback", async () => { + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as const; + undiciFetchMock.mockResolvedValue(new Response("ok", { status: 200 })); + + const fetcher = resolveDiscordRestFetch("http://[::1]:8080", runtime); + + await fetcher("https://discord.com/api/v10/oauth2/applications/@me"); + + expect(proxyAgentSpy).toHaveBeenCalledWith("http://[::1]:8080"); + expect(runtime.error).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/discord/src/proxy-fetch.ts b/extensions/discord/src/proxy-fetch.ts index 2a39434c694..3de720d5ab4 100644 --- a/extensions/discord/src/proxy-fetch.ts +++ b/extensions/discord/src/proxy-fetch.ts @@ -1,3 +1,4 @@ +import { isIP } from "node:net"; import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { makeProxyFetch } from "openclaw/plugin-sdk/infra-runtime"; import { danger } from "openclaw/plugin-sdk/runtime-env"; @@ -45,9 +46,46 @@ export function withValidatedDiscordProxy( return undefined; } try { + validateDiscordProxyUrl(proxy); return createValue(proxy); } catch (err) { runtime?.error?.(danger(`discord: invalid rest proxy: ${String(err)}`)); return undefined; } } + +export function validateDiscordProxyUrl(proxyUrl: string): string { + let parsed: URL; + try { + parsed = new URL(proxyUrl); + } catch { + throw new Error("Proxy URL must be a valid http or https URL"); + } + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new Error("Proxy URL must use http or https"); + } + if (!isLoopbackProxyHostname(parsed.hostname)) { + throw new Error("Proxy URL must target a loopback host"); + } + return proxyUrl; +} + +function isLoopbackProxyHostname(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + if (!normalized) { + return false; + } + const bracketless = + normalized.startsWith("[") && normalized.endsWith("]") ? normalized.slice(1, -1) : normalized; + if (bracketless === "localhost") { + return true; + } + const ipFamily = isIP(bracketless); + if (ipFamily === 4) { + return bracketless.startsWith("127."); + } + if (ipFamily === 6) { + return bracketless === "::1" || bracketless === "0:0:0:0:0:0:0:1"; + } + return false; +} diff --git a/extensions/discord/src/send.webhook.proxy.test.ts b/extensions/discord/src/send.webhook.proxy.test.ts index bda04da0794..577b6559422 100644 --- a/extensions/discord/src/send.webhook.proxy.test.ts +++ b/extensions/discord/src/send.webhook.proxy.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { sendWebhookMessageDiscord } from "./send.outbound.js"; @@ -13,6 +13,11 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { }); describe("sendWebhookMessageDiscord proxy support", () => { + beforeEach(() => { + makeProxyFetchMock.mockReset(); + vi.restoreAllMocks(); + }); + it("falls back to global fetch when the Discord proxy URL is invalid", async () => { makeProxyFetchMock.mockImplementation(() => { throw new Error("bad proxy"); @@ -38,8 +43,8 @@ describe("sendWebhookMessageDiscord proxy support", () => { wait: true, }); - expect(makeProxyFetchMock).toHaveBeenCalledWith("bad-proxy"); - expect(globalFetchMock).toHaveBeenCalledOnce(); + expect(makeProxyFetchMock).not.toHaveBeenCalledWith("bad-proxy"); + expect(globalFetchMock).toHaveBeenCalled(); globalFetchMock.mockRestore(); }); @@ -49,6 +54,32 @@ describe("sendWebhookMessageDiscord proxy support", () => { .mockResolvedValue(new Response(JSON.stringify({ id: "msg-1" }), { status: 200 })); makeProxyFetchMock.mockReturnValue(proxiedFetch); + const cfg = { + channels: { + discord: { + token: "Bot test-token", + proxy: "http://127.0.0.1:8080", + }, + }, + } as OpenClawConfig; + + await sendWebhookMessageDiscord("hello", { + cfg, + accountId: "default", + webhookId: "123", + webhookToken: "abc", + wait: true, + }); + + expect(makeProxyFetchMock).toHaveBeenCalledWith("http://127.0.0.1:8080"); + expect(proxiedFetch).toHaveBeenCalledOnce(); + }); + + it("uses global fetch when the Discord proxy URL is remote", async () => { + const globalFetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(JSON.stringify({ id: "msg-remote" }), { status: 200 })); + const cfg = { channels: { discord: { @@ -66,8 +97,9 @@ describe("sendWebhookMessageDiscord proxy support", () => { wait: true, }); - expect(makeProxyFetchMock).toHaveBeenCalledWith("http://proxy.test:8080"); - expect(proxiedFetch).toHaveBeenCalledOnce(); + expect(makeProxyFetchMock).not.toHaveBeenCalledWith("http://proxy.test:8080"); + expect(globalFetchMock).toHaveBeenCalled(); + globalFetchMock.mockRestore(); }); it("uses global fetch when no proxy is configured", async () => { @@ -92,7 +124,7 @@ describe("sendWebhookMessageDiscord proxy support", () => { wait: true, }); - expect(globalFetchMock).toHaveBeenCalledOnce(); + expect(globalFetchMock).toHaveBeenCalled(); globalFetchMock.mockRestore(); }); }); diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index fcbbf54d728..01dda4c0f2d 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -324,7 +324,7 @@ describe("normalizeCompatibilityConfigValues", () => { allowedHostnames: ["localhost"], }, }, - }); + } as unknown as OpenClawConfig); expect( (res.config.browser?.ssrfPolicy as Record | undefined)?.allowPrivateNetwork, @@ -344,7 +344,7 @@ describe("normalizeCompatibilityConfigValues", () => { dangerouslyAllowPrivateNetwork: false, }, }, - }); + } as unknown as OpenClawConfig); expect( (res.config.browser?.ssrfPolicy as Record | undefined)?.allowPrivateNetwork, @@ -658,7 +658,7 @@ describe("normalizeCompatibilityConfigValues", () => { interruptOnSpeech: false, silenceTimeoutMs: 1500, }, - }); + } as unknown as OpenClawConfig); expect(res.config.talk).toEqual({ providers: { @@ -689,7 +689,7 @@ describe("normalizeCompatibilityConfigValues", () => { }, apiKey: "secret-key", }, - }); + } as unknown as OpenClawConfig); expect(res.config.talk).toEqual({ provider: "elevenlabs", diff --git a/src/media-understanding/shared.test.ts b/src/media-understanding/shared.test.ts index d72d69133df..83e9dbc4555 100644 --- a/src/media-understanding/shared.test.ts +++ b/src/media-understanding/shared.test.ts @@ -13,6 +13,7 @@ vi.mock("../infra/net/fetch-guard.js", async (importOriginal) => { }); import { + postJsonRequest, fetchWithTimeoutGuarded, readErrorResponse, resolveProviderHttpRequestConfig, @@ -192,4 +193,32 @@ describe("fetchWithTimeoutGuarded", () => { }), ); }); + + it("passes configured explicit proxy policy through the SSRF guard", async () => { + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(null, { status: 200 }), + finalUrl: "https://example.com", + release: async () => {}, + }); + + await postJsonRequest({ + url: "https://api.deepgram.com/v1/listen", + headers: new Headers({ authorization: "Token test-key" }), + body: { hello: "world" }, + fetchFn: fetch, + dispatcherPolicy: { + mode: "explicit-proxy", + proxyUrl: "http://169.254.169.254:8080", + }, + }); + + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + dispatcherPolicy: { + mode: "explicit-proxy", + proxyUrl: "http://169.254.169.254:8080", + }, + }), + ); + }); }); diff --git a/src/plugins/config-policy.ts b/src/plugins/config-policy.ts index d77b7a66c6d..40916863eab 100644 --- a/src/plugins/config-policy.ts +++ b/src/plugins/config-policy.ts @@ -197,10 +197,6 @@ export function resolvePluginActivationState(params: { config: params.sourceConfig ?? params.config, rootConfig: params.sourceRootConfig ?? params.rootConfig, }); - const explicitlyConfiguredBundledChannel = - params.origin === "bundled" && - explicitSelection.explicitlyEnabled && - explicitSelection.reason === "channel enabled in config"; if (!params.config.enabled) { return { @@ -249,7 +245,7 @@ export function resolvePluginActivationState(params: { reason: "selected memory slot", }; } - if (params.config.allow.length > 0 && !explicitlyAllowed && !explicitlyConfiguredBundledChannel) { + if (params.config.allow.length > 0 && !explicitlyAllowed) { return { enabled: false, activated: false, diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index a302ccdbccb..b686e469cd3 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -368,10 +368,6 @@ export function resolvePluginActivationState(params: { config: activationSource.plugins, rootConfig: activationSource.rootConfig, }); - const explicitlyConfiguredBundledChannel = - params.origin === "bundled" && - explicitSelection.explicitlyEnabled && - explicitSelection.cause === "bundled-channel-enabled-in-config"; if (!params.config.enabled) { return toPluginActivationState({ @@ -420,7 +416,16 @@ export function resolvePluginActivationState(params: { cause: "selected-memory-slot", }); } - if (params.config.allow.length > 0 && !explicitlyAllowed && !explicitlyConfiguredBundledChannel) { + if (explicitSelection.cause === "bundled-channel-enabled-in-config") { + return toPluginActivationState({ + enabled: true, + activated: true, + explicitlyEnabled: true, + source: "explicit", + cause: explicitSelection.cause, + }); + } + if (params.config.allow.length > 0 && !explicitlyAllowed) { return toPluginActivationState({ enabled: false, activated: false, diff --git a/src/plugins/contracts/tts.contract.test.ts b/src/plugins/contracts/tts.contract.test.ts index b94afe4c7be..0593c5ea768 100644 --- a/src/plugins/contracts/tts.contract.test.ts +++ b/src/plugins/contracts/tts.contract.test.ts @@ -681,8 +681,10 @@ describe("tts", () => { messages: { tts: { provider: "edge", - edge: { - enabled: true, + providers: { + edge: { + enabled: true, + }, }, }, }, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index e24f72a2142..f5976bd23f7 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -731,7 +731,7 @@ describe("loadOpenClawPlugins", () => { }, }, { - name: "loads bundled channel plugins when channels..enabled=true even if plugins.allow excludes them", + name: "keeps bundled channel plugins behind restrictive allowlists even when channels..enabled=true", config: { channels: { telegram: { @@ -743,7 +743,10 @@ describe("loadOpenClawPlugins", () => { }, } satisfies PluginLoadConfig, assert: (registry: ReturnType) => { - expectTelegramLoaded(registry); + const telegram = registry.plugins.find((entry) => entry.id === "telegram"); + expect(telegram?.status).toBe("disabled"); + expect(telegram?.error).toBe("not in allowlist"); + expect(telegram?.explicitlyEnabled).toBe(true); }, }, {