From 5e2ce12cd7b3dcfcb721d9f02cf85d2cfcc64150 Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Thu, 24 Sep 2026 12:59:15 +0200 Subject: [PATCH 1/5] fix(payload): isolate locale properties in createLocalReq Fixes payloadcms/payload#18246. A Local API call that passes both `req` and a different `locale` rewrote the caller's `req.locale`/`req.fallbackLocale` in place. `beforeChange` merges submitted data into per-locale fields based on `req.locale` after hooks/validation have awaited, so the nested call's locale corrupted the parent operation's data (submitted values merged into the wrong locale). Wrap the incoming req with `isolateObjectProperty(req, ['locale', 'fallbackLocale'])` (the same pattern `restoreVersion` already uses) so the nested operation sees its own locale through the returned request while the caller's object keeps its original values. --- .../src/utilities/createLocalReq.spec.ts | 63 +++++++++++++++++++ .../payload/src/utilities/createLocalReq.ts | 53 +++++++++------- 2 files changed, 94 insertions(+), 22 deletions(-) diff --git a/packages/payload/src/utilities/createLocalReq.spec.ts b/packages/payload/src/utilities/createLocalReq.spec.ts index 9ba39b015ce..5657b286b5b 100644 --- a/packages/payload/src/utilities/createLocalReq.spec.ts +++ b/packages/payload/src/utilities/createLocalReq.spec.ts @@ -123,3 +123,66 @@ describe('createLocalReq - URL construction', () => { expect(mockPayload.logger.error).not.toHaveBeenCalled() }) }) + +describe('createLocalReq - locale isolation', () => { + const localizedPayload = { + config: { + admin: { user: 'users' }, + serverURL: undefined, + i18n: { + fallbackLanguage: 'en', + supportedLanguages: { en: {} }, + translations: {}, + }, + localization: { + defaultLocale: 'en', + fallback: true, + localeCodes: ['en', 'es'], + locales: [{ code: 'en' }, { code: 'es' }], + }, + }, + logger: { + error: vi.fn(), + }, + } as unknown as Payload + + it('should not rewrite the caller req locale when the nested call passes a different locale', async () => { + const req = { + i18n: { t: (str: string) => str }, + locale: 'es', + payloadDataLoader: {}, + } + + const result = await createLocalReq({ locale: 'en', req }, localizedPayload) + + expect(result.locale).toBe('en') + expect(req.locale).toBe('es') + }) + + it('should not rewrite the caller req fallbackLocale when the nested call passes a locale', async () => { + const req = { + fallbackLocale: false, + i18n: { t: (str: string) => str }, + locale: 'es', + payloadDataLoader: {}, + } + + const result = await createLocalReq({ locale: 'en', req }, localizedPayload) + + expect(result.fallbackLocale).toBe('en') + expect(req.fallbackLocale).toBe(false) + }) + + it('should fall back to the caller req locale when the nested call passes no locale', async () => { + const req = { + i18n: { t: (str: string) => str }, + locale: 'es', + payloadDataLoader: {}, + } + + const result = await createLocalReq({ req }, localizedPayload) + + expect(result.locale).toBe('es') + expect(req.locale).toBe('es') + }) +}) diff --git a/packages/payload/src/utilities/createLocalReq.ts b/packages/payload/src/utilities/createLocalReq.ts index 9f5c2b7925e..3587fedffeb 100644 --- a/packages/payload/src/utilities/createLocalReq.ts +++ b/packages/payload/src/utilities/createLocalReq.ts @@ -3,7 +3,8 @@ import type { PayloadRequest } from '../types/index.js' import { getDataLoader } from '../collections/dataloader.js' import { getLocalI18n } from '../translations/getLocalI18n.js' -import { sanitizeFallbackLocale } from '../utilities/sanitizeFallbackLocale.js' +import { isolateObjectProperty } from './isolateObjectProperty.js' +import { sanitizeFallbackLocale } from './sanitizeFallbackLocale.js' function getRequestContext( req: Partial = { context: null } as unknown as PayloadRequest, @@ -109,55 +110,63 @@ export const createLocalReq: CreateLocalReq = async ( }, payload, ): Promise => { + // A nested Local API call with an explicit `locale` must not rewrite the + // caller's req: `beforeChange` merges submitted data into per-locale fields + // based on `req.locale` after hooks/validation have awaited, so a mutated + // `req.locale` corrupts the parent operation's data (see #18246). Isolate + // both locale properties; the nested operation still sees its own locale + // through the returned request. + const localReq = isolateObjectProperty(req, ['locale', 'fallbackLocale']) + const localization = payload.config?.localization if (localization) { const locale = localeArg === '*' ? 'all' : localeArg const defaultLocale = localization.defaultLocale - const localeCandidate = locale || req?.locale || req?.query?.locale + const localeCandidate = locale || localReq?.locale || localReq?.query?.locale - req.locale = + localReq.locale = localeCandidate && typeof localeCandidate === 'string' ? localeCandidate : defaultLocale const sanitizedFallback = sanitizeFallbackLocale({ fallbackLocale: fallbackLocale!, - locale: req.locale, + locale: localReq.locale, localization, }) - req.fallbackLocale = sanitizedFallback! + localReq.fallbackLocale = sanitizedFallback! } const i18n = - req?.i18n || + localReq?.i18n || (await getLocalI18n({ config: payload.config, language: payload.config.i18n.fallbackLanguage })) - if (!req.headers) { - req.headers = new Headers() + if (!localReq.headers) { + localReq.headers = new Headers() } - req.context = getRequestContext(req, context) - req.payloadAPI = req?.payloadAPI || 'local' - req.payload = payload - req.i18n = i18n - req.t = i18n.t - req.user = user || req?.user || null + localReq.context = getRequestContext(localReq, context) + localReq.payloadAPI = localReq?.payloadAPI || 'local' + localReq.payload = payload + localReq.i18n = i18n + localReq.t = i18n.t + localReq.user = user || localReq?.user || null // Ensure user.collection is set for auth-related access control // TODO (4.0): Instead of silently falling back, throw an error if user.collection is missing - if (req.user && !req.user.collection) { - req.user = { ...req.user, collection: payload.config.admin.user } + if (localReq.user && !localReq.user.collection) { + localReq.user = { ...localReq.user, collection: payload.config.admin.user } } - req.payloadDataLoader = req?.payloadDataLoader || getDataLoader(req as PayloadRequest) - req.routeParams = req?.routeParams || {} - req.query = req?.query || {} + localReq.payloadDataLoader = localReq?.payloadDataLoader || getDataLoader(localReq as PayloadRequest) + localReq.routeParams = localReq?.routeParams || {} + localReq.query = localReq?.query || {} if (typeof depth !== 'undefined') { - req.query.depth = depth + localReq.query.depth = depth } - attachFakeURLProperties(req, urlSuffix) + attachFakeURLProperties(localReq, urlSuffix) - return req as PayloadRequest + return localReq as PayloadRequest } From 650a6b117e6dbcd5156910a6accbcb4daea63eb4 Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Fri, 23 Oct 2026 11:29:11 +0200 Subject: [PATCH 2/5] fix(payload): make isolated properties visible to spread --- .../utilities/isolateObjectProperty.spec.ts | 46 +++++++++++++++++++ .../src/utilities/isolateObjectProperty.ts | 36 +++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/packages/payload/src/utilities/isolateObjectProperty.spec.ts b/packages/payload/src/utilities/isolateObjectProperty.spec.ts index 6282a464378..171e4f99d7c 100644 --- a/packages/payload/src/utilities/isolateObjectProperty.spec.ts +++ b/packages/payload/src/utilities/isolateObjectProperty.spec.ts @@ -59,6 +59,52 @@ describe('isolateObjectProperty', () => { }) }) + describe('spread and enumeration', () => { + it('should include an isolated property set on the proxy in a spread', () => { + const original: Record = { b: 2 } + const proxy = isolateObjectProperty(original, 'a') + + proxy.a = 100 + + expect({ ...proxy }).toEqual({ a: 100, b: 2 }) + expect(original).toEqual({ b: 2 }) + }) + + it('should include isolated properties in Object.keys and entries', () => { + const original = { a: 1, b: 2 } + const proxy = isolateObjectProperty(original, 'a') + + proxy.a = 100 + + expect(Object.keys(proxy).sort()).toEqual(['a', 'b']) + expect(Object.fromEntries(Object.entries(proxy))).toEqual({ a: 100, b: 2 }) + }) + + it('should report an enumerable, configurable descriptor for an isolated property not on the original', () => { + const original: Record = { b: 2 } + const proxy = isolateObjectProperty(original, 'a') + + proxy.a = 100 + + expect(Object.getOwnPropertyDescriptor(proxy, 'a')).toEqual({ + configurable: true, + enumerable: true, + value: 100, + writable: true, + }) + }) + + it('should not include a deleted isolated property in a spread', () => { + const original: Record = { a: 1, b: 2 } + const proxy = isolateObjectProperty(original, 'a') + + delete proxy.a + + expect({ ...proxy }).toEqual({ b: 2 }) + expect(Object.keys(proxy)).toEqual(['b']) + }) + }) + describe('private field access (Node 24+ compatibility)', () => { /** * Class with private fields to test that the proxy correctly handles diff --git a/packages/payload/src/utilities/isolateObjectProperty.ts b/packages/payload/src/utilities/isolateObjectProperty.ts index 1248a13fb5f..1b9d03b59fa 100644 --- a/packages/payload/src/utilities/isolateObjectProperty.ts +++ b/packages/payload/src/utilities/isolateObjectProperty.ts @@ -23,9 +23,45 @@ export function isolateObjectProperty(object: T, key: (keyof T // Use target as receiver to preserve private field access (e.g., Request#headers in Node 24+) return Reflect.get(target, p, target) }, + getOwnPropertyDescriptor(target, p) { + if (keys.includes(p as keyof T)) { + const descriptor = Reflect.getOwnPropertyDescriptor(delegate, p) + + if (descriptor) { + const targetDescriptor = Reflect.getOwnPropertyDescriptor(target, p) + + // A key held only in the delegate is not an own property of the + // target, so it can only be reported as configurable. Keep the + // target's configurability when it also holds the key. + return { + ...descriptor, + configurable: targetDescriptor ? targetDescriptor.configurable : true, + } + } + + const targetDescriptor = Reflect.getOwnPropertyDescriptor(target, p) + + // An isolated key missing from the delegate counts as absent, + // unless the target holds it as non-configurable, which a proxy + // is not allowed to hide. + return targetDescriptor && !targetDescriptor.configurable ? targetDescriptor : undefined + } + return Reflect.getOwnPropertyDescriptor(target, p) + }, has(target, p) { return Reflect.has(keys.includes(p as keyof T) ? delegate : target, p) }, + ownKeys(target) { + const targetKeys = Reflect.ownKeys(target) + + // Extra keys can only be reported when the target is extensible, + // otherwise the result must match the target's keys exactly. + if (!Object.isExtensible(target)) { + return targetKeys + } + + return [...targetKeys, ...Reflect.ownKeys(delegate).filter((k) => !targetKeys.includes(k))] + }, set(target, p, newValue, _receiver) { if (keys.includes(p as keyof T)) { // in case of transactionID we must ignore any receiver, because From fcfab963017ab2ba6245c52ffe68c567a467a1fc Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Fri, 23 Oct 2026 11:59:05 +0200 Subject: [PATCH 3/5] fix(payload): run formatter on createPayloadRequest --- packages/payload/src/utilities/createPayloadRequest.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/utilities/createPayloadRequest.ts b/packages/payload/src/utilities/createPayloadRequest.ts index 4542c22de45..4973e720697 100644 --- a/packages/payload/src/utilities/createPayloadRequest.ts +++ b/packages/payload/src/utilities/createPayloadRequest.ts @@ -157,7 +157,8 @@ export const createPayloadRequest: CreatePayloadRequest = async ({ localReq.user = { ...localReq.user, collection: payload.config.admin.user } } - localReq.payloadDataLoader = localReq?.payloadDataLoader || getDataLoader(localReq as PayloadRequest) + localReq.payloadDataLoader = + localReq?.payloadDataLoader || getDataLoader(localReq as PayloadRequest) localReq.routeParams = localReq?.routeParams || {} localReq.query = localReq?.query || {} From 2c590d91d2ae6a9a1b7ce5d6fe33f5de51aa2c8c Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Fri, 23 Oct 2026 17:02:18 +0200 Subject: [PATCH 4/5] fix: bind proxied methods to target so private field access works --- packages/payload/src/utilities/isolateObjectProperty.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/utilities/isolateObjectProperty.ts b/packages/payload/src/utilities/isolateObjectProperty.ts index 1b9d03b59fa..b4481665482 100644 --- a/packages/payload/src/utilities/isolateObjectProperty.ts +++ b/packages/payload/src/utilities/isolateObjectProperty.ts @@ -21,7 +21,14 @@ export function isolateObjectProperty(object: T, key: (keyof T return Reflect.get(delegate, p, receiver) } // Use target as receiver to preserve private field access (e.g., Request#headers in Node 24+) - return Reflect.get(target, p, target) + const value = Reflect.get(target, p, target) + // Methods must run against the target: invoked as proxy.method(), `this` + // would be the proxy and private field access (e.g., Request#text reading + // #state in undici) throws "Cannot read private member". + if (typeof value === 'function') { + return value.bind(target) + } + return value }, getOwnPropertyDescriptor(target, p) { if (keys.includes(p as keyof T)) { From 58ea8173e7ef2e53b4cbef2b4c4e72921e1894f6 Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Fri, 23 Oct 2026 17:02:21 +0200 Subject: [PATCH 5/5] test: cover proxied method calls that access private fields --- .../utilities/isolateObjectProperty.spec.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/payload/src/utilities/isolateObjectProperty.spec.ts b/packages/payload/src/utilities/isolateObjectProperty.spec.ts index 171e4f99d7c..c0af778bdba 100644 --- a/packages/payload/src/utilities/isolateObjectProperty.spec.ts +++ b/packages/payload/src/utilities/isolateObjectProperty.spec.ts @@ -172,5 +172,33 @@ describe('isolateObjectProperty', () => { expect(proxy.headers).toBeInstanceOf(Headers) expect(proxy.headers.get('content-type')).toBe('application/json') }) + + it('should allow calling methods that access private fields', () => { + class ObjectWithPrivateMethod { + #privateValue: string + + constructor(value: string) { + this.#privateValue = value + } + + getValue(): string { + return this.#privateValue + } + } + + const original = new ObjectWithPrivateMethod('secret') + const proxy = isolateObjectProperty(original, 'nonExistentKey' as keyof typeof original) + + // This would throw "Cannot read private member" if the method ran with the proxy as receiver + expect(proxy.getValue()).toBe('secret') + }) + + it('should allow calling Request.text() on a proxied request', async () => { + const request = new Request('https://example.com', { method: 'POST', body: 'hello' }) + const proxy = isolateObjectProperty(request, 'nonExistentKey' as keyof typeof request) + + // Undici reads the private #state field; with the proxy as receiver this throws + await expect(proxy.text()).resolves.toBe('hello') + }) }) })