Skip to content

Commit a4cc229

Browse files
authored
fix(cli): block config prototype pollution (#54)
1 parent f8040e9 commit a4cc229

2 files changed

Lines changed: 59 additions & 4 deletions

File tree

packages/cli/src/config.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it } from "vitest";
2+
import { getConfigValue, mergeConfig, setConfigValue, type JsonObject } from "./config.js";
3+
4+
describe("config helpers", () => {
5+
it("sets and reads nested own properties", () => {
6+
const config: JsonObject = {};
7+
8+
setConfigValue("waiting.arcade.defaultGame", "snake", config);
9+
10+
expect(getConfigValue("waiting.arcade.defaultGame", config)).toBe("snake");
11+
});
12+
13+
it.each(["__proto__", "prototype", "constructor"])(
14+
"rejects unsafe path key: %s",
15+
(key) => {
16+
const marker = "logicsrcPrototypePollution";
17+
const config: JsonObject = {};
18+
19+
expect(() => setConfigValue(`${key}.${marker}`, "true", config)).toThrow(
20+
`Unsafe config key: ${key}`
21+
);
22+
expect(Object.hasOwn(Object.prototype, marker)).toBe(false);
23+
}
24+
);
25+
26+
it("rejects unsafe keys while merging parsed config", () => {
27+
const override = JSON.parse(
28+
'{"__proto__":{"logicsrcPrototypePollution":true}}'
29+
) as JsonObject;
30+
31+
expect(() => mergeConfig({}, override)).toThrow("Unsafe config key: __proto__");
32+
expect(Object.hasOwn(Object.prototype, "logicsrcPrototypePollution")).toBe(false);
33+
});
34+
35+
it("does not read inherited config values", () => {
36+
const config = Object.create({ inherited: "secret" }) as JsonObject;
37+
38+
expect(getConfigValue("inherited", config)).toBeUndefined();
39+
});
40+
});

packages/cli/src/config.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { homedir } from "node:os";
44

55
export type JsonObject = Record<string, unknown>;
66

7+
const UNSAFE_CONFIG_KEYS = new Set(["__proto__", "prototype", "constructor"]);
8+
79
export const defaultConfig: JsonObject = {
810
waiting: {
911
arcade: {
@@ -40,17 +42,23 @@ export function writeConfig(config: JsonObject) {
4042
}
4143

4244
export function getConfigValue(path: string, config = readConfig()) {
43-
return path.split(".").reduce<unknown>((current, key) => (isObject(current) ? current[key] : undefined), config);
45+
return path.split(".").reduce<unknown>((current, key) => {
46+
if (UNSAFE_CONFIG_KEYS.has(key) || !isObject(current) || !Object.hasOwn(current, key)) {
47+
return undefined;
48+
}
49+
return current[key];
50+
}, config);
4451
}
4552

4653
export function setConfigValue(path: string, rawValue: string, config = readConfig()) {
4754
const parts = path.split(".").filter(Boolean);
4855
if (parts.length === 0) {
4956
throw new Error("Config path cannot be empty.");
5057
}
58+
parts.forEach(assertSafeConfigKey);
5159
let current: JsonObject = config;
5260
for (const part of parts.slice(0, -1)) {
53-
if (!isObject(current[part])) {
61+
if (!Object.hasOwn(current, part) || !isObject(current[part])) {
5462
current[part] = {};
5563
}
5664
current = current[part] as JsonObject;
@@ -71,9 +79,10 @@ export function parseConfigValue(value: string): unknown {
7179
}
7280
}
7381

74-
function mergeConfig(base: JsonObject, override: JsonObject): JsonObject {
82+
export function mergeConfig(base: JsonObject, override: JsonObject): JsonObject {
7583
for (const [key, value] of Object.entries(override)) {
76-
if (isObject(value) && isObject(base[key])) {
84+
assertSafeConfigKey(key);
85+
if (isObject(value) && Object.hasOwn(base, key) && isObject(base[key])) {
7786
base[key] = mergeConfig(base[key] as JsonObject, value);
7887
} else {
7988
base[key] = value;
@@ -85,3 +94,9 @@ function mergeConfig(base: JsonObject, override: JsonObject): JsonObject {
8594
function isObject(value: unknown): value is JsonObject {
8695
return typeof value === "object" && value !== null && !Array.isArray(value);
8796
}
97+
98+
function assertSafeConfigKey(key: string) {
99+
if (UNSAFE_CONFIG_KEYS.has(key)) {
100+
throw new Error(`Unsafe config key: ${key}`);
101+
}
102+
}

0 commit comments

Comments
 (0)