diff --git a/changelog.d/fixes/11880-provider-icon-prototype-collision.md b/changelog.d/fixes/11880-provider-icon-prototype-collision.md new file mode 100644 index 00000000000..493608fb8ac --- /dev/null +++ b/changelog.d/fixes/11880-provider-icon-prototype-collision.md @@ -0,0 +1 @@ +- **fix(dashboard):** the providers page no longer crashes into the error boundary when a provider id collides with an `Object.prototype` member (`constructor`, `__proto__`); icon lookups are own-property guarded ([#11880](https://github.com/diegosouzapw/OmniRoute/pull/11880)) — thanks @NoxzRCW diff --git a/src/shared/components/lobeProviderIcons.ts b/src/shared/components/lobeProviderIcons.ts index 5cc463d98c5..c45a07cb1a8 100644 --- a/src/shared/components/lobeProviderIcons.ts +++ b/src/shared/components/lobeProviderIcons.ts @@ -484,9 +484,17 @@ export function getLobeProviderIcon( providerId: string, type: "mono" | "color" = "color" ): LobeIconComponent | null { - const iconKey = LOBE_PROVIDER_ALIASES[providerId.toLowerCase()]; - if (!iconKey) return null; + if (typeof providerId !== "string") return null; + const aliasKey = providerId.toLowerCase(); + // Own-property guards: a providerId such as "constructor" or "__proto__" + // otherwise resolves through Object.prototype, yielding a truthy iconKey + // whose LOBE_ICON_COMPONENTS lookup is undefined -> `entry.color` throws and + // takes down the whole providers dashboard via the error boundary. + if (!Object.hasOwn(LOBE_PROVIDER_ALIASES, aliasKey)) return null; + const iconKey = LOBE_PROVIDER_ALIASES[aliasKey]; + if (!iconKey || !Object.hasOwn(LOBE_ICON_COMPONENTS, iconKey)) return null; const entry = LOBE_ICON_COMPONENTS[iconKey]; + if (!entry) return null; return type === "color" && entry.color ? entry.color : entry.mono; } diff --git a/tests/unit/lobe-provider-icons-prototype-collision-11853.test.ts b/tests/unit/lobe-provider-icons-prototype-collision-11853.test.ts new file mode 100644 index 00000000000..0e19fe8892f --- /dev/null +++ b/tests/unit/lobe-provider-icons-prototype-collision-11853.test.ts @@ -0,0 +1,48 @@ +/** + * Regression for #11853 — the providers dashboard crashed with + * "Cannot read properties of undefined (reading 'color')" and rendered a + * misleading "Failed to load providers — check your connection" card. + * + * `getLobeProviderIcon()` indexed two plain object literals without own-property + * guards. A provider id whose lowercased form is an Object.prototype member + * resolves through the prototype chain: `LOBE_PROVIDER_ALIASES["constructor"]` + * returns the Object constructor (truthy, so the `if (!iconKey) return null` + * guard passes), then `LOBE_ICON_COMPONENTS[]` is undefined and + * `entry.color` throws — taking the whole page down through the App Router + * error boundary, since ProviderIcon calls this for every provider card. + * + * Only `constructor` and `__proto__` are reachable: every other Object.prototype + * member is camelCase and no longer collides after `.toLowerCase()`. + * + * Runner: node --import tsx/esm --test tests/unit/lobe-provider-icons-prototype-collision-11853.test.ts + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { getLobeProviderIcon } = await import("../../src/shared/components/lobeProviderIcons.ts"); + +test("#11853 — prototype-colliding provider ids return null instead of throwing", () => { + for (const id of ["constructor", "__proto__", "CONSTRUCTOR", "__PROTO__"]) { + for (const type of ["color", "mono"] as const) { + assert.doesNotThrow(() => getLobeProviderIcon(id, type), `${id} (${type}) must not throw`); + assert.equal(getLobeProviderIcon(id, type), null, `${id} (${type}) must resolve to null`); + } + } +}); + +test("#11853 — camelCase prototype members were already safe and stay safe", () => { + for (const id of ["valueOf", "toString", "hasOwnProperty", "isPrototypeOf"]) { + assert.equal(getLobeProviderIcon(id), null); + } +}); + +test("#11853 — no regression: known providers still resolve, unknown ones still null", () => { + assert.notEqual(getLobeProviderIcon("openai"), null); + assert.notEqual(getLobeProviderIcon("anthropic"), null); + assert.equal(getLobeProviderIcon("definitely-not-a-provider"), null); +}); + +test("#11853 — a non-string provider id does not throw", () => { + assert.doesNotThrow(() => getLobeProviderIcon(undefined as unknown as string)); + assert.equal(getLobeProviderIcon(undefined as unknown as string), null); +});