Skip to content
Open
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cldmv/slothlet",
"version": "3.14.0",
"version": "3.14.1",
"moduleVersions": {
"lazy": "3.0.0",
"eager": "3.0.0",
Expand Down
69 changes: 54 additions & 15 deletions src/lib/handlers/api-manager.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>}
* @private
*/
const UNSAFE_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);

/**
* Manages runtime API component lifecycle (add/remove/reload).
* @class ApiManager
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/lib/handlers/unified-wrapper.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3677,6 +3677,16 @@ export class UnifiedWrapper extends ComponentBase {
return value;
}

// A callable leaf's built-in function surface — inherited Function.prototype/Object.prototype
// members (apply, call, bind, constructor, …) and the non-enumerable own `prototype` slot — are
// the function's OWN properties, not child endpoints. Return them directly: wrapping one
// registered a phantom child and flipped the leaf's record from function to namespace on a mere
// read (#304). Only a user-added ENUMERABLE own property of the impl materializes as a child.
const currentImpl = wrapper.____slothletInternal.impl;
if (typeof currentImpl === "function" && !Object.prototype.propertyIsEnumerable.call(currentImpl, prop)) {
return value;
}

const wrapped = wrapper.___createChildWrapper(prop, value);
// ___createChildWrapper always returns a wrapper for every value type seen in tests; null is never returned.
/* v8 ignore next */
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/languages/de-de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/languages/en-gb.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/languages/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/languages/es-es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/languages/es-mx.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/languages/fr-fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/languages/hi-in.json
Original file line number Diff line number Diff line change
Expand Up @@ -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' है",
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/languages/ja-jp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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' です",
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/languages/ko-kr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'임",
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/languages/pt-br.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/languages/ru-ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/languages/zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading
Loading