Guard dangerous gateway config mutations (#62006)

* 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) <noreply@anthropic.com>

* chore: remove USER.md audit log from PR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* changelog: note gateway-tool dangerous config mutation guard (#62006)

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Agustin Rivera
2026-04-13 19:59:39 -07:00
committed by GitHub
parent df192c514c
commit 29f206243b
3 changed files with 161 additions and 4 deletions

View File

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

View File

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

View File

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