mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:10:43 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user