Skip to content

Commit 2981425

Browse files
fix(config): restrict config paths to own properties (#99846)
Co-authored-by: zenglingbiao <[email protected]>
1 parent 02b529a commit 2981425

2 files changed

Lines changed: 116 additions & 8 deletions

File tree

src/config/config-paths.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import {
3+
getConfigValueAtPath,
4+
setConfigValueAtPath,
5+
unsetConfigValueAtPath,
6+
} from "./config-paths.js";
7+
8+
describe("config path own-property traversal", () => {
9+
for (const key of ["toString", "valueOf", "hasOwnProperty"]) {
10+
it(`does not treat inherited ${key} as config`, () => {
11+
const parent: Record<string, unknown> = {};
12+
const root: Record<string, unknown> = { parent };
13+
14+
expect(getConfigValueAtPath(root, ["parent", key])).toBeUndefined();
15+
expect(unsetConfigValueAtPath(root, ["parent", key])).toBe(false);
16+
expect(root).toEqual({ parent: {} });
17+
18+
setConfigValueAtPath(root, ["parent", key], "own");
19+
expect(Object.hasOwn(parent, key)).toBe(true);
20+
expect(getConfigValueAtPath(root, ["parent", key])).toBe("own");
21+
expect(unsetConfigValueAtPath(root, ["parent", key])).toBe(true);
22+
expect(root).toEqual({});
23+
});
24+
}
25+
26+
it("replaces an inherited parent instead of traversing it", () => {
27+
const prototypeBranch = { leaf: "prototype" };
28+
const root = Object.create({ branch: prototypeBranch }) as Record<string, unknown>;
29+
30+
expect(getConfigValueAtPath(root, ["branch", "leaf"])).toBeUndefined();
31+
expect(unsetConfigValueAtPath(root, ["branch", "leaf"])).toBe(false);
32+
expect(prototypeBranch).toEqual({ leaf: "prototype" });
33+
34+
setConfigValueAtPath(root, ["branch", "leaf"], "own");
35+
expect(Object.hasOwn(root, "branch")).toBe(true);
36+
expect(root.branch).toEqual({ leaf: "own" });
37+
expect(prototypeBranch).toEqual({ leaf: "prototype" });
38+
39+
expect(unsetConfigValueAtPath(root, ["branch", "leaf"])).toBe(true);
40+
expect(Object.hasOwn(root, "branch")).toBe(false);
41+
expect(getConfigValueAtPath(root, ["branch", "leaf"])).toBeUndefined();
42+
expect(prototypeBranch).toEqual({ leaf: "prototype" });
43+
});
44+
45+
for (const inheritedKind of ["setter", "non-writable"] as const) {
46+
it(`creates an own parent over an inherited ${inheritedKind} property`, () => {
47+
const setter = vi.fn();
48+
const prototype = {};
49+
Object.defineProperty(
50+
prototype,
51+
"branch",
52+
inheritedKind === "setter"
53+
? { configurable: true, set: setter }
54+
: { configurable: true, value: { leaf: "prototype" }, writable: false },
55+
);
56+
const root = Object.create(prototype) as Record<string, unknown>;
57+
58+
expect(() => setConfigValueAtPath(root, ["branch", "leaf"], "own")).not.toThrow();
59+
expect(setter).not.toHaveBeenCalled();
60+
expect(Object.getOwnPropertyDescriptor(root, "branch")).toMatchObject({
61+
configurable: true,
62+
enumerable: true,
63+
value: { leaf: "own" },
64+
writable: true,
65+
});
66+
});
67+
68+
it(`creates an own leaf over an inherited ${inheritedKind} property`, () => {
69+
const setter = vi.fn();
70+
const prototype = {};
71+
Object.defineProperty(
72+
prototype,
73+
"leaf",
74+
inheritedKind === "setter"
75+
? { configurable: true, set: setter }
76+
: { configurable: true, value: "prototype", writable: false },
77+
);
78+
const parent = Object.create(prototype) as Record<string, unknown>;
79+
const root = { parent };
80+
81+
expect(() => setConfigValueAtPath(root, ["parent", "leaf"], "own")).not.toThrow();
82+
expect(setter).not.toHaveBeenCalled();
83+
expect(Object.getOwnPropertyDescriptor(parent, "leaf")).toMatchObject({
84+
configurable: true,
85+
enumerable: true,
86+
value: "own",
87+
writable: true,
88+
});
89+
});
90+
}
91+
});

src/config/config-paths.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,22 @@
1+
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
12
// Resolves and classifies config paths for reads, writes, and metadata.
23
import { isPlainObject } from "../utils.js";
3-
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
44

55
type PathNode = Record<string, unknown>;
66

7+
function setOwnConfigProperty(node: PathNode, key: string, value: unknown): void {
8+
if (Object.hasOwn(node, key)) {
9+
node[key] = value;
10+
return;
11+
}
12+
Object.defineProperty(node, key, {
13+
configurable: true,
14+
enumerable: true,
15+
value,
16+
writable: true,
17+
});
18+
}
19+
720
/** Parses CLI/config dot-notation paths and rejects unsafe object-key segments. */
821
export function parseConfigPath(raw: string): {
922
ok: boolean;
@@ -37,13 +50,14 @@ export function setConfigValueAtPath(root: PathNode, path: string[], value: unkn
3750
let cursor: PathNode = root;
3851
for (let idx = 0; idx < path.length - 1; idx += 1) {
3952
const key = path[idx];
40-
const next = cursor[key];
41-
if (!isPlainObject(next)) {
42-
cursor[key] = {};
53+
const existing = Object.hasOwn(cursor, key) ? cursor[key] : undefined;
54+
const next: PathNode = isPlainObject(existing) ? existing : {};
55+
if (next !== existing) {
56+
setOwnConfigProperty(cursor, key, next);
4357
}
44-
cursor = cursor[key] as PathNode;
58+
cursor = next;
4559
}
46-
cursor[path[path.length - 1]] = value;
60+
setOwnConfigProperty(cursor, path[path.length - 1], value);
4761
}
4862

4963
/** Removes a value at a config path and prunes empty parent objects created by setters. */
@@ -52,6 +66,9 @@ export function unsetConfigValueAtPath(root: PathNode, path: string[]): boolean
5266
let cursor: PathNode = root;
5367
for (let idx = 0; idx < path.length - 1; idx += 1) {
5468
const key = path[idx];
69+
if (!Object.hasOwn(cursor, key)) {
70+
return false;
71+
}
5572
const next = cursor[key];
5673
if (!isPlainObject(next)) {
5774
return false;
@@ -60,7 +77,7 @@ export function unsetConfigValueAtPath(root: PathNode, path: string[]): boolean
6077
cursor = next;
6178
}
6279
const leafKey = path[path.length - 1];
63-
if (!(leafKey in cursor)) {
80+
if (!Object.hasOwn(cursor, leafKey)) {
6481
return false;
6582
}
6683
delete cursor[leafKey];
@@ -82,7 +99,7 @@ export function unsetConfigValueAtPath(root: PathNode, path: string[]): boolean
8299
export function getConfigValueAtPath(root: PathNode, path: string[]): unknown {
83100
let cursor: unknown = root;
84101
for (const key of path) {
85-
if (!isPlainObject(cursor)) {
102+
if (!isPlainObject(cursor) || !Object.hasOwn(cursor, key)) {
86103
return undefined;
87104
}
88105
cursor = cursor[key];

0 commit comments

Comments
 (0)