Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/cli/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { getConfigValue, mergeConfig, setConfigValue, type JsonObject } from "./config.js";

describe("config helpers", () => {
it("sets and reads nested own properties", () => {
const config: JsonObject = {};

setConfigValue("waiting.arcade.defaultGame", "snake", config);

expect(getConfigValue("waiting.arcade.defaultGame", config)).toBe("snake");
});

it.each(["__proto__", "prototype", "constructor"])(
"rejects unsafe path key: %s",
(key) => {
const marker = "logicsrcPrototypePollution";
const config: JsonObject = {};

expect(() => setConfigValue(`${key}.${marker}`, "true", config)).toThrow(
`Unsafe config key: ${key}`
);
expect(Object.hasOwn(Object.prototype, marker)).toBe(false);
}
);

it("rejects unsafe keys while merging parsed config", () => {
const override = JSON.parse(
'{"__proto__":{"logicsrcPrototypePollution":true}}'
) as JsonObject;

expect(() => mergeConfig({}, override)).toThrow("Unsafe config key: __proto__");
expect(Object.hasOwn(Object.prototype, "logicsrcPrototypePollution")).toBe(false);
});

it("does not read inherited config values", () => {
const config = Object.create({ inherited: "secret" }) as JsonObject;

expect(getConfigValue("inherited", config)).toBeUndefined();
});
});
23 changes: 19 additions & 4 deletions packages/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { homedir } from "node:os";

export type JsonObject = Record<string, unknown>;

const UNSAFE_CONFIG_KEYS = new Set(["__proto__", "prototype", "constructor"]);

export const defaultConfig: JsonObject = {
waiting: {
arcade: {
Expand Down Expand Up @@ -40,17 +42,23 @@ export function writeConfig(config: JsonObject) {
}

export function getConfigValue(path: string, config = readConfig()) {
return path.split(".").reduce<unknown>((current, key) => (isObject(current) ? current[key] : undefined), config);
return path.split(".").reduce<unknown>((current, key) => {
if (UNSAFE_CONFIG_KEYS.has(key) || !isObject(current) || !Object.hasOwn(current, key)) {
return undefined;
}
return current[key];
}, config);
}

export function setConfigValue(path: string, rawValue: string, config = readConfig()) {
const parts = path.split(".").filter(Boolean);
if (parts.length === 0) {
throw new Error("Config path cannot be empty.");
}
parts.forEach(assertSafeConfigKey);
let current: JsonObject = config;
for (const part of parts.slice(0, -1)) {
if (!isObject(current[part])) {
if (!Object.hasOwn(current, part) || !isObject(current[part])) {
current[part] = {};
}
current = current[part] as JsonObject;
Expand All @@ -71,9 +79,10 @@ export function parseConfigValue(value: string): unknown {
}
}

function mergeConfig(base: JsonObject, override: JsonObject): JsonObject {
export function mergeConfig(base: JsonObject, override: JsonObject): JsonObject {
for (const [key, value] of Object.entries(override)) {
if (isObject(value) && isObject(base[key])) {
assertSafeConfigKey(key);
if (isObject(value) && Object.hasOwn(base, key) && isObject(base[key])) {
base[key] = mergeConfig(base[key] as JsonObject, value);
} else {
base[key] = value;
Expand All @@ -85,3 +94,9 @@ function mergeConfig(base: JsonObject, override: JsonObject): JsonObject {
function isObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function assertSafeConfigKey(key: string) {
if (UNSAFE_CONFIG_KEYS.has(key)) {
throw new Error(`Unsafe config key: ${key}`);
}
}
Loading