From e2632c86d03fb040e7b299a38172703f3167e9c3 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 12 Sep 2025 14:52:50 -0400 Subject: [PATCH] fix: fully sanitize unauthenticated client config (#13785) Follow-up to #13714. Fully sanitizes the unauthenticated client config to exclude much of the users collection, including fields, etc. These are not required of the login flow and are now completely omitted along with other unnecessary properties. This is closely aligned with the goals of the original PR, and as an added bonus, makes the config _even smaller_ than it already was for unauthenticated users. Needs #13790. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1211332845301588 --- .../next/src/views/CreateFirstUser/index.tsx | 1 + packages/payload/src/admin/forms/Form.ts | 8 ++++++ packages/payload/src/config/client.ts | 28 +++++++++++++++---- packages/ui/src/utilities/buildFormState.ts | 8 +++++- test/auth/BeforeDashboard.tsx | 16 +++++++++++ test/auth/BeforeLogin.tsx | 16 +++++++++++ test/auth/config.ts | 8 ++++++ test/auth/e2e.spec.ts | 26 +++++++++++++++++ test/auth/payload-types.ts | 2 ++ test/next-env.d.ts | 2 +- 10 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 test/auth/BeforeDashboard.tsx create mode 100644 test/auth/BeforeLogin.tsx diff --git a/packages/next/src/views/CreateFirstUser/index.tsx b/packages/next/src/views/CreateFirstUser/index.tsx index dd83082c9..604c03add 100644 --- a/packages/next/src/views/CreateFirstUser/index.tsx +++ b/packages/next/src/views/CreateFirstUser/index.tsx @@ -73,6 +73,7 @@ export async function CreateFirstUserView({ initPageResult }: AdminViewServerPro renderAllFields: true, req, schemaPath: collectionConfig.slug, + skipClientConfigAuth: true, skipValidation: true, }) diff --git a/packages/payload/src/admin/forms/Form.ts b/packages/payload/src/admin/forms/Form.ts index 035c062cf..061545128 100644 --- a/packages/payload/src/admin/forms/Form.ts +++ b/packages/payload/src/admin/forms/Form.ts @@ -132,6 +132,14 @@ export type BuildFormStateArgs = { returnLockStatus?: boolean schemaPath: string select?: SelectType + /** + * When true, sets `user: true` when calling `getClientConfig`. + * This will retrieve the client config in its entirety, even when unauthenticated. + * For example, the create-first-user view needs the entire config, but there is no user yet. + * + * @experimental This property is experimental and may change in the future. Use at your own discretion. + */ + skipClientConfigAuth?: boolean skipValidation?: boolean updateLastEdited?: boolean } & ( diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts index 324ed63c7..dc267f46d 100644 --- a/packages/payload/src/config/client.ts +++ b/packages/payload/src/config/client.ts @@ -2,7 +2,7 @@ import type { I18nClient } from '@payloadcms/translations' import type { DeepPartial } from 'ts-essentials' import type { ImportMap } from '../bin/generateImportMap/index.js' -import type { ClientBlock } from '../fields/config/types.js' +import type { ClientBlock, ClientField, Field } from '../fields/config/types.js' import type { BlockSlug, TypedUser } from '../index.js' import type { RootLivePreviewConfig, @@ -59,7 +59,12 @@ export type UnauthenticatedClientConfig = { routes: ClientConfig['admin']['routes'] user: ClientConfig['admin']['user'] } - collections: [ClientCollectionConfig] + collections: [ + { + auth: ClientCollectionConfig['auth'] + slug: string + }, + ] globals: [] routes: ClientConfig['routes'] serverURL: ClientConfig['serverURL'] @@ -99,8 +104,9 @@ export type CreateClientConfigArgs = { * If unauthenticated, the client config will omit some sensitive properties * such as field schemas, etc. This is useful for login and error pages where * the page source should not contain this information. - * Allow `true` to generate a client config for the "create first user" page - * where there is no user yet, but the config should be as complete. + * + * For example, allow `true` to generate a client config for the "create first user" page + * where there is no user yet, but the config should still be complete. */ user: true | TypedUser } @@ -114,12 +120,24 @@ export const createUnauthenticatedClientConfig = ({ */ clientConfig: ClientConfig }): UnauthenticatedClientConfig => { + /** + * To share memory, find the admin user collection from the existing client config. + */ + const adminUserCollection = clientConfig.collections.find( + ({ slug }) => slug === clientConfig.admin.user, + )! + return { admin: { routes: clientConfig.admin.routes, user: clientConfig.admin.user, }, - collections: [clientConfig.collections.find(({ slug }) => slug === clientConfig.admin.user)!], + collections: [ + { + slug: adminUserCollection.slug, + auth: adminUserCollection.auth, + }, + ], globals: [], routes: clientConfig.routes, serverURL: clientConfig.serverURL, diff --git a/packages/ui/src/utilities/buildFormState.ts b/packages/ui/src/utilities/buildFormState.ts index 6785b0225..0698f4dc4 100644 --- a/packages/ui/src/utilities/buildFormState.ts +++ b/packages/ui/src/utilities/buildFormState.ts @@ -128,6 +128,7 @@ export const buildFormState = async ( returnLockStatus, schemaPath = collectionSlug || globalSlug, select, + skipClientConfigAuth, skipValidation, updateLastEdited, } = args @@ -147,7 +148,12 @@ export const buildFormState = async ( const clientSchemaMap = getClientSchemaMap({ collectionSlug, - config: getClientConfig({ config, i18n, importMap: req.payload.importMap, user: req.user }), + config: getClientConfig({ + config, + i18n, + importMap: req.payload.importMap, + user: skipClientConfigAuth ? true : req.user, + }), globalSlug, i18n, payload, diff --git a/test/auth/BeforeDashboard.tsx b/test/auth/BeforeDashboard.tsx new file mode 100644 index 000000000..1250ba6b3 --- /dev/null +++ b/test/auth/BeforeDashboard.tsx @@ -0,0 +1,16 @@ +'use client' + +import { useConfig } from '@payloadcms/ui' + +export const BeforeDashboard = () => { + const { config } = useConfig() + + return ( +

+ {JSON.stringify(config, null, 2)} +

+ ) +} diff --git a/test/auth/BeforeLogin.tsx b/test/auth/BeforeLogin.tsx new file mode 100644 index 000000000..efdbc4824 --- /dev/null +++ b/test/auth/BeforeLogin.tsx @@ -0,0 +1,16 @@ +'use client' + +import { useConfig } from '@payloadcms/ui' + +export const BeforeLogin = () => { + const { config } = useConfig() + + return ( +

+ {JSON.stringify(config, null, 2)} +

+ ) +} diff --git a/test/auth/config.ts b/test/auth/config.ts index 870e71888..831f25f10 100644 --- a/test/auth/config.ts +++ b/test/auth/config.ts @@ -21,6 +21,10 @@ export default buildConfigWithDefaults({ password: devUser.password, prefillOnly: true, }, + components: { + beforeDashboard: ['./BeforeDashboard.js#BeforeDashboard'], + beforeLogin: ['./BeforeLogin.js#BeforeLogin'], + }, importMap: { baseDir: path.resolve(dirname), }, @@ -185,6 +189,10 @@ export default buildConfigWithDefaults({ }, label: 'Auth Debug', }, + { + name: 'shouldNotShowInClientConfigUnlessAuthenticated', + type: 'text', + }, ], }, { diff --git a/test/auth/e2e.spec.ts b/test/auth/e2e.spec.ts index 598f253e9..d44b10795 100644 --- a/test/auth/e2e.spec.ts +++ b/test/auth/e2e.spec.ts @@ -182,6 +182,32 @@ describe('Auth', () => { await saveDocAndAssert(page, '#action-save') }) + test('should protect the client config behind authentication', async () => { + await logout(page, serverURL) + + // This element is absolutely positioned and `opacity: 0` + await expect(page.locator('#unauthenticated-client-config')).toBeAttached() + + // Search for our uniquely identifiable field name + await expect( + page.locator('#unauthenticated-client-config', { + hasText: 'shouldNotShowInClientConfigUnlessAuthenticated', + }), + ).toHaveCount(0) + + await login({ page, serverURL }) + + await page.goto(serverURL + '/admin') + + await expect(page.locator('#authenticated-client-config')).toBeAttached() + + await expect( + page.locator('#authenticated-client-config', { + hasText: 'shouldNotShowInClientConfigUnlessAuthenticated', + }), + ).toHaveCount(1) + }) + test('should allow change password', async () => { await page.goto(url.account) const emailBeforeSave = await page.locator('#field-email').inputValue() diff --git a/test/auth/payload-types.ts b/test/auth/payload-types.ts index 97937fca4..a060945fa 100644 --- a/test/auth/payload-types.ts +++ b/test/auth/payload-types.ts @@ -348,6 +348,7 @@ export interface ApiKey { */ export interface PublicUser { id: string; + shouldNotShowInClientConfigUnlessAuthenticated?: string | null; updatedAt: string; createdAt: string; email: string; @@ -611,6 +612,7 @@ export interface ApiKeysSelect { * via the `definition` "public-users_select". */ export interface PublicUsersSelect { + shouldNotShowInClientConfigUnlessAuthenticated?: T; updatedAt?: T; createdAt?: T; email?: T; diff --git a/test/next-env.d.ts b/test/next-env.d.ts index 40c3d6809..1b3be0840 100644 --- a/test/next-env.d.ts +++ b/test/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.