GHSA-4C35-WCG5-MM9H

Vulnerability from github – Published: 2026-05-06 17:34 – Updated: 2026-05-06 17:34
VLAI
Summary
next-intl has prototype pollution with `experimental.messages.precompile` via attacker-controlled translation catalog keys
Details

Summary

setNestedProperty in packages/next-intl/src/extractor/utils.tsx walks a dotted key path and assigns the final value without blocking the reserved keys __proto__, constructor, or prototype. When the next-intl Next.js plugin is configured with experimental.messages and messages.precompile: true, a JSON translation catalog containing a top‑level __proto__ key causes setNestedProperty(result, '__proto__.isAdmin', compiledMessage) to assign onto Object.prototype, polluting every object in the running build process.

Details

Root cause — packages/next-intl/src/extractor/utils.tsx:13-34:

export function setNestedProperty(
  obj: Record<string, any>,
  keyPath: string,
  value: any
): void {
  const keys = keyPath.split('.');
  let current = obj;

  for (let i = 0; i < keys.length - 1; i++) {
    const key = keys[i];
    if (
      !(key in current) ||
      typeof current[key] !== 'object' ||
      current[key] === null
    ) {
      current[key] = {};
    }
    current = current[key];
  }

  current[keys[keys.length - 1]] = value;
}

The existence check !(key in current) uses the in operator, which walks the prototype chain. For key === '__proto__', '__proto__' in {} is true (it's inherited from Object.prototype) and typeof current['__proto__'] === 'object' (it is Object.prototype). The guard therefore never re-initializes current[key], and current = current['__proto__'] redirects all subsequent writes onto Object.prototype. The final assignment current[keys[keys.length-1]] = value sets Object.prototype[<attacker key>] = <attacker value>.

Build-time data flow:

  1. packages/next-intl/src/plugin/catalog/catalogLoader.tsx:55-83 — the webpack/turbopack loader receives the catalog file source and, if options.messages.precompile is enabled, calls codec.decode(source, {locale}).
  2. packages/next-intl/src/extractor/format/codecs/JSONCodec.tsx:9-18decode runs JSON.parse(source). V8 installs __proto__ as an own data property on the result when the JSON key is literally "__proto__" (bypassing the normal Object.prototype.__proto__ setter that would otherwise reassign the prototype).
  3. JSONCodec.tsx:33-53traverseMessages iterates Object.keys(obj), which for a JSON‑parsed object includes the own __proto__ key. It reads obj.__proto__ (returns the attacker’s nested object, not Object.prototype, because it's an own property), recurses into it, and emits message id __proto__.isAdmin.
  4. catalogLoader.tsx:71precompileMessages(decoded, cache).
  5. catalogLoader.tsx:89-131 — for each message, calls setNestedProperty(result, message.id, compiledMessage). With message.id === '__proto__.isAdmin', setNestedProperty walks into Object.prototype and assigns Object.prototype.isAdmin = compiledMessage.

The same sink is also reachable via JSONCodec.encode (JSONCodec.tsx:20-26) and POCodec (packages/next-intl/src/extractor/format/codecs/POCodec.tsx:87) during extraction, both of which feed attacker-influenced message.id values into setNestedProperty — but those paths require control of source-code identifiers, which is a weaker attack vector than the build-time catalog path above.

After pollution, every subsequent object access during the remainder of the Next.js build pipeline (webpack, turbopack, babel, next-intl’s own logic) inherits the attacker-controlled properties. This is a classic gadget-chain precondition for corrupting build-tool internals and tampering with generated bundles, since many build tools use patterns like if (obj.someFlag) or options[key] ?? default that are sensitive to polluted prototypes.

Trust boundary note: next-intl’s message catalogs are realistically attacker-influenced in practice. Translation files are routinely round-tripped through external TMS systems (Crowdin, Lokalise, Transifex), accepted via community locale PRs, or pulled from third-party translation packages — any of which can carry a crafted __proto__ key unnoticed, since JSON translation diffs are usually merged with minimal scrutiny.

PoC

Prerequisites: a Next.js project using next-intl ≤ 4.9.1 with the Next.js plugin configured:

// next.config.ts
import createNextIntlPlugin from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin({
  experimental: {
    messages: {
      path: './messages',
      format: 'json',
      locales: 'infer',
      precompile: true
    }
  }
});

export default withNextIntl({});
  1. Drop a malicious catalog at messages/en.json:

json { "Greeting": "Hello", "__proto__": { "isAdmin": "polluted" } }

  1. Run next build (or next dev). The catalogLoader will invoke JSONCodec.decodetraverseMessagesprecompileMessagessetNestedProperty.

  2. Minimal reproduction of the sink itself (verified locally against the v4.9.1 source):

```js function setNestedProperty(obj, keyPath, value) { const keys = keyPath.split('.'); let current = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) { current[key] = {}; } current = current[key]; } current[keys[keys.length - 1]] = value; }

setNestedProperty({}, 'proto.isAdmin', 'PWNED'); console.log(({}).isAdmin); // -> "PWNED" ```

Output: PWNED.

  1. Full chain reproduction (also verified):

js const parsed = JSON.parse('{"Greeting":"Hello","__proto__":{"isAdmin":"polluted"}}'); // traverseMessages emits: [{id:"Greeting",message:"Hello"},{id:"__proto__.isAdmin",message:"polluted"}] // precompileMessages then calls setNestedProperty(result, "__proto__.isAdmin", "polluted") console.log(({}).isAdmin); // -> "polluted"

After the loader runs, ({}).isAdmin === 'polluted' for the remainder of the build Node process.

Impact

  • Object.prototype is polluted for the lifetime of the build‑time Node.js process, affecting every object created or inspected thereafter in the Next.js build pipeline (webpack/turbopack loaders, babel plugins, next-intl’s own codecs, user plugins).
  • Classic CWE-1321 gadget-chain precondition: downstream tools that branch on obj.someFlag, options[key] ?? default, if (!config.noX), etc. can be coerced into unintended behavior, including emitting tampered bundles.
  • Realistic delivery vectors include TMS round-trips (Crowdin/Lokalise/Transifex), community locale PRs, and compromised/transitively-installed translation packages — all situations where a JSON catalog diff is routinely accepted without the scrutiny given to code changes.
  • Exploitation requires the user to opt in to the experimental.messages + precompile configuration. Users who do not use the extractor/precompile features are not affected.

Recommended Fix

Reject reserved keys in setNestedProperty and stop using the in operator for the existence check. A minimal patch to packages/next-intl/src/extractor/utils.tsx:

const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);

export function setNestedProperty(
  obj: Record<string, any>,
  keyPath: string,
  value: any
): void {
  const keys = keyPath.split('.');
  for (const key of keys) {
    if (FORBIDDEN_KEYS.has(key)) {
      throw new Error(`Invalid message id segment: ${key}`);
    }
  }

  let current = obj;
  for (let i = 0; i < keys.length - 1; i++) {
    const key = keys[i];
    if (
      !Object.prototype.hasOwnProperty.call(current, key) ||
      typeof current[key] !== 'object' ||
      current[key] === null
    ) {
      current[key] = Object.create(null);
    }
    current = current[key];
  }

  current[keys[keys.length - 1]] = value;
}

Additionally:

  • In packages/next-intl/src/extractor/format/codecs/JSONCodec.tsx, make traverseMessages skip reserved keys (or switch to Object.create(null) + Object.hasOwn semantics) so that a malicious catalog is rejected early with a clear error rather than producing __proto__.* message ids.
  • In packages/next-intl/src/plugin/catalog/catalogLoader.tsx, initialize precompileMessages’s result with Object.create(null) as defense in depth, so even if a key slipped through it could not redirect through Object.prototype.
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 4.9.1"
      },
      "package": {
        "ecosystem": "npm",
        "name": "next-intl"
      },
      "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:34:12Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\n`setNestedProperty` in `packages/next-intl/src/extractor/utils.tsx` walks a dotted key path and assigns the final value without blocking the reserved keys `__proto__`, `constructor`, or `prototype`. When the next-intl Next.js plugin is configured with `experimental.messages` and `messages.precompile: true`, a JSON translation catalog containing a top\u2011level `__proto__` key causes `setNestedProperty(result, \u0027__proto__.isAdmin\u0027, compiledMessage)` to assign onto `Object.prototype`, polluting every object in the running build process.\n\n## Details\n\nRoot cause \u2014 `packages/next-intl/src/extractor/utils.tsx:13-34`:\n\n```ts\nexport function setNestedProperty(\n  obj: Record\u003cstring, any\u003e,\n  keyPath: string,\n  value: any\n): void {\n  const keys = keyPath.split(\u0027.\u0027);\n  let current = obj;\n\n  for (let i = 0; i \u003c keys.length - 1; i++) {\n    const key = keys[i];\n    if (\n      !(key in current) ||\n      typeof current[key] !== \u0027object\u0027 ||\n      current[key] === null\n    ) {\n      current[key] = {};\n    }\n    current = current[key];\n  }\n\n  current[keys[keys.length - 1]] = value;\n}\n```\n\nThe existence check `!(key in current)` uses the `in` operator, which walks the prototype chain. For `key === \u0027__proto__\u0027`, `\u0027__proto__\u0027 in {}` is `true` (it\u0027s inherited from `Object.prototype`) and `typeof current[\u0027__proto__\u0027] === \u0027object\u0027` (it *is* `Object.prototype`). The guard therefore never re-initializes `current[key]`, and `current = current[\u0027__proto__\u0027]` redirects all subsequent writes onto `Object.prototype`. The final assignment `current[keys[keys.length-1]] = value` sets `Object.prototype[\u003cattacker key\u003e] = \u003cattacker value\u003e`.\n\nBuild-time data flow:\n\n1. `packages/next-intl/src/plugin/catalog/catalogLoader.tsx:55-83` \u2014 the webpack/turbopack loader receives the catalog file `source` and, if `options.messages.precompile` is enabled, calls `codec.decode(source, {locale})`.\n2. `packages/next-intl/src/extractor/format/codecs/JSONCodec.tsx:9-18` \u2014 `decode` runs `JSON.parse(source)`. V8 installs `__proto__` as an **own data property** on the result when the JSON key is literally `\"__proto__\"` (bypassing the normal `Object.prototype.__proto__` setter that would otherwise reassign the prototype).\n3. `JSONCodec.tsx:33-53` \u2014 `traverseMessages` iterates `Object.keys(obj)`, which for a JSON\u2011parsed object includes the own `__proto__` key. It reads `obj.__proto__` (returns the attacker\u2019s nested object, not `Object.prototype`, because it\u0027s an own property), recurses into it, and emits message id `__proto__.isAdmin`.\n4. `catalogLoader.tsx:71` \u2014 `precompileMessages(decoded, cache)`.\n5. `catalogLoader.tsx:89-131` \u2014 for each message, calls `setNestedProperty(result, message.id, compiledMessage)`. With `message.id === \u0027__proto__.isAdmin\u0027`, `setNestedProperty` walks into `Object.prototype` and assigns `Object.prototype.isAdmin = compiledMessage`.\n\nThe same sink is also reachable via `JSONCodec.encode` (`JSONCodec.tsx:20-26`) and `POCodec` (`packages/next-intl/src/extractor/format/codecs/POCodec.tsx:87`) during extraction, both of which feed attacker-influenced `message.id` values into `setNestedProperty` \u2014 but those paths require control of source-code identifiers, which is a weaker attack vector than the build-time catalog path above.\n\nAfter pollution, every subsequent object access during the remainder of the Next.js build pipeline (webpack, turbopack, babel, next-intl\u2019s own logic) inherits the attacker-controlled properties. This is a classic gadget-chain precondition for corrupting build-tool internals and tampering with generated bundles, since many build tools use patterns like `if (obj.someFlag)` or `options[key] ?? default` that are sensitive to polluted prototypes.\n\nTrust boundary note: next-intl\u2019s message catalogs are realistically attacker-influenced in practice. Translation files are routinely round-tripped through external TMS systems (Crowdin, Lokalise, Transifex), accepted via community locale PRs, or pulled from third-party translation packages \u2014 any of which can carry a crafted `__proto__` key unnoticed, since JSON translation diffs are usually merged with minimal scrutiny.\n\n## PoC\n\nPrerequisites: a Next.js project using next-intl \u2264 4.9.1 with the Next.js plugin configured:\n\n```ts\n// next.config.ts\nimport createNextIntlPlugin from \u0027next-intl/plugin\u0027;\n\nconst withNextIntl = createNextIntlPlugin({\n  experimental: {\n    messages: {\n      path: \u0027./messages\u0027,\n      format: \u0027json\u0027,\n      locales: \u0027infer\u0027,\n      precompile: true\n    }\n  }\n});\n\nexport default withNextIntl({});\n```\n\n1. Drop a malicious catalog at `messages/en.json`:\n\n   ```json\n   {\n     \"Greeting\": \"Hello\",\n     \"__proto__\": { \"isAdmin\": \"polluted\" }\n   }\n   ```\n\n2. Run `next build` (or `next dev`). The `catalogLoader` will invoke `JSONCodec.decode` \u2192 `traverseMessages` \u2192 `precompileMessages` \u2192 `setNestedProperty`.\n\n3. Minimal reproduction of the sink itself (verified locally against the v4.9.1 source):\n\n   ```js\n   function setNestedProperty(obj, keyPath, value) {\n     const keys = keyPath.split(\u0027.\u0027);\n     let current = obj;\n     for (let i = 0; i \u003c keys.length - 1; i++) {\n       const key = keys[i];\n       if (!(key in current) || typeof current[key] !== \u0027object\u0027 || current[key] === null) {\n         current[key] = {};\n       }\n       current = current[key];\n     }\n     current[keys[keys.length - 1]] = value;\n   }\n\n   setNestedProperty({}, \u0027__proto__.isAdmin\u0027, \u0027PWNED\u0027);\n   console.log(({}).isAdmin); // -\u003e \"PWNED\"\n   ```\n\n   Output: `PWNED`.\n\n4. Full chain reproduction (also verified):\n\n   ```js\n   const parsed = JSON.parse(\u0027{\"Greeting\":\"Hello\",\"__proto__\":{\"isAdmin\":\"polluted\"}}\u0027);\n   // traverseMessages emits: [{id:\"Greeting\",message:\"Hello\"},{id:\"__proto__.isAdmin\",message:\"polluted\"}]\n   // precompileMessages then calls setNestedProperty(result, \"__proto__.isAdmin\", \"polluted\")\n   console.log(({}).isAdmin); // -\u003e \"polluted\"\n   ```\n\n   After the loader runs, `({}).isAdmin === \u0027polluted\u0027` for the remainder of the build Node process.\n\n## Impact\n\n- `Object.prototype` is polluted for the lifetime of the build\u2011time Node.js process, affecting every object created or inspected thereafter in the Next.js build pipeline (webpack/turbopack loaders, babel plugins, next-intl\u2019s own codecs, user plugins).\n- Classic CWE-1321 gadget-chain precondition: downstream tools that branch on `obj.someFlag`, `options[key] ?? default`, `if (!config.noX)`, etc. can be coerced into unintended behavior, including emitting tampered bundles.\n- Realistic delivery vectors include TMS round-trips (Crowdin/Lokalise/Transifex), community locale PRs, and compromised/transitively-installed translation packages \u2014 all situations where a JSON catalog diff is routinely accepted without the scrutiny given to code changes.\n- Exploitation requires the user to opt in to the `experimental.messages` + `precompile` configuration. Users who do not use the extractor/precompile features are not affected.\n\n## Recommended Fix\n\nReject reserved keys in `setNestedProperty` and stop using the `in` operator for the existence check. A minimal patch to `packages/next-intl/src/extractor/utils.tsx`:\n\n```ts\nconst FORBIDDEN_KEYS = new Set([\u0027__proto__\u0027, \u0027constructor\u0027, \u0027prototype\u0027]);\n\nexport function setNestedProperty(\n  obj: Record\u003cstring, any\u003e,\n  keyPath: string,\n  value: any\n): void {\n  const keys = keyPath.split(\u0027.\u0027);\n  for (const key of keys) {\n    if (FORBIDDEN_KEYS.has(key)) {\n      throw new Error(`Invalid message id segment: ${key}`);\n    }\n  }\n\n  let current = obj;\n  for (let i = 0; i \u003c keys.length - 1; i++) {\n    const key = keys[i];\n    if (\n      !Object.prototype.hasOwnProperty.call(current, key) ||\n      typeof current[key] !== \u0027object\u0027 ||\n      current[key] === null\n    ) {\n      current[key] = Object.create(null);\n    }\n    current = current[key];\n  }\n\n  current[keys[keys.length - 1]] = value;\n}\n```\n\nAdditionally:\n\n- In `packages/next-intl/src/extractor/format/codecs/JSONCodec.tsx`, make `traverseMessages` skip reserved keys (or switch to `Object.create(null)` + `Object.hasOwn` semantics) so that a malicious catalog is rejected early with a clear error rather than producing `__proto__.*` message ids.\n- In `packages/next-intl/src/plugin/catalog/catalogLoader.tsx`, initialize `precompileMessages`\u2019s `result` with `Object.create(null)` as defense in depth, so even if a key slipped through it could not redirect through `Object.prototype`.",
  "id": "GHSA-4c35-wcg5-mm9h",
  "modified": "2026-05-06T17:34:12Z",
  "published": "2026-05-06T17:34:12Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/amannn/next-intl/security/advisories/GHSA-4c35-wcg5-mm9h"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/amannn/next-intl"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:L",
      "type": "CVSS_V3"
    }
  ],
  "summary": "next-intl has prototype pollution with `experimental.messages.precompile` via attacker-controlled translation catalog keys"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…