From 29f206243b2d636e10ebf794a27d937d63f04b49 Mon Sep 17 00:00:00 2001 From: Agustin Rivera <31522568+eleqtrizit@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:59:39 -0700 Subject: [PATCH] Guard dangerous gateway config mutations (#62006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gateway): guard dangerous config alias * fix(gateway): ignore reordered dangerous flags * fix(gateway): use id-based mapping identity and honor legacy alias baseline * fix(gateway): tighten dangerous config matching * fix(gateway): strip IPv6 brackets in isRemoteGatewayTarget hostname check * fix(gateway): detect tunneled remote targets * fix(gateway): match id-less hook mappings by fingerprint, not index * fix(gateway): detect env-selected remote targets * fix(gateway): resolve remote-target guard from live config, not captured opts * fix(gateway): resolve remote-target guard from live config, not captured opts * fix(gateway): treat loopback OPENCLAW_GATEWAY_URL as local when mode is not remote * fix(gateway): preserve legacy dangerous hook edits * fix(gateway): block dangerous plugin reactivation * fix(gateway): handle dotted plugin IDs in dangerous-flag checks * fix(gateway): honor plugin policy activation * fix(gateway): block remote plugin activation changes via allow/deny/enabled * fix(gateway): broaden loopback url detection * fix(gateway): resolve plugin IDs by longest-prefix match * fix(gateway): block remote slot activation * fix(gateway): preserve legacy mapping identity during id+field transitions * fix(gateway): block remote load-path and channel activation changes * test(gateway): fix remote config mock typing * fix(gateway): guard auto-enabled dangerous plugins * fix(gateway): address P1 review comments on remote gateway mutation guards - Treat all OPENCLAW_GATEWAY_URL targets as remote for mutation guards to prevent SSH tunnel bypasses - Always load config fresh in isRemoteGatewayTargetForAgentTools to detect session changes - Expand remote activation guard to cover auto-enable paths (auth.profiles, models.providers, agents.defaults, agents.list, tools.web.fetch.provider) - Respect plugins.deny in manifest-missing fallback to prevent false negatives - Fix hook mapping identity matching to properly handle id-less mappings by fingerprint - Update tests to reflect new secure behavior for env-sourced gateway URLs * fix(gateway): prevent hook mapping swap attacks via fingerprint-only matching When both current and next tokens have fingerprints, match ONLY by fingerprint. This prevents replacing one dangerous hook mapping with a different one at the same array index from being incorrectly treated as 'already present'. The previous fallback to index-based matching allowed bypasses where an attacker could swap dangerous mappings at the same index without triggering the guard. * fix(gateway): honor allowlist in fallback guard * fix(gateway): treat empty plugin allowlist as unrestricted in manifest-missing fallback * docs: update USER.md worklog for empty-allowlist fix * fix(gateway): resolve review comments — type safety, auto-enable resilience, remote hardening edits * docs: update USER.md worklog for review comment resolution * fix(gateway): block remaining remote setup auto-enable paths * fix(gateway): simplify dangerous config mutation guard to set-diff approach Replace 400+ lines of hook fingerprinting, remote gateway detection, plugin activation tracking, and auto-enable enumeration with a simple set-diff against collectEnabledInsecureOrDangerousFlags — the same enumeration openclaw security audit already uses. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: remove USER.md audit log from PR Co-Authored-By: Claude Opus 4.6 (1M context) * changelog: note gateway-tool dangerous config mutation guard (#62006) --------- Co-authored-by: Devin Robison Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + src/agents/openclaw-gateway-tool.test.ts | 143 +++++++++++++++++++++++ src/agents/tools/gateway-tool.ts | 21 +++- 3 files changed, 161 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9904bcf2a7..bc7ce6e37f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Models/Codex: include `apiKey` in the codex provider catalog output so the Pi ModelRegistry validator no longer rejects the entry and silently drops all custom models from every provider in `models.json`. (#66180) Thanks @hoyyeva. - Slack/interactions: apply the configured global `allowFrom` owner allowlist to channel block-action and modal interactive events, require an expected sender id for cross-verification, and reject ambiguous channel types so interactive triggers can no longer bypass the documented allowlist intent in channels without a `users` list. Open-by-default behavior is preserved when no allowlists are configured. (#66028) Thanks @eleqtrizit. - Media-understanding/attachments: fail closed when a local attachment path cannot be canonically resolved via `realpath`, so a `realpath` error can no longer downgrade the canonical-roots allowlist check to a non-canonical comparison; attachments that also have a URL still fall back to the network fetch path. (#66022) Thanks @eleqtrizit. +- Agents/gateway-tool: reject `config.patch` and `config.apply` calls from the model-facing gateway tool when they would newly enable any flag enumerated by `openclaw security audit` (for example `dangerouslyDisableDeviceAuth`, `allowInsecureAuth`, `dangerouslyAllowHostHeaderOriginFallback`, `hooks.gmail.allowUnsafeExternalContent`, `tools.exec.applyPatch.workspaceOnly: false`); already-enabled flags pass through unchanged so non-dangerous edits in the same patch still apply, and direct authenticated operator RPC behavior is unchanged. (#62006) Thanks @eleqtrizit. ## 2026.4.14-beta.1 diff --git a/src/agents/openclaw-gateway-tool.test.ts b/src/agents/openclaw-gateway-tool.test.ts index fc050cd9ea5..16bcebd953a 100644 --- a/src/agents/openclaw-gateway-tool.test.ts +++ b/src/agents/openclaw-gateway-tool.test.ts @@ -397,6 +397,149 @@ describe("gateway tool", () => { ); }); + it("rejects config.patch that enables dangerouslyDisableDeviceAuth", async () => { + const tool = requireGatewayTool(); + + await expect( + tool.execute("call-dangerous-device-auth", { + action: "config.patch", + raw: "{ gateway: { controlUi: { dangerouslyDisableDeviceAuth: true } } }", + }), + ).rejects.toThrow("cannot enable dangerous config flags"); + expect(callGatewayTool).not.toHaveBeenCalledWith( + "config.patch", + expect.any(Object), + expect.anything(), + ); + }); + + it("rejects config.patch that enables allowUnsafeExternalContent on gmail hooks", async () => { + const tool = requireGatewayTool(); + + await expect( + tool.execute("call-dangerous-gmail", { + action: "config.patch", + raw: "{ hooks: { gmail: { allowUnsafeExternalContent: true } } }", + }), + ).rejects.toThrow("cannot enable dangerous config flags"); + expect(callGatewayTool).not.toHaveBeenCalledWith( + "config.patch", + expect.any(Object), + expect.anything(), + ); + }); + + it("rejects config.patch that weakens applyPatch.workspaceOnly", async () => { + const tool = requireGatewayTool(); + + await expect( + tool.execute("call-dangerous-workspace", { + action: "config.patch", + raw: "{ tools: { exec: { applyPatch: { workspaceOnly: false } } } }", + }), + ).rejects.toThrow("cannot enable dangerous config flags"); + expect(callGatewayTool).not.toHaveBeenCalledWith( + "config.patch", + expect.any(Object), + expect.anything(), + ); + }); + + it("rejects config.patch that enables allowInsecureAuth on control UI", async () => { + const tool = requireGatewayTool(); + + await expect( + tool.execute("call-dangerous-insecure-auth", { + action: "config.patch", + raw: "{ gateway: { controlUi: { allowInsecureAuth: true } } }", + }), + ).rejects.toThrow("cannot enable dangerous config flags"); + expect(callGatewayTool).not.toHaveBeenCalledWith( + "config.patch", + expect.any(Object), + expect.anything(), + ); + }); + + it("rejects config.patch that enables dangerouslyAllowHostHeaderOriginFallback", async () => { + const tool = requireGatewayTool(); + + await expect( + tool.execute("call-dangerous-origin-fallback", { + action: "config.patch", + raw: "{ gateway: { controlUi: { dangerouslyAllowHostHeaderOriginFallback: true } } }", + }), + ).rejects.toThrow("cannot enable dangerous config flags"); + expect(callGatewayTool).not.toHaveBeenCalledWith( + "config.patch", + expect.any(Object), + expect.anything(), + ); + }); + + it("allows config.patch that does not enable any dangerous flag", async () => { + const sessionKey = "agent:main:whatsapp:dm:+15555550123"; + const tool = requireGatewayTool(sessionKey); + + const raw = '{ channels: { telegram: { groups: { "*": { requireMention: false } } } } }'; + await tool.execute("call-safe-patch", { + action: "config.patch", + raw, + }); + + expect(callGatewayTool).toHaveBeenCalledWith( + "config.patch", + expect.any(Object), + expect.objectContaining({ raw: raw.trim() }), + ); + }); + + it("allows config.patch when a dangerous flag is already enabled and stays enabled", async () => { + vi.mocked(callGatewayTool).mockImplementationOnce(async (method: string) => { + if (method === "config.get") { + return { + hash: "hash-1", + config: { + tools: { exec: { ask: "on-miss", security: "allowlist" } }, + hooks: { gmail: { allowUnsafeExternalContent: true } }, + }, + }; + } + return { ok: true }; + }); + const sessionKey = "agent:main:whatsapp:dm:+15555550123"; + const tool = requireGatewayTool(sessionKey); + + const raw = + '{ hooks: { gmail: { allowUnsafeExternalContent: true } }, agents: { defaults: { workspace: "~/test" } } }'; + await tool.execute("call-keep-dangerous", { + action: "config.patch", + raw, + }); + + expect(callGatewayTool).toHaveBeenCalledWith( + "config.patch", + expect.any(Object), + expect.objectContaining({ raw: raw.trim() }), + ); + }); + + it("rejects config.apply that introduces a dangerous flag", async () => { + const tool = requireGatewayTool(); + + await expect( + tool.execute("call-dangerous-apply", { + action: "config.apply", + raw: '{ tools: { exec: { ask: "on-miss", security: "allowlist", applyPatch: { workspaceOnly: false } } } }', + }), + ).rejects.toThrow("cannot enable dangerous config flags"); + expect(callGatewayTool).not.toHaveBeenCalledWith( + "config.apply", + expect.any(Object), + expect.anything(), + ); + }); + it("passes update.run through gateway call", async () => { const sessionKey = "agent:main:whatsapp:dm:+15555550123"; const tool = requireGatewayTool(sessionKey); diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 913ea38fd2d..52ae244405f 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -12,6 +12,7 @@ import { } from "../../infra/restart-sentinel.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { collectEnabledInsecureOrDangerousFlags } from "../../security/dangerous-config-flags.js"; import { normalizeOptionalString, readStringValue } from "../../shared/string-coerce.js"; import { stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; @@ -113,12 +114,24 @@ function assertGatewayConfigMutationAllowed(params: { getValueAtPath(nextConfig, path), ), ); - if (changedProtectedPaths.length === 0) { - return; + if (changedProtectedPaths.length > 0) { + throw new Error( + `gateway ${params.action} cannot change protected config paths: ${changedProtectedPaths.join(", ")}`, + ); } - throw new Error( - `gateway ${params.action} cannot change protected config paths: ${changedProtectedPaths.join(", ")}`, + + // Block writes that newly enable any dangerous config flag. + // Uses the same flag enumeration as `openclaw security audit`. + const currentFlags = new Set( + collectEnabledInsecureOrDangerousFlags(params.currentConfig as OpenClawConfig), ); + const nextFlags = collectEnabledInsecureOrDangerousFlags(nextConfig as OpenClawConfig); + const newlyEnabled = nextFlags.filter((f) => !currentFlags.has(f)); + if (newlyEnabled.length > 0) { + throw new Error( + `gateway ${params.action} cannot enable dangerous config flags: ${newlyEnabled.join(", ")}`, + ); + } } const GATEWAY_ACTIONS = [