diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index 0b365986..011bc156 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -56,6 +56,16 @@ import { isFrameworkInternal } from "#handlers/framework-internals"; // the filesystem-backed add/reload/remove paths are Node-only and never run in browser mode. import { fsp, path } from "@cldmv/slothlet/helpers/platform"; +/** + * Mount-path segment names that must never be written through to the object graph. + * Assigning to any of these while walking `current[segment] = …` mutates `Object.prototype` + * (or `Function.prototype`) globally — classic prototype pollution — instead of the api tree. + * They are refused at path-normalization time in any segment position (#302). + * @type {ReadonlySet} + * @private + */ +const UNSAFE_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]); + /** * Manages runtime API component lifecycle (add/remove/reload). * @class ApiManager @@ -165,6 +175,18 @@ export class ApiManager extends ComponentBase { }); } + // Prototype-pollution guard: refuse a __proto__/constructor/prototype segment in any position (#302). + const unsafeArrayIndex = apiPath.findIndex((segment) => UNSAFE_PATH_SEGMENTS.has(segment)); + if (unsafeArrayIndex !== -1) { + throw new this.SlothletError("INVALID_CONFIG_API_PATH_INVALID", { + apiPath, + reason: translate("API_PATH_REASON_UNSAFE_SEGMENT"), + index: unsafeArrayIndex, + segment: apiPath[unsafeArrayIndex], + validationError: true + }); + } + return { apiPath: apiPath.join("."), parts: apiPath }; } @@ -206,6 +228,18 @@ export class ApiManager extends ComponentBase { }); } + // Prototype-pollution guard: refuse a __proto__/constructor/prototype segment in any position (#302). + const unsafeIndex = parts.findIndex((segment) => UNSAFE_PATH_SEGMENTS.has(segment)); + if (unsafeIndex !== -1) { + throw new this.SlothletError("INVALID_CONFIG_API_PATH_INVALID", { + apiPath: normalized, + reason: translate("API_PATH_REASON_UNSAFE_SEGMENT"), + index: unsafeIndex, + segment: parts[unsafeIndex], + validationError: true + }); + } + return { apiPath: normalized, parts }; } @@ -443,6 +477,26 @@ export class ApiManager extends ComponentBase { * @public */ setOwnedProperty(apiPath, value, callerWrapper) { + // Reject prototype-pollution segments BEFORE the canonical normalizer runs. This surface has + // its own dedicated LOOSE_SET_RESERVED_KEY error; `normalizeApiPath` now also blocks these + // segments, but with a generic INVALID_CONFIG_API_PATH_INVALID, so the check must run first to + // keep the loose-set error. `self.__proto__ = obj` would assign onto the API root's prototype + // chain; `self.a.__proto__ = obj` (via the dotted form) would do the same on the wrapper at `a`. + // Reuses the module-level UNSAFE_PATH_SEGMENTS (same set the add-path guard uses). `String()` + // coerces without a branch and without throwing on a Symbol (unlike a template literal) — callers + // always pass `String(prop)`, and null/undefined stringify to a non-reserved token that falls + // through to the empty-path guard below. + const coercedPath = String(apiPath); + for (const segment of coercedPath.split(".")) { + if (UNSAFE_PATH_SEGMENTS.has(segment)) { + throw new this.SlothletError("LOOSE_SET_RESERVED_KEY", { + apiPath: coercedPath, + segment, + validationError: true + }); + } + } + // Delegate parsing to the canonical normalizer used by api.add / api.remove: // it rejects empty segments (e.g. "a..b") AND reserved root names (slothlet, // shutdown, destroy) so a runtime `self.slothlet = …` can't overwrite the @@ -463,21 +517,6 @@ export class ApiManager extends ComponentBase { }); } - // Reject prototype-pollution segments at any position. `self.__proto__ = obj` - // would assign onto the API root's prototype chain; `self.a.__proto__ = obj` - // (via dotted form) would do the same on the wrapper at `a`. Mirrors the same - // blocked set used by metadata.mjs and api_builder.mjs. - const RESERVED = new Set(["__proto__", "prototype", "constructor"]); - for (const segment of parts) { - if (RESERVED.has(segment)) { - throw new this.SlothletError("LOOSE_SET_RESERVED_KEY", { - apiPath: parts.join("."), - segment, - validationError: true - }); - } - } - // Ownership root = the caller module's MOUNT POINT, not its function-level apiPath. // The wrapper for `lib.config.foo` may belong to a module whose entire mount // point is `lib.config` — that whole subtree is the module's domain. We look diff --git a/src/lib/i18n/languages/de-de.json b/src/lib/i18n/languages/de-de.json index 702a617a..88868a66 100644 --- a/src/lib/i18n/languages/de-de.json +++ b/src/lib/i18n/languages/de-de.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "Array-Elemente müssen Strings sein", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "Array enthält leere String-Segmente", "API_PATH_REASON_RESERVED_NAME": "kollidiert mit reservierten Namen (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "enthält ein unsicheres Segment (__proto__, constructor, prototype), das die Prototypenkette verunreinigen würde", "API_PATH_REASON_INVALID_TYPE": "muss ein String, ein Array von Strings, ein leerer String (Root) oder null/undefined (Root) sein", "API_PATH_REASON_EMPTY_SEGMENTS": "enthält leere Pfad-Segmente", "API_PATH_REASON_COLLISION_ERROR": "Pfad existiert bereits und Kollisionsmodus ist 'error'", diff --git a/src/lib/i18n/languages/en-gb.json b/src/lib/i18n/languages/en-gb.json index 00d718cf..873919c9 100644 --- a/src/lib/i18n/languages/en-gb.json +++ b/src/lib/i18n/languages/en-gb.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "array elements must be strings", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "array contains empty string segments", "API_PATH_REASON_RESERVED_NAME": "conflicts with reserved names (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "contains an unsafe segment (__proto__, constructor, prototype) that would pollute the prototype chain", "API_PATH_REASON_INVALID_TYPE": "must be a string, array of strings, empty string (root), or null/undefined (root)", "API_PATH_REASON_EMPTY_SEGMENTS": "contains empty path segments", "API_PATH_REASON_COLLISION_ERROR": "path already exists and collision mode is 'error'", diff --git a/src/lib/i18n/languages/en-us.json b/src/lib/i18n/languages/en-us.json index 2ba93b98..3e887866 100644 --- a/src/lib/i18n/languages/en-us.json +++ b/src/lib/i18n/languages/en-us.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "array elements must be strings", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "array contains empty string segments", "API_PATH_REASON_RESERVED_NAME": "conflicts with reserved names (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "contains an unsafe segment (__proto__, constructor, prototype) that would pollute the prototype chain", "API_PATH_REASON_INVALID_TYPE": "must be a string, array of strings, empty string (root), or null/undefined (root)", "API_PATH_REASON_EMPTY_SEGMENTS": "contains empty path segments", "API_PATH_REASON_COLLISION_ERROR": "path already exists and collision mode is 'error'", diff --git a/src/lib/i18n/languages/es-es.json b/src/lib/i18n/languages/es-es.json index 45f266b9..1fc944b0 100644 --- a/src/lib/i18n/languages/es-es.json +++ b/src/lib/i18n/languages/es-es.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "los elementos del array deben ser cadenas", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "el array contiene segmentos de cadena vacíos", "API_PATH_REASON_RESERVED_NAME": "entra en conflicto con nombres reservados (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "contiene un segmento no seguro (__proto__, constructor, prototype) que contaminaría la cadena de prototipos", "API_PATH_REASON_INVALID_TYPE": "debe ser una cadena, un array de cadenas, cadena vacía (raíz) o null/undefined (raíz)", "API_PATH_REASON_EMPTY_SEGMENTS": "contiene segmentos de ruta vacíos", "API_PATH_REASON_COLLISION_ERROR": "la ruta ya existe y el modo de colisión es 'error'", diff --git a/src/lib/i18n/languages/es-mx.json b/src/lib/i18n/languages/es-mx.json index ae1b78d8..bafa14c7 100644 --- a/src/lib/i18n/languages/es-mx.json +++ b/src/lib/i18n/languages/es-mx.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "los elementos del array deben ser cadenas", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "el array contiene segmentos de cadena vacíos", "API_PATH_REASON_RESERVED_NAME": "entra en conflicto con nombres reservados (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "contiene un segmento no seguro (__proto__, constructor, prototype) que contaminaría la cadena de prototipos", "API_PATH_REASON_INVALID_TYPE": "debe ser una cadena, un array de cadenas, cadena vacía (raíz) o null/undefined (raíz)", "API_PATH_REASON_EMPTY_SEGMENTS": "contiene segmentos de ruta vacíos", "API_PATH_REASON_COLLISION_ERROR": "la ruta ya existe y el modo de colisión es 'error'", diff --git a/src/lib/i18n/languages/fr-fr.json b/src/lib/i18n/languages/fr-fr.json index 73a4a57f..77cf3f99 100644 --- a/src/lib/i18n/languages/fr-fr.json +++ b/src/lib/i18n/languages/fr-fr.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "les éléments du tableau doivent être des chaînes", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "le tableau contient des segments de chaînes vides", "API_PATH_REASON_RESERVED_NAME": "entre en conflit avec les noms réservés (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "contient un segment non sécurisé (__proto__, constructor, prototype) qui polluerait la chaîne de prototypes", "API_PATH_REASON_INVALID_TYPE": "doit être une chaîne, un tableau de chaînes, une chaîne vide (racine) ou null/undefined (racine)", "API_PATH_REASON_EMPTY_SEGMENTS": "contient des segments de chemin vides", "API_PATH_REASON_COLLISION_ERROR": "le chemin existe déjà et le mode de collision est 'error'", diff --git a/src/lib/i18n/languages/hi-in.json b/src/lib/i18n/languages/hi-in.json index c1acee29..bbdd801d 100644 --- a/src/lib/i18n/languages/hi-in.json +++ b/src/lib/i18n/languages/hi-in.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "एरे तत्व स्ट्रिंग्स होने चाहिए", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "एरे में खाली स्ट्रिंग खंड हैं", "API_PATH_REASON_RESERVED_NAME": "आरक्षित नामों (slothlet, shutdown, destroy) के साथ संघर्ष", + "API_PATH_REASON_UNSAFE_SEGMENT": "एक असुरक्षित सेगमेंट (__proto__, constructor, prototype) शामिल है जो प्रोटोटाइप श्रृंखला को दूषित करेगा", "API_PATH_REASON_INVALID_TYPE": "स्ट्रिंग, स्ट्रिंग का एरे, खाली स्ट्रिंग (रूट), या null/undefined (रूट) होना चाहिए", "API_PATH_REASON_EMPTY_SEGMENTS": "इसमें खाली पथ खंड हैं", "API_PATH_REASON_COLLISION_ERROR": "पथ पहले से मौजूद है और टकराव मोड 'error' है", diff --git a/src/lib/i18n/languages/ja-jp.json b/src/lib/i18n/languages/ja-jp.json index dc6d515a..67df20ab 100644 --- a/src/lib/i18n/languages/ja-jp.json +++ b/src/lib/i18n/languages/ja-jp.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "配列の要素は文字列である必要があります", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "配列に空の文字列セグメントが含まれています", "API_PATH_REASON_RESERVED_NAME": "予約名 (slothlet, shutdown, destroy) と衝突しています", + "API_PATH_REASON_UNSAFE_SEGMENT": "プロトタイプチェーンを汚染する安全でないセグメント (__proto__, constructor, prototype) が含まれています", "API_PATH_REASON_INVALID_TYPE": "文字列、文字列の配列、空の文字列 (ルート)、または null/undefined (ルート) である必要があります", "API_PATH_REASON_EMPTY_SEGMENTS": "空のパスセグメントが含まれています", "API_PATH_REASON_COLLISION_ERROR": "パスが既に存在し、衝突モードが 'error' です", diff --git a/src/lib/i18n/languages/ko-kr.json b/src/lib/i18n/languages/ko-kr.json index 24d9d22d..df234588 100644 --- a/src/lib/i18n/languages/ko-kr.json +++ b/src/lib/i18n/languages/ko-kr.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "배열 요소는 문자열이어야 함", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "배열에 빈 문자열 세그먼트가 포함되어 있음", "API_PATH_REASON_RESERVED_NAME": "예약어(slothlet, shutdown, destroy)와 충돌함", + "API_PATH_REASON_UNSAFE_SEGMENT": "프로토타입 체인을 오염시킬 수 있는 안전하지 않은 세그먼트(__proto__, constructor, prototype)를 포함함", "API_PATH_REASON_INVALID_TYPE": "문자열, 문자열 배열, 빈 문자열(루트), 또는 null/undefined(루트)여야 함", "API_PATH_REASON_EMPTY_SEGMENTS": "빈 경로 세그먼트를 포함하고 있음", "API_PATH_REASON_COLLISION_ERROR": "경로가 이미 존재하고 충돌 모드가 'error'임", diff --git a/src/lib/i18n/languages/pt-br.json b/src/lib/i18n/languages/pt-br.json index ced94f40..9591db42 100644 --- a/src/lib/i18n/languages/pt-br.json +++ b/src/lib/i18n/languages/pt-br.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "elementos do array devem ser strings", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "o array contém segmentos de string vazios", "API_PATH_REASON_RESERVED_NAME": "conflita com nomes reservados (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "contém um segmento inseguro (__proto__, constructor, prototype) que poluiria a cadeia de protótipos", "API_PATH_REASON_INVALID_TYPE": "deve ser uma string, array de strings, string vazia (raiz) ou null/undefined (raiz)", "API_PATH_REASON_EMPTY_SEGMENTS": "contém segmentos de caminho vazios", "API_PATH_REASON_COLLISION_ERROR": "o caminho já existe e o modo de colisão é 'error'", diff --git a/src/lib/i18n/languages/ru-ru.json b/src/lib/i18n/languages/ru-ru.json index 50375723..5aed1931 100644 --- a/src/lib/i18n/languages/ru-ru.json +++ b/src/lib/i18n/languages/ru-ru.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "Элементы массива должны быть строками", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "Массив содержит пустые сегменты", "API_PATH_REASON_RESERVED_NAME": "Конфликт с зарезервированными именами (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "Содержит небезопасный сегмент (__proto__, constructor, prototype), который загрязнил бы цепочку прототипов", "API_PATH_REASON_INVALID_TYPE": "Должно быть строкой, массивом строк, пустой строкой (root) или null/undefined (root)", "API_PATH_REASON_EMPTY_SEGMENTS": "Содержит пустые сегменты пути", "API_PATH_REASON_COLLISION_ERROR": "Путь уже существует и режим столкновения 'error'", diff --git a/src/lib/i18n/languages/zh-cn.json b/src/lib/i18n/languages/zh-cn.json index 9136c9ab..6d737424 100644 --- a/src/lib/i18n/languages/zh-cn.json +++ b/src/lib/i18n/languages/zh-cn.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "数组元素必须为字符串", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "数组包含空字符串片段", "API_PATH_REASON_RESERVED_NAME": "与保留名称冲突(slothlet、shutdown、destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "包含会污染原型链的不安全段(__proto__、constructor、prototype)", "API_PATH_REASON_INVALID_TYPE": "必须是字符串、字符串数组、空字符串(根)或 null/undefined(根)", "API_PATH_REASON_EMPTY_SEGMENTS": "包含空的路径段", "API_PATH_REASON_COLLISION_ERROR": "路径已存在且冲突模式为 'error'", diff --git a/tests/vitests/suites/api-manager/api-manager-reserved-paths.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-reserved-paths.test.vitest.mjs index 5e5bebe4..e141c64c 100644 --- a/tests/vitests/suites/api-manager/api-manager-reserved-paths.test.vitest.mjs +++ b/tests/vitests/suites/api-manager/api-manager-reserved-paths.test.vitest.mjs @@ -97,3 +97,46 @@ describe.each(CONFIGS)("normalizeApiPath reserved names — $name", ({ config }) await expect(api.slothlet.api.add("a..b", TEST_DIRS.API_TEST)).rejects.toMatchObject({ code: "INVALID_CONFIG_API_PATH_INVALID" }); }); }); + +// #302 – a mount-path segment of __proto__/constructor/prototype must be refused, never +// written through to the real prototype chain (prototype pollution). Any segment position +// is dangerous, and both the dotted-string and array path forms funnel through normalizeApiPath. +describe.each(CONFIGS)("normalizeApiPath prototype-pollution guard — $name", ({ config }) => { + let api; + + afterEach(async () => { + // Fail loudly if any assertion below leaked a write onto Object.prototype. + delete Object.prototype.x; + delete Object.prototype.pwn; + if (api) { + await api.shutdown(); + api = null; + } + }); + + const UNSAFE = [ + { label: "__proto__ (string)", path: "__proto__.x", probe: "x" }, + { label: "constructor.prototype (string)", path: "constructor.prototype.pwn", probe: "pwn" }, + { label: "__proto__ (array)", path: ["__proto__", "x"], probe: "x" }, + { label: "prototype at a deeper segment (array)", path: ["safe", "prototype", "pwn"], probe: "pwn" } + ]; + + it.each(UNSAFE)("rejects a $label mount path and does not pollute Object.prototype", async ({ path, probe }) => { + api = await makeApi(config); + await expect(api.slothlet.api.add(path, () => "polluted", { moduleID: "proto-guard" })).rejects.toMatchObject({ + code: "INVALID_CONFIG_API_PATH_INVALID" + }); + // The write must not have reached the prototype chain. + expect(Object.prototype[probe]).toBeUndefined(); + expect({}[probe]).toBeUndefined(); + }); + + it("accepts a __-prefixed / reserved-substring segment (precise guard, not a blanket '__' ban)", async () => { + // Only the exact segments __proto__/constructor/prototype are refused. A name that merely + // starts with "__" (the module-private convention) or contains a reserved word as a substring + // must still mount — the guard matches whole segments, not prefixes or substrings. + api = await makeApi(config); + await expect(api.slothlet.api.add("__config.value", () => 1, { moduleID: "safe-underscore" })).resolves.toBeDefined(); + await expect(api.slothlet.api.add(["prototypeName", "leaf"], () => 2, { moduleID: "safe-substring" })).resolves.toBeDefined(); + }); +});