GitHub Security Advisory: GHSA-4c35-wcg5-mm9h
Package: next-intl (NPM)
Severity: MODERATE (CVSS 4.2)
CWE: CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')
Vulnerable versions: <= 4.9.1
Patched in: 4.9.2

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`:

```ts
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-18` — `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).
3. `JSONCodec.tsx:33-53` — `traverseMessages` 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:71` — `precompileMessages(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/