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
This commit is contained in:
Vincent Koc
2026-04-04 02:48:52 +09:00
committed by GitHub
parent 3ddf745f97
commit 50e1eb56d7
16 changed files with 266 additions and 44 deletions

View File

@@ -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

View File

@@ -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 =

View File

@@ -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

View File

@@ -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 =

View File

@@ -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));
});
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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<T>(
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;
}

View File

@@ -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();
});
});

View File

@@ -324,7 +324,7 @@ describe("normalizeCompatibilityConfigValues", () => {
allowedHostnames: ["localhost"],
},
},
});
} as unknown as OpenClawConfig);
expect(
(res.config.browser?.ssrfPolicy as Record<string, unknown> | undefined)?.allowPrivateNetwork,
@@ -344,7 +344,7 @@ describe("normalizeCompatibilityConfigValues", () => {
dangerouslyAllowPrivateNetwork: false,
},
},
});
} as unknown as OpenClawConfig);
expect(
(res.config.browser?.ssrfPolicy as Record<string, unknown> | 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",

View File

@@ -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",
},
}),
);
});
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -681,8 +681,10 @@ describe("tts", () => {
messages: {
tts: {
provider: "edge",
edge: {
enabled: true,
providers: {
edge: {
enabled: true,
},
},
},
},

View File

@@ -731,7 +731,7 @@ describe("loadOpenClawPlugins", () => {
},
},
{
name: "loads bundled channel plugins when channels.<id>.enabled=true even if plugins.allow excludes them",
name: "keeps bundled channel plugins behind restrictive allowlists even when channels.<id>.enabled=true",
config: {
channels: {
telegram: {
@@ -743,7 +743,10 @@ describe("loadOpenClawPlugins", () => {
},
} satisfies PluginLoadConfig,
assert: (registry: ReturnType<typeof loadOpenClawPlugins>) => {
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);
},
},
{