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
This commit is contained in:
Jacob Fletcher
2025-09-12 14:52:50 -04:00
committed by GitHub
parent b62a30a8dc
commit e2632c86d0
10 changed files with 108 additions and 7 deletions

View File

@@ -73,6 +73,7 @@ export async function CreateFirstUserView({ initPageResult }: AdminViewServerPro
renderAllFields: true,
req,
schemaPath: collectionConfig.slug,
skipClientConfigAuth: true,
skipValidation: true,
})

View File

@@ -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
} & (

View File

@@ -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,

View File

@@ -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,

View File

@@ -0,0 +1,16 @@
'use client'
import { useConfig } from '@payloadcms/ui'
export const BeforeDashboard = () => {
const { config } = useConfig()
return (
<p
id="authenticated-client-config"
style={{ opacity: 0, pointerEvents: 'none', position: 'absolute' }}
>
{JSON.stringify(config, null, 2)}
</p>
)
}

16
test/auth/BeforeLogin.tsx Normal file
View File

@@ -0,0 +1,16 @@
'use client'
import { useConfig } from '@payloadcms/ui'
export const BeforeLogin = () => {
const { config } = useConfig()
return (
<p
id="unauthenticated-client-config"
style={{ opacity: 0, pointerEvents: 'none', position: 'absolute' }}
>
{JSON.stringify(config, null, 2)}
</p>
)
}

View File

@@ -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',
},
],
},
{

View File

@@ -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()

View File

@@ -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<T extends boolean = true> {
* via the `definition` "public-users_select".
*/
export interface PublicUsersSelect<T extends boolean = true> {
shouldNotShowInClientConfigUnlessAuthenticated?: T;
updatedAt?: T;
createdAt?: T;
email?: T;

2
test/next-env.d.ts vendored
View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// 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.