Root cause
Resource abuse & availability introduced
feat: Ahead-of-time compilation for messages (#2220)
How AI contributed: Direct introductionPublished May 6, 2026 in
amannn/next-intl
The AI-written ahead-of-time compiler in packages/icu-minify stored ICU select options in a plain object literal in compile.tsx and looked them up with options[value] ?? options.other in format.tsx. A message value naming an inherited member such as constructor, valueOf or hasOwnProperty resolves to a truthy Function from Object.prototype, so the fallback is skipped and formatNodes calls that function, which throws. The failure surfaces as a 500 response wherever the message is rendered with precompile enabled.
Select option tables were built as plain object literals, so a lookup key matching an Object.prototype member resolves through the prototype chain, comes back truthy, and defeats the options[value] ?? options.other fallback meant for unknown values. The fix creates the tables with Object.create(null) and rejects __proto__, constructor and prototype path segments.
Root cause
feat: Ahead-of-time compilation for messages (#2220)
Fix
fix: Prototype safety guards for `precompile: true` (#2307)
Case facts
@@ -1,5 +1,5 @@-import {IntlMessageFormat} from 'intl-messageformat'; import {type ReactNode, cloneElement, isValidElement} from 'react';+import formatMessage from 'use-intl/format-message'; import type AbstractIntlMessages from './AbstractIntlMessages.js'; import type {Locale} from './AppConfig.js'; import type Formats from './Formats.js';@@ -12,33 +12,34 @@ import type { RichTranslationValues, TranslationValues } from './TranslationValues.js';-import convertFormatsToIntlMessageFormat from './convertFormatsToIntlMessageFormat.js'; import {defaultGetMessageFallback} from './defaults.js';-import {- type Formatters,- type IntlCache,- type IntlFormatters,- type MessageFormatter,- memoFn-} from './formatters.js';+import type {Formatters, IntlCache} from './formatters.js'; import joinPath from './joinPath.js'; -// Placed here for improved tree shaking. Somehow when this is placed in-// `formatters.tsx`, then it can't be shaken off from `next-intl`.-function createMessageFormatter(- cache: IntlCache,- intlFormatters: IntlFormatters-): MessageFormatter {- const getMessageFormat = memoFn(- (...args: ConstructorParameters<typeof IntlMessageFormat>) =>- new IntlMessageFormat(args[0], args[1], args[2], {- formatters: intlFormatters,- ...args[3]- }),- cache.message- );-- return getMessageFormat;+function prepareTranslationValues(values: RichTranslationValues) {+ // Related to https://github.com/formatjs/formatjs/issues/1467+ const transformedValues: RichTranslationValues = {};+ Object.keys(values).forEach((key) => {+ let index = 0;+ const value = values[key];++ let transformed;+ if (typeof value === 'function') {+ transformed = (chunks: ReactNode) => {+ const result = value(chunks);++ return isValidElement(result)+ ? cloneElement(result, {key: key + index++})+ : result;+ };+ } else {+ transformed = value;+ }++ transformedValues[key] = transformed;+ });++ return transformedValues; } function resolvePath(@@ -77,32 +78,6 @@ function resolvePath( return message; } -function prepareTranslationValues(values: RichTranslationValues) {- // Workaround for https://github.com/formatjs/formatjs/issues/1467- const transformedValues: RichTranslationValues = {};- Object.keys(values).forEach((key) => {- let index = 0;- const value = values[key];-- let transformed;- if (typeof value === 'function') {- transformed = (chunks: ReactNode) => {- const result = value(chunks);-- return isValidElement(result)- ? cloneElement(result, {key: key + index++})- : result;- };- } else {- transformed = value;- }-@@ -1,4 +1,5 @@ import fs from 'fs';+import {createRequire} from 'module'; import path from 'path'; import type {NextConfig} from 'next'; import type {@@ -14,6 +15,7 @@ import {hasStableTurboConfig, isNextJs16OrHigher} from './nextFlags.js'; import type {PluginConfig} from './types.js'; import {throwError} from './utils.js'; +const require = createRequire(import.meta.url); function withExtensions(localPath: string) { return [ `${localPath}.ts`,@@ -69,6 +71,12 @@ export default function getNextConfig( nextConfig?: NextConfig ) { const useTurbo = process.env.TURBOPACK != null;++ // `experimental-analyze` doesn’t set the TURBOPACK env param. Since Next.js+ // 16 doesn't print a warning when we configure both Turbo- and Webpack, just+ // always configure Turbopack just in case.+ const shouldConfigureTurbo = useTurbo || isNextJs16OrHigher();+ const nextIntlConfig: Partial<NextConfig> = {}; function getExtractMessagesLoaderConfig() {@@ -123,7 +131,19 @@ export default function getNextConfig( } } - if (useTurbo) {+ // Validate messages config+ if (pluginConfig.experimental?.messages) {+ const messages = pluginConfig.experimental.messages;+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- For non-TS consumers+ if (!messages.format) {+ throwError('`format` is required when using `messages`.');+ }+ if (!messages.path) {+ throwError('`path` is required when using `messages`.');+ }+ }++ if (shouldConfigureTurbo) { if ( pluginConfig.requestConfig && path.isAbsolute(pluginConfig.requestConfig)@@ -135,12 +155,31 @@ export default function getNextConfig( } // Assign alias for `next-intl/config`- const resolveAlias = {+ const resolveAlias: Record<string, string> = { // Turbo aliases don't work with absolute // paths (see error handling above) 'next-intl/config': resolveI18nPath(pluginConfig.requestConfig) }; + // Add alias for precompiled message formatting+ if (pluginConfig.experimental?.messages?.precompile) {+ // Workaround for https://github.com/vercel/next.js/issues/88540+ let formatOnlyPath = path.relative(+ process.cwd(),+ require.resolve('use-intl/format-message/format-only')+ );++ // Turbopack seems to require this, otherwise `use-intl/format-message` is+ // still bundled (despite the code correctly calling into `format-only`).+ // Note that in this monorepo this is not necessary, because we'll end+ // up with a path like `../…` — but for actual consumers this is required.+ if (!formatOnlyPath.startsWith('.')) {+ formatOnlyPath = `./${formatOnlyPath}`;+ }++ resolveAlias['use-intl/format-message'] = formatOnlyPath;+ }+ // Add loaders let rules: Record<string, TurbopackRuleConfigCollection> | undefined; @@ -215,7 +254,9 @@ export default function getNextConfig( } }; }- } else {+ }++ if (!useTurbo) { nextIntlConfig.webpack = function webpack(config: Configuration, context) {@@ -848,7 +848,7 @@ describe('error handling', () => { const error: IntlError = onError.mock.calls[0][0]; expect(error.code).toBe(IntlErrorCode.INSUFFICIENT_PATH); expect(error.message).toBe(- 'INSUFFICIENT_PATH: Message at `Component.object` resolved to an object, but only strings are supported. Use a `.` to retrieve nested messages. See https://next-intl.dev/docs/usage/translations#structuring-messages'+ 'INSUFFICIENT_PATH: Message at `Component.object` resolved to `object`, but only strings are supported. Use a `.` to retrieve nested messages. See https://next-intl.dev/docs/usage/translations#structuring-messages' ); }); @@ -873,7 +873,7 @@ describe('error handling', () => { const error: IntlError = onError.mock.calls[0][0]; expect(error.code).toBe(IntlErrorCode.INSUFFICIENT_PATH); expect(error.message).toBe(- 'INSUFFICIENT_PATH: Message at `Component.object` resolved to an object, but only strings are supported. Use a `.` to retrieve nested messages. See https://next-intl.dev/docs/usage/translations#structuring-messages'+ 'INSUFFICIENT_PATH: Message at `Component.object` resolved to `object`, but only strings are supported. Use a `.` to retrieve nested messages. See https://next-intl.dev/docs/usage/translations#structuring-messages' ); }); @@ -90,7 +90,7 @@ function precompileMessages( messages: Array<ExtractorMessage>, cache: Map<string, CompiledMessageCacheEntry> ): Record<string, unknown> {- const result: Record<string, unknown> = {};+ const result = Object.create(null) as Record<string, unknown>; const cacheKeysToEvict = new Set(cache.keys()); for (const message of messages) {@@ -9,23 +9,38 @@ export function normalizePathToPosix(filePath: string): string { ); } +const FORBIDDEN_OBJECT_KEYS = new Set([+ '__proto__',+ 'constructor',+ 'prototype'+]);++export function isForbiddenObjectKey(key: string): boolean {+ return FORBIDDEN_OBJECT_KEYS.has(key);+}+ // Essentialls lodash/set, but we avoid this dependency export function setNestedProperty( obj: Record<string, any>, keyPath: string, value: any ): void { const keys = keyPath.split('.');- let current = obj;+ for (const key of keys) {+ if (isForbiddenObjectKey(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 (- !(key in current) ||+ !Object.prototype.hasOwnProperty.call(current, key) || typeof current[key] !== 'object' || current[key] === null ) {- current[key] = {};+ current[key] = Object.create(null); } current = current[key]; }@@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest';-import {getSortedMessages} from './utils.js';+import {getSortedMessages, setNestedProperty} from './utils.js'; describe('getSortedMessages', () => { it('sorts by reference path', () => {@@ -63,3 +63,21 @@ describe('getSortedMessages', () => { ).toEqual(['c', 'a', 'b']); }); });++describe('setNestedProperty', () => {+ it('rejects __proto__ segments (prototype pollution)', () => {+ expect(() => setNestedProperty({}, '__proto__.polluted', 'x')).toThrow(+ 'Invalid message id segment: __proto__'+ );+ expect(+ (Object.prototype as unknown as {polluted?: string}).polluted+ ).toBeUndefined();+ });++ it('creates plain data properties for nested paths', () => {+ const root = Object.create(null) as Record<string, unknown>;+ setNestedProperty(root, 'a.b', 1);+ expect(Object.hasOwn(root, 'a')).toBe(true);+ expect(({} as Record<string, unknown>).b).toBeUndefined();+ });+});AI-assisted change f7c4ca3a5ee2ade4107ba8b712de2731327998fa5e6f935d190e35ebb1d94694 · Fix 442c0e5ff952f0ad359fe3f49fb8e4a835a55334ee2a822be07af9b5d480027e