From bd7c61a6149584cf55f7044fcb2b79cfff9a2d69 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 23 Feb 2026 02:46:46 -0500 Subject: [PATCH] security(cli): harden config path traversal --- src/cli/config-cli.test.ts | 29 +++++++++++++++++++++++++++++ src/cli/config-cli.ts | 27 +++++++++++++++++++-------- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 392be2ad0cc..a1735449736 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -204,6 +204,35 @@ describe("config cli", () => { }); }); + describe("path hardening", () => { + it("rejects blocked prototype-key segments for config get", async () => { + await expect(runConfigCommand(["config", "get", "gateway.__proto__.token"])).rejects.toThrow( + "Invalid path segment: __proto__", + ); + + expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled(); + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + }); + + it("rejects blocked prototype-key segments for config set", async () => { + await expect( + runConfigCommand(["config", "set", "tools.constructor.profile", '"sandbox"']), + ).rejects.toThrow("Invalid path segment: constructor"); + + expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled(); + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + }); + + it("rejects blocked prototype-key segments for config unset", async () => { + await expect( + runConfigCommand(["config", "unset", "channels.prototype.enabled"]), + ).rejects.toThrow("Invalid path segment: prototype"); + + expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled(); + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + }); + }); + describe("config unset - issue #6070", () => { it("preserves existing config keys when unsetting a value", async () => { const resolved: OpenClawConfig = { diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index c9fb6e33520..3893aa1d020 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import JSON5 from "json5"; import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; +import { isBlockedObjectKey } from "../config/prototype-keys.js"; import { redactConfigObject } from "../config/redact-snapshot.js"; import { danger, info } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -88,6 +89,18 @@ function parseValue(raw: string, opts: ConfigSetParseOpts): unknown { } } +function hasOwnPathKey(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function validatePathSegments(path: PathSegment[]): void { + for (const segment of path) { + if (!isIndexSegment(segment) && isBlockedObjectKey(segment)) { + throw new Error(`Invalid path segment: ${segment}`); + } + } +} + function getAtPath(root: unknown, path: PathSegment[]): { found: boolean; value?: unknown } { let current: unknown = root; for (const segment of path) { @@ -106,7 +119,7 @@ function getAtPath(root: unknown, path: PathSegment[]): { found: boolean; value? continue; } const record = current as Record; - if (!(segment in record)) { + if (!hasOwnPathKey(record, segment)) { return { found: false }; } current = record[segment]; @@ -136,7 +149,7 @@ function setAtPath(root: Record, path: PathSegment[], value: un throw new Error(`Cannot traverse into "${segment}" (not an object)`); } const record = current as Record; - const existing = record[segment]; + const existing = hasOwnPathKey(record, segment) ? record[segment] : undefined; if (!existing || typeof existing !== "object") { record[segment] = nextIsIndex ? [] : {}; } @@ -177,7 +190,7 @@ function unsetAtPath(root: Record, path: PathSegment[]): boolea continue; } const record = current as Record; - if (!(segment in record)) { + if (!hasOwnPathKey(record, segment)) { return false; } current = record[segment]; @@ -199,7 +212,7 @@ function unsetAtPath(root: Record, path: PathSegment[]): boolea return false; } const record = current as Record; - if (!(last in record)) { + if (!hasOwnPathKey(record, last)) { return false; } delete record[last]; @@ -225,6 +238,7 @@ function parseRequiredPath(path: string): PathSegment[] { if (parsedPath.length === 0) { throw new Error("Path is empty."); } + validatePathSegments(parsedPath); return parsedPath; } @@ -322,10 +336,7 @@ export function registerConfigCli(program: Command) { .option("--json", "Legacy alias for --strict-json", false) .action(async (path: string, value: string, opts) => { try { - const parsedPath = parsePath(path); - if (parsedPath.length === 0) { - throw new Error("Path is empty."); - } + const parsedPath = parseRequiredPath(path); const parsedValue = parseValue(value, { strictJson: Boolean(opts.strictJson || opts.json), });