Skip to content

Commit 29f2062

Browse files
eleqtrizitdrobison00claude
authored
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>
1 parent df192c5 commit 29f2062

3 files changed

Lines changed: 161 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111
- 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.
1212
- 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.
1313
- 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.
14+
- 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.
1415

1516
## 2026.4.14-beta.1
1617

src/agents/openclaw-gateway-tool.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,149 @@ describe("gateway tool", () => {
397397
);
398398
});
399399

400+
it("rejects config.patch that enables dangerouslyDisableDeviceAuth", async () => {
401+
const tool = requireGatewayTool();
402+
403+
await expect(
404+
tool.execute("call-dangerous-device-auth", {
405+
action: "config.patch",
406+
raw: "{ gateway: { controlUi: { dangerouslyDisableDeviceAuth: true } } }",
407+
}),
408+
).rejects.toThrow("cannot enable dangerous config flags");
409+
expect(callGatewayTool).not.toHaveBeenCalledWith(
410+
"config.patch",
411+
expect.any(Object),
412+
expect.anything(),
413+
);
414+
});
415+
416+
it("rejects config.patch that enables allowUnsafeExternalContent on gmail hooks", async () => {
417+
const tool = requireGatewayTool();
418+
419+
await expect(
420+
tool.execute("call-dangerous-gmail", {
421+
action: "config.patch",
422+
raw: "{ hooks: { gmail: { allowUnsafeExternalContent: true } } }",
423+
}),
424+
).rejects.toThrow("cannot enable dangerous config flags");
425+
expect(callGatewayTool).not.toHaveBeenCalledWith(
426+
"config.patch",
427+
expect.any(Object),
428+
expect.anything(),
429+
);
430+
});
431+
432+
it("rejects config.patch that weakens applyPatch.workspaceOnly", async () => {
433+
const tool = requireGatewayTool();
434+
435+
await expect(
436+
tool.execute("call-dangerous-workspace", {
437+
action: "config.patch",
438+
raw: "{ tools: { exec: { applyPatch: { workspaceOnly: false } } } }",
439+
}),
440+
).rejects.toThrow("cannot enable dangerous config flags");
441+
expect(callGatewayTool).not.toHaveBeenCalledWith(
442+
"config.patch",
443+
expect.any(Object),
444+
expect.anything(),
445+
);
446+
});
447+
448+
it("rejects config.patch that enables allowInsecureAuth on control UI", async () => {
449+
const tool = requireGatewayTool();
450+
451+
await expect(
452+
tool.execute("call-dangerous-insecure-auth", {
453+
action: "config.patch",
454+
raw: "{ gateway: { controlUi: { allowInsecureAuth: true } } }",
455+
}),
456+
).rejects.toThrow("cannot enable dangerous config flags");
457+
expect(callGatewayTool).not.toHaveBeenCalledWith(
458+
"config.patch",
459+
expect.any(Object),
460+
expect.anything(),
461+
);
462+
});
463+
464+
it("rejects config.patch that enables dangerouslyAllowHostHeaderOriginFallback", async () => {
465+
const tool = requireGatewayTool();
466+
467+
await expect(
468+
tool.execute("call-dangerous-origin-fallback", {
469+
action: "config.patch",
470+
raw: "{ gateway: { controlUi: { dangerouslyAllowHostHeaderOriginFallback: true } } }",
471+
}),
472+
).rejects.toThrow("cannot enable dangerous config flags");
473+
expect(callGatewayTool).not.toHaveBeenCalledWith(
474+
"config.patch",
475+
expect.any(Object),
476+
expect.anything(),
477+
);
478+
});
479+
480+
it("allows config.patch that does not enable any dangerous flag", async () => {
481+
const sessionKey = "agent:main:whatsapp:dm:+15555550123";
482+
const tool = requireGatewayTool(sessionKey);
483+
484+
const raw = '{ channels: { telegram: { groups: { "*": { requireMention: false } } } } }';
485+
await tool.execute("call-safe-patch", {
486+
action: "config.patch",
487+
raw,
488+
});
489+
490+
expect(callGatewayTool).toHaveBeenCalledWith(
491+
"config.patch",
492+
expect.any(Object),
493+
expect.objectContaining({ raw: raw.trim() }),
494+
);
495+
});
496+
497+
it("allows config.patch when a dangerous flag is already enabled and stays enabled", async () => {
498+
vi.mocked(callGatewayTool).mockImplementationOnce(async (method: string) => {
499+
if (method === "config.get") {
500+
return {
501+
hash: "hash-1",
502+
config: {
503+
tools: { exec: { ask: "on-miss", security: "allowlist" } },
504+
hooks: { gmail: { allowUnsafeExternalContent: true } },
505+
},
506+
};
507+
}
508+
return { ok: true };
509+
});
510+
const sessionKey = "agent:main:whatsapp:dm:+15555550123";
511+
const tool = requireGatewayTool(sessionKey);
512+
513+
const raw =
514+
'{ hooks: { gmail: { allowUnsafeExternalContent: true } }, agents: { defaults: { workspace: "~/test" } } }';
515+
await tool.execute("call-keep-dangerous", {
516+
action: "config.patch",
517+
raw,
518+
});
519+
520+
expect(callGatewayTool).toHaveBeenCalledWith(
521+
"config.patch",
522+
expect.any(Object),
523+
expect.objectContaining({ raw: raw.trim() }),
524+
);
525+
});
526+
527+
it("rejects config.apply that introduces a dangerous flag", async () => {
528+
const tool = requireGatewayTool();
529+
530+
await expect(
531+
tool.execute("call-dangerous-apply", {
532+
action: "config.apply",
533+
raw: '{ tools: { exec: { ask: "on-miss", security: "allowlist", applyPatch: { workspaceOnly: false } } } }',
534+
}),
535+
).rejects.toThrow("cannot enable dangerous config flags");
536+
expect(callGatewayTool).not.toHaveBeenCalledWith(
537+
"config.apply",
538+
expect.any(Object),
539+
expect.anything(),
540+
);
541+
});
542+
400543
it("passes update.run through gateway call", async () => {
401544
const sessionKey = "agent:main:whatsapp:dm:+15555550123";
402545
const tool = requireGatewayTool(sessionKey);

src/agents/tools/gateway-tool.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "../../infra/restart-sentinel.js";
1313
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
1414
import { createSubsystemLogger } from "../../logging/subsystem.js";
15+
import { collectEnabledInsecureOrDangerousFlags } from "../../security/dangerous-config-flags.js";
1516
import { normalizeOptionalString, readStringValue } from "../../shared/string-coerce.js";
1617
import { stringEnum } from "../schema/typebox.js";
1718
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
@@ -113,12 +114,24 @@ function assertGatewayConfigMutationAllowed(params: {
113114
getValueAtPath(nextConfig, path),
114115
),
115116
);
116-
if (changedProtectedPaths.length === 0) {
117-
return;
117+
if (changedProtectedPaths.length > 0) {
118+
throw new Error(
119+
`gateway ${params.action} cannot change protected config paths: ${changedProtectedPaths.join(", ")}`,
120+
);
118121
}
119-
throw new Error(
120-
`gateway ${params.action} cannot change protected config paths: ${changedProtectedPaths.join(", ")}`,
122+
123+
// Block writes that newly enable any dangerous config flag.
124+
// Uses the same flag enumeration as `openclaw security audit`.
125+
const currentFlags = new Set(
126+
collectEnabledInsecureOrDangerousFlags(params.currentConfig as OpenClawConfig),
121127
);
128+
const nextFlags = collectEnabledInsecureOrDangerousFlags(nextConfig as OpenClawConfig);
129+
const newlyEnabled = nextFlags.filter((f) => !currentFlags.has(f));
130+
if (newlyEnabled.length > 0) {
131+
throw new Error(
132+
`gateway ${params.action} cannot enable dangerous config flags: ${newlyEnabled.join(", ")}`,
133+
);
134+
}
122135
}
123136

124137
const GATEWAY_ACTIONS = [

0 commit comments

Comments
 (0)