mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -681,8 +681,10 @@ describe("tts", () => {
|
||||
messages: {
|
||||
tts: {
|
||||
provider: "edge",
|
||||
edge: {
|
||||
enabled: true,
|
||||
providers: {
|
||||
edge: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user