Skip to content

Commit e2632c8

Browse files
authored
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
1 parent b62a30a commit e2632c8

File tree

10 files changed

+108
-7
lines changed

10 files changed

+108
-7
lines changed

packages/next/src/views/CreateFirstUser/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export async function CreateFirstUserView({ initPageResult }: AdminViewServerPro
7373
renderAllFields: true,
7474
req,
7575
schemaPath: collectionConfig.slug,
76+
skipClientConfigAuth: true,
7677
skipValidation: true,
7778
})
7879

packages/payload/src/admin/forms/Form.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,14 @@ export type BuildFormStateArgs = {
132132
returnLockStatus?: boolean
133133
schemaPath: string
134134
select?: SelectType
135+
/**
136+
* When true, sets `user: true` when calling `getClientConfig`.
137+
* This will retrieve the client config in its entirety, even when unauthenticated.
138+
* For example, the create-first-user view needs the entire config, but there is no user yet.
139+
*
140+
* @experimental This property is experimental and may change in the future. Use at your own discretion.
141+
*/
142+
skipClientConfigAuth?: boolean
135143
skipValidation?: boolean
136144
updateLastEdited?: boolean
137145
} & (

packages/payload/src/config/client.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { I18nClient } from '@payloadcms/translations'
22
import type { DeepPartial } from 'ts-essentials'
33

44
import type { ImportMap } from '../bin/generateImportMap/index.js'
5-
import type { ClientBlock } from '../fields/config/types.js'
5+
import type { ClientBlock, ClientField, Field } from '../fields/config/types.js'
66
import type { BlockSlug, TypedUser } from '../index.js'
77
import type {
88
RootLivePreviewConfig,
@@ -59,7 +59,12 @@ export type UnauthenticatedClientConfig = {
5959
routes: ClientConfig['admin']['routes']
6060
user: ClientConfig['admin']['user']
6161
}
62-
collections: [ClientCollectionConfig]
62+
collections: [
63+
{
64+
auth: ClientCollectionConfig['auth']
65+
slug: string
66+
},
67+
]
6368
globals: []
6469
routes: ClientConfig['routes']
6570
serverURL: ClientConfig['serverURL']
@@ -99,8 +104,9 @@ export type CreateClientConfigArgs = {
99104
* If unauthenticated, the client config will omit some sensitive properties
100105
* such as field schemas, etc. This is useful for login and error pages where
101106
* the page source should not contain this information.
102-
* Allow `true` to generate a client config for the "create first user" page
103-
* where there is no user yet, but the config should be as complete.
107+
*
108+
* For example, allow `true` to generate a client config for the "create first user" page
109+
* where there is no user yet, but the config should still be complete.
104110
*/
105111
user: true | TypedUser
106112
}
@@ -114,12 +120,24 @@ export const createUnauthenticatedClientConfig = ({
114120
*/
115121
clientConfig: ClientConfig
116122
}): UnauthenticatedClientConfig => {
123+
/**
124+
* To share memory, find the admin user collection from the existing client config.
125+
*/
126+
const adminUserCollection = clientConfig.collections.find(
127+
({ slug }) => slug === clientConfig.admin.user,
128+
)!
129+
117130
return {
118131
admin: {
119132
routes: clientConfig.admin.routes,
120133
user: clientConfig.admin.user,
121134
},
122-
collections: [clientConfig.collections.find(({ slug }) => slug === clientConfig.admin.user)!],
135+
collections: [
136+
{
137+
slug: adminUserCollection.slug,
138+
auth: adminUserCollection.auth,
139+
},
140+
],
123141
globals: [],
124142
routes: clientConfig.routes,
125143
serverURL: clientConfig.serverURL,

packages/ui/src/utilities/buildFormState.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export const buildFormState = async (
128128
returnLockStatus,
129129
schemaPath = collectionSlug || globalSlug,
130130
select,
131+
skipClientConfigAuth,
131132
skipValidation,
132133
updateLastEdited,
133134
} = args
@@ -147,7 +148,12 @@ export const buildFormState = async (
147148

148149
const clientSchemaMap = getClientSchemaMap({
149150
collectionSlug,
150-
config: getClientConfig({ config, i18n, importMap: req.payload.importMap, user: req.user }),
151+
config: getClientConfig({
152+
config,
153+
i18n,
154+
importMap: req.payload.importMap,
155+
user: skipClientConfigAuth ? true : req.user,
156+
}),
151157
globalSlug,
152158
i18n,
153159
payload,

test/auth/BeforeDashboard.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use client'
2+
3+
import { useConfig } from '@payloadcms/ui'
4+
5+
export const BeforeDashboard = () => {
6+
const { config } = useConfig()
7+
8+
return (
9+
<p
10+
id="authenticated-client-config"
11+
style={{ opacity: 0, pointerEvents: 'none', position: 'absolute' }}
12+
>
13+
{JSON.stringify(config, null, 2)}
14+
</p>
15+
)
16+
}

test/auth/BeforeLogin.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use client'
2+
3+
import { useConfig } from '@payloadcms/ui'
4+
5+
export const BeforeLogin = () => {
6+
const { config } = useConfig()
7+
8+
return (
9+
<p
10+
id="unauthenticated-client-config"
11+
style={{ opacity: 0, pointerEvents: 'none', position: 'absolute' }}
12+
>
13+
{JSON.stringify(config, null, 2)}
14+
</p>
15+
)
16+
}

test/auth/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export default buildConfigWithDefaults({
2121
password: devUser.password,
2222
prefillOnly: true,
2323
},
24+
components: {
25+
beforeDashboard: ['./BeforeDashboard.js#BeforeDashboard'],
26+
beforeLogin: ['./BeforeLogin.js#BeforeLogin'],
27+
},
2428
importMap: {
2529
baseDir: path.resolve(dirname),
2630
},
@@ -185,6 +189,10 @@ export default buildConfigWithDefaults({
185189
},
186190
label: 'Auth Debug',
187191
},
192+
{
193+
name: 'shouldNotShowInClientConfigUnlessAuthenticated',
194+
type: 'text',
195+
},
188196
],
189197
},
190198
{

test/auth/e2e.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,32 @@ describe('Auth', () => {
182182
await saveDocAndAssert(page, '#action-save')
183183
})
184184

185+
test('should protect the client config behind authentication', async () => {
186+
await logout(page, serverURL)
187+
188+
// This element is absolutely positioned and `opacity: 0`
189+
await expect(page.locator('#unauthenticated-client-config')).toBeAttached()
190+
191+
// Search for our uniquely identifiable field name
192+
await expect(
193+
page.locator('#unauthenticated-client-config', {
194+
hasText: 'shouldNotShowInClientConfigUnlessAuthenticated',
195+
}),
196+
).toHaveCount(0)
197+
198+
await login({ page, serverURL })
199+
200+
await page.goto(serverURL + '/admin')
201+
202+
await expect(page.locator('#authenticated-client-config')).toBeAttached()
203+
204+
await expect(
205+
page.locator('#authenticated-client-config', {
206+
hasText: 'shouldNotShowInClientConfigUnlessAuthenticated',
207+
}),
208+
).toHaveCount(1)
209+
})
210+
185211
test('should allow change password', async () => {
186212
await page.goto(url.account)
187213
const emailBeforeSave = await page.locator('#field-email').inputValue()

test/auth/payload-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ export interface ApiKey {
348348
*/
349349
export interface PublicUser {
350350
id: string;
351+
shouldNotShowInClientConfigUnlessAuthenticated?: string | null;
351352
updatedAt: string;
352353
createdAt: string;
353354
email: string;
@@ -611,6 +612,7 @@ export interface ApiKeysSelect<T extends boolean = true> {
611612
* via the `definition` "public-users_select".
612613
*/
613614
export interface PublicUsersSelect<T extends boolean = true> {
615+
shouldNotShowInClientConfigUnlessAuthenticated?: T;
614616
updatedAt?: T;
615617
createdAt?: T;
616618
email?: T;

test/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
/// <reference types="next/image-types/global" />
33

44
// NOTE: This file should not be edited
5-
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
5+
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

0 commit comments

Comments
 (0)