diff --git a/CHANGELOG.md b/CHANGELOG.md index e0f4ac366c2..403aa4db05f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- fix(security): block MINIMAX_API_HOST workspace env injection and remove env-driven URL routing [AI-assisted]. (#67300) Thanks @pgondhi987. - Cron/delivery: treat explicit `delivery.mode: "none"` runs as not requested even if the runner reports `delivered: false`, so no-delivery cron jobs no longer persist false delivery failures or errors. (#69285) Thanks @matsuri1987. - Plugins/install: repair active and default-enabled bundled plugin runtime dependencies before import in packaged installs, so bundled Discord, WhatsApp, Slack, Telegram, and provider plugins work without putting their dependency trees in core. - BlueBubbles: raise the outbound `/api/v1/message/text` send timeout default from 10s to 30s, and add a configurable `channels.bluebubbles.sendTimeoutMs` (also per-account) so macOS 26 setups where Private API iMessage sends stall for 60+ seconds no longer silently lose messages at the 10s abort. Probes, chat lookups, and health checks keep the shorter 10s default. Fixes #67486. (#69193) Thanks @omarshahine. diff --git a/extensions/minimax/speech-provider.test.ts b/extensions/minimax/speech-provider.test.ts index be5e314cee5..059cdb0c30b 100644 --- a/extensions/minimax/speech-provider.test.ts +++ b/extensions/minimax/speech-provider.test.ts @@ -86,7 +86,7 @@ describe("buildMinimaxSpeechProvider", () => { expect(config.pitch).toBe(3); }); - it("reads from env vars as fallback", () => { + it("keeps trusted MINIMAX_API_HOST fallback for TTS baseUrl", () => { process.env.MINIMAX_API_HOST = "https://env.api.com"; process.env.MINIMAX_TTS_MODEL = "speech-01-240228"; process.env.MINIMAX_TTS_VOICE_ID = "Chinese (Mandarin)_Gentle_Boy"; diff --git a/src/agents/minimax-vlm.normalizes-api-key.test.ts b/src/agents/minimax-vlm.normalizes-api-key.test.ts index 5156fc5817d..ac413368b8c 100644 --- a/src/agents/minimax-vlm.normalizes-api-key.test.ts +++ b/src/agents/minimax-vlm.normalizes-api-key.test.ts @@ -10,6 +10,7 @@ beforeAll(async () => { describe("minimaxUnderstandImage apiKey normalization", () => { const priorFetch = global.fetch; + const priorMinimaxApiHost = process.env.MINIMAX_API_HOST; const apiResponse = JSON.stringify({ base_resp: { status_code: 0, status_msg: "ok" }, content: "ok", @@ -17,6 +18,11 @@ describe("minimaxUnderstandImage apiKey normalization", () => { afterEach(() => { global.fetch = priorFetch; + if (priorMinimaxApiHost === undefined) { + delete process.env.MINIMAX_API_HOST; + } else { + process.env.MINIMAX_API_HOST = priorMinimaxApiHost; + } vi.restoreAllMocks(); }); @@ -50,6 +56,30 @@ describe("minimaxUnderstandImage apiKey normalization", () => { it("drops non-Latin1 characters from apiKey before sending Authorization header", async () => { await runNormalizationCase("minimax-\u0417\u2502test-key"); }); + + it("keeps trusted MINIMAX_API_HOST env fallback for VLM routing", async () => { + process.env.MINIMAX_API_HOST = "https://api.minimaxi.com"; + const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { + const requestUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + expect(requestUrl).toBe("https://api.minimaxi.com/v1/coding_plan/vlm"); + return new Response(apiResponse, { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + global.fetch = withFetchPreconnect(fetchSpy); + + await expect( + minimaxUnderstandImage({ + apiKey: "minimax-test-key", + prompt: "hi", + imageDataUrl: "data:image/png;base64,AAAA", + }), + ).resolves.toBe("ok"); + + expect(fetchSpy).toHaveBeenCalledOnce(); + }); }); describe("isMinimaxVlmModel", () => { diff --git a/src/infra/dotenv.test.ts b/src/infra/dotenv.test.ts index 0f8cbbfe32d..4652cded1c1 100644 --- a/src/infra/dotenv.test.ts +++ b/src/infra/dotenv.test.ts @@ -197,6 +197,8 @@ describe("loadDotEnv", () => { "OPENCLAW_STATE_DIR=./evil-state", "OPENCLAW_CONFIG_PATH=./evil-config.json", "ANTHROPIC_BASE_URL=https://evil.example.com/v1", + "EXAMPLE_API_HOST=https://evil-api.example.com", + "MINIMAX_API_HOST=https://evil.example.com", "HTTP_PROXY=http://evil-proxy:8080", "UV_PYTHON=./attacker-python", "uv_python=./attacker-python-lower", @@ -209,6 +211,8 @@ describe("loadDotEnv", () => { delete process.env.NODE_OPTIONS; delete process.env.OPENCLAW_CONFIG_PATH; delete process.env.ANTHROPIC_BASE_URL; + delete process.env.EXAMPLE_API_HOST; + delete process.env.MINIMAX_API_HOST; delete process.env.HTTP_PROXY; delete process.env.UV_PYTHON; delete process.env.uv_python; @@ -221,6 +225,8 @@ describe("loadDotEnv", () => { expect(process.env.OPENCLAW_STATE_DIR).toBe(stateDir); expect(process.env.OPENCLAW_CONFIG_PATH).toBeUndefined(); expect(process.env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(process.env.EXAMPLE_API_HOST).toBeUndefined(); + expect(process.env.MINIMAX_API_HOST).toBeUndefined(); expect(process.env.HTTP_PROXY).toBeUndefined(); expect(process.env.UV_PYTHON).toBeUndefined(); expect(process.env.uv_python).toBeUndefined(); @@ -613,6 +619,8 @@ describe("workspace .env blocklist completeness", () => { "OPENCLAW_DISABLE_BUNDLED_PLUGINS", "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS", "OPENCLAW_BROWSER_EXECUTABLE_PATH", + "EXAMPLE_API_HOST", + "MINIMAX_API_HOST", "BROWSER_EXECUTABLE_PATH", "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", "OPENCLAW_SKIP_CHANNELS", @@ -671,4 +679,29 @@ describe("workspace .env blocklist completeness", () => { }); }); }); + + it("blocks generic endpoint-routing suffixes from workspace .env", async () => { + await withIsolatedEnvAndCwd(async () => { + await withDotEnvFixture(async ({ cwdDir }) => { + await writeEnvFile( + path.join(cwdDir, ".env"), + [ + "FUTURE_PROVIDER_API_HOST=https://evil.example.com", + "FUTURE_PROVIDER_BASE_URL=https://evil.example.com/v1", + "SAFE_PROVIDER_URL=https://allowed.example.com", + ].join("\n"), + ); + + delete process.env.FUTURE_PROVIDER_API_HOST; + delete process.env.FUTURE_PROVIDER_BASE_URL; + delete process.env.SAFE_PROVIDER_URL; + + loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true }); + + expect(process.env.FUTURE_PROVIDER_API_HOST).toBeUndefined(); + expect(process.env.FUTURE_PROVIDER_BASE_URL).toBeUndefined(); + expect(process.env.SAFE_PROVIDER_URL).toBe("https://allowed.example.com"); + }); + }); + }); }); diff --git a/src/infra/dotenv.ts b/src/infra/dotenv.ts index 86642e41e43..76af2982138 100644 --- a/src/infra/dotenv.ts +++ b/src/infra/dotenv.ts @@ -21,6 +21,7 @@ const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([ "CLAWHUB_URL", "HTTP_PROXY", "HTTPS_PROXY", + "MINIMAX_API_HOST", "NODE_TLS_REJECT_UNAUTHORIZED", "NO_PROXY", "OPENAI_API_KEY", @@ -69,7 +70,7 @@ const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([ ]); // Block endpoint redirection for any service without overfitting per-provider names. -const BLOCKED_WORKSPACE_DOTENV_SUFFIXES = ["_BASE_URL"]; +const BLOCKED_WORKSPACE_DOTENV_SUFFIXES = ["_API_HOST", "_BASE_URL"]; const BLOCKED_WORKSPACE_DOTENV_PREFIXES = [ "ANTHROPIC_API_KEY_", "CLAWHUB_",