Files
openclaw/src/secrets/path-utils.test.ts
Yuval Dinodia beda4218af fix(secrets): reject prototype-polluting mutation paths (#102840)
* fix(secrets): reject prototype-polluting mutation paths

Co-authored-by: yetval <yetvald@gmail.com>

* docs(secrets): document blocked mutation segments

* chore(secrets): keep release notes in PR metadata

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-10 00:17:31 +01:00

144 lines
5.1 KiB
TypeScript

/** Tests dotted-path get/set/delete helpers used by secrets migration. */
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
deletePathStrict,
getPath,
setPathCreateStrict,
setPathExistingStrict,
} from "./path-utils.js";
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
function createAgentListConfig(): OpenClawConfig {
return asConfig({
agents: {
list: [{ id: "a" }],
},
});
}
const BLOCKED_PATH_SEGMENTS = ["__proto__", "constructor", "prototype"];
const POLLUTION_PROBE = "openclawPathPollutionProbe";
describe("secrets path utils", () => {
it("deletePathStrict compacts arrays via splice", () => {
const config = asConfig({});
setPathCreateStrict(config, ["agents", "list"], [{ id: "a" }, { id: "b" }, { id: "c" }]);
const changed = deletePathStrict(config, ["agents", "list", "1"]);
expect(changed).toBe(true);
expect(getPath(config, ["agents", "list"])).toEqual([{ id: "a" }, { id: "c" }]);
});
it("getPath returns undefined for invalid array path segment", () => {
const config = asConfig({
agents: {
list: [{ id: "a" }],
},
});
expect(getPath(config, ["agents", "list", "foo"])).toBeUndefined();
expect(getPath(config, ["agents", "list", "0abc"])).toBeUndefined();
expect(getPath(config, ["agents", "list", "+0"])).toBeUndefined();
expect(getPath(config, ["agents", "list", "9007199254740993"])).toBeUndefined();
expect(getPath(config, ["agents", "list", "4294967294"])).toBeUndefined();
});
it("setPathCreateStrict rejects unsafe array path segments", () => {
const config = createAgentListConfig();
expect(() =>
setPathCreateStrict(config, ["agents", "list", "9007199254740993", "id"], "b"),
).toThrow(/Invalid array index segment/);
expect(() => setPathCreateStrict(config, ["agents", "list", "4294967294", "id"], "b")).toThrow(
/Invalid array index segment/,
);
expect(() => setPathCreateStrict(config, ["agents", "list", "+0", "id"], "b")).toThrow(
/Invalid path shape/,
);
});
it.each(BLOCKED_PATH_SEGMENTS)(
"setPathCreateStrict rejects %s before creating partial containers",
(blockedSegment) => {
const config = asConfig({});
expect(() =>
setPathCreateStrict(config, ["safe", blockedSegment, POLLUTION_PROBE], "yes"),
).toThrow(/prototype-polluting/);
expect(config).toEqual({});
expect(Object.hasOwn(Object.prototype, POLLUTION_PROBE)).toBe(false);
},
);
it.each([
["leading", (blockedSegment: string) => [blockedSegment, POLLUTION_PROBE]],
["middle", (blockedSegment: string) => ["safe", blockedSegment, POLLUTION_PROBE]],
["leaf", (blockedSegment: string) => ["safe", "value", blockedSegment]],
] as const)("all mutation helpers reject blocked segments at the %s", (_position, pathFor) => {
for (const blockedSegment of BLOCKED_PATH_SEGMENTS) {
const segments = pathFor(blockedSegment);
const config = asConfig({ safe: { value: "kept" } });
expect(() => setPathCreateStrict(config, segments, "changed")).toThrow(/prototype-polluting/);
expect(() => setPathExistingStrict(config, segments, "changed")).toThrow(
/prototype-polluting/,
);
expect(() => deletePathStrict(config, segments)).toThrow(/prototype-polluting/);
expect(config).toEqual({ safe: { value: "kept" } });
}
});
it("setPathExistingStrict throws when path does not already exist", () => {
const config = createAgentListConfig();
expect(() =>
setPathExistingStrict(
config,
["agents", "list", "0", "memorySearch", "remote", "apiKey"],
"x",
),
).toThrow(/Path segment does not exist/);
});
it("setPathExistingStrict updates an existing leaf", () => {
const config = asConfig({
talk: {
apiKey: "old", // pragma: allowlist secret
},
});
const changed = setPathExistingStrict(config, ["talk", "apiKey"], "new");
expect(changed).toBe(true);
expect(getPath(config, ["talk", "apiKey"])).toBe("new");
});
it("setPathCreateStrict creates missing container segments", () => {
const config = asConfig({});
const changed = setPathCreateStrict(config, ["talk", "provider", "apiKey"], "x");
expect(changed).toBe(true);
expect(getPath(config, ["talk", "provider", "apiKey"])).toBe("x");
});
it("setPathCreateStrict leaves value unchanged when equal", () => {
const config = asConfig({
talk: {
apiKey: "same", // pragma: allowlist secret
},
});
const changed = setPathCreateStrict(config, ["talk", "apiKey"], "same");
expect(changed).toBe(false);
expect(getPath(config, ["talk", "apiKey"])).toBe("same");
});
it("setPathCreateStrict works on nested config sub-objects", () => {
const pluginConfig: Record<string, unknown> = {};
const changed = setPathCreateStrict(pluginConfig, ["webSearch", "mode"], "llm-context");
expect(changed).toBe(true);
expect(pluginConfig).toEqual({
webSearch: {
mode: "llm-context",
},
});
});
});