diff --git a/src/features/mcp/codexcli-mcp.ts b/src/features/mcp/codexcli-mcp.ts index e034e9c9c..65b4328d0 100644 --- a/src/features/mcp/codexcli-mcp.ts +++ b/src/features/mcp/codexcli-mcp.ts @@ -6,7 +6,7 @@ import { ValidationResult } from "../../types/ai-file.js"; import { McpServers } from "../../types/mcp.js"; import { readFileContentOrNull, readOrInitializeFileContent } from "../../utils/file.js"; import { warnWithFallback } from "../../utils/logger.js"; -import { isRecord } from "../../utils/type-guards.js"; +import { isPlainObject, isRecord } from "../../utils/type-guards.js"; import { RulesyncMcp } from "./rulesync-mcp.js"; import { ToolMcp, @@ -21,12 +21,6 @@ const MAX_REMOVE_EMPTY_ENTRIES_DEPTH = 32; const PROTOTYPE_POLLUTION_KEYS = new Set(["__proto__", "constructor", "prototype"]); -function isPlainObject(value: unknown): value is Record { - if (!isRecord(value)) return false; - const proto = Object.getPrototypeOf(value); - return proto === null || proto === Object.prototype; -} - function convertFromCodexFormat(codexMcp: Record): McpServers { const result: McpServers = {}; diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts index e8ed0949a..2b8441e96 100644 --- a/src/utils/frontmatter.ts +++ b/src/utils/frontmatter.ts @@ -2,12 +2,7 @@ import matter from "gray-matter"; import { dump, load } from "js-yaml"; import { formatError } from "./error.js"; - -function isPlainObject(value: unknown): value is Record { - if (value === null || typeof value !== "object") return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} +import { isPlainObject } from "./type-guards.js"; function deepRemoveNullishValue(value: unknown): unknown { if (value === null || value === undefined) { diff --git a/src/utils/type-guards.ts b/src/utils/type-guards.ts index b87d8a42a..ab8ea94b7 100644 --- a/src/utils/type-guards.ts +++ b/src/utils/type-guards.ts @@ -5,3 +5,20 @@ export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } + +/** + * Stricter sibling of {@link isRecord}: narrows to *plain* objects whose + * prototype is either ``Object.prototype`` or ``null`` — i.e. object + * literals, ``Object.create(null)`` bags, and the output of ``JSON.parse``. + * + * Rejects class instances even though they pass ``isRecord``. This is the + * check needed for prototype-pollution hardening: anything walking + * arbitrary user-supplied keys (frontmatter parsing, MCP config + * conversion, etc.) should reject inputs whose prototype could carry + * malicious accessor descriptors. + */ +export function isPlainObject(value: unknown): value is Record { + if (!isRecord(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === null || proto === Object.prototype; +}