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:
@@ -73,6 +73,7 @@ export async function CreateFirstUserView({ initPageResult }: AdminViewServerPro
|
|||||||
renderAllFields: true,
|
renderAllFields: true,
|
||||||
req,
|
req,
|
||||||
schemaPath: collectionConfig.slug,
|
schemaPath: collectionConfig.slug,
|
||||||
|
skipClientConfigAuth: true,
|
||||||
skipValidation: true,
|
skipValidation: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,14 @@ export type BuildFormStateArgs = {
|
|||||||
returnLockStatus?: boolean
|
returnLockStatus?: boolean
|
||||||
schemaPath: string
|
schemaPath: string
|
||||||
select?: SelectType
|
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
|
skipValidation?: boolean
|
||||||
updateLastEdited?: boolean
|
updateLastEdited?: boolean
|
||||||
} & (
|
} & (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { I18nClient } from '@payloadcms/translations'
|
|||||||
import type { DeepPartial } from 'ts-essentials'
|
import type { DeepPartial } from 'ts-essentials'
|
||||||
|
|
||||||
import type { ImportMap } from '../bin/generateImportMap/index.js'
|
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 { BlockSlug, TypedUser } from '../index.js'
|
||||||
import type {
|
import type {
|
||||||
RootLivePreviewConfig,
|
RootLivePreviewConfig,
|
||||||
@@ -59,7 +59,12 @@ export type UnauthenticatedClientConfig = {
|
|||||||
routes: ClientConfig['admin']['routes']
|
routes: ClientConfig['admin']['routes']
|
||||||
user: ClientConfig['admin']['user']
|
user: ClientConfig['admin']['user']
|
||||||
}
|
}
|
||||||
collections: [ClientCollectionConfig]
|
collections: [
|
||||||
|
{
|
||||||
|
auth: ClientCollectionConfig['auth']
|
||||||
|
slug: string
|
||||||
|
},
|
||||||
|
]
|
||||||
globals: []
|
globals: []
|
||||||
routes: ClientConfig['routes']
|
routes: ClientConfig['routes']
|
||||||
serverURL: ClientConfig['serverURL']
|
serverURL: ClientConfig['serverURL']
|
||||||
@@ -99,8 +104,9 @@ export type CreateClientConfigArgs = {
|
|||||||
* If unauthenticated, the client config will omit some sensitive properties
|
* If unauthenticated, the client config will omit some sensitive properties
|
||||||
* such as field schemas, etc. This is useful for login and error pages where
|
* such as field schemas, etc. This is useful for login and error pages where
|
||||||
* the page source should not contain this information.
|
* 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
|
user: true | TypedUser
|
||||||
}
|
}
|
||||||
@@ -114,12 +120,24 @@ export const createUnauthenticatedClientConfig = ({
|
|||||||
*/
|
*/
|
||||||
clientConfig: ClientConfig
|
clientConfig: ClientConfig
|
||||||
}): UnauthenticatedClientConfig => {
|
}): 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 {
|
return {
|
||||||
admin: {
|
admin: {
|
||||||
routes: clientConfig.admin.routes,
|
routes: clientConfig.admin.routes,
|
||||||
user: clientConfig.admin.user,
|
user: clientConfig.admin.user,
|
||||||
},
|
},
|
||||||
collections: [clientConfig.collections.find(({ slug }) => slug === clientConfig.admin.user)!],
|
collections: [
|
||||||
|
{
|
||||||
|
slug: adminUserCollection.slug,
|
||||||
|
auth: adminUserCollection.auth,
|
||||||
|
},
|
||||||
|
],
|
||||||
globals: [],
|
globals: [],
|
||||||
routes: clientConfig.routes,
|
routes: clientConfig.routes,
|
||||||
serverURL: clientConfig.serverURL,
|
serverURL: clientConfig.serverURL,
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export const buildFormState = async (
|
|||||||
returnLockStatus,
|
returnLockStatus,
|
||||||
schemaPath = collectionSlug || globalSlug,
|
schemaPath = collectionSlug || globalSlug,
|
||||||
select,
|
select,
|
||||||
|
skipClientConfigAuth,
|
||||||
skipValidation,
|
skipValidation,
|
||||||
updateLastEdited,
|
updateLastEdited,
|
||||||
} = args
|
} = args
|
||||||
@@ -147,7 +148,12 @@ export const buildFormState = async (
|
|||||||
|
|
||||||
const clientSchemaMap = getClientSchemaMap({
|
const clientSchemaMap = getClientSchemaMap({
|
||||||
collectionSlug,
|
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,
|
globalSlug,
|
||||||
i18n,
|
i18n,
|
||||||
payload,
|
payload,
|
||||||
|
|||||||
16
test/auth/BeforeDashboard.tsx
Normal file
16
test/auth/BeforeDashboard.tsx
Normal 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
16
test/auth/BeforeLogin.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ export default buildConfigWithDefaults({
|
|||||||
password: devUser.password,
|
password: devUser.password,
|
||||||
prefillOnly: true,
|
prefillOnly: true,
|
||||||
},
|
},
|
||||||
|
components: {
|
||||||
|
beforeDashboard: ['./BeforeDashboard.js#BeforeDashboard'],
|
||||||
|
beforeLogin: ['./BeforeLogin.js#BeforeLogin'],
|
||||||
|
},
|
||||||
importMap: {
|
importMap: {
|
||||||
baseDir: path.resolve(dirname),
|
baseDir: path.resolve(dirname),
|
||||||
},
|
},
|
||||||
@@ -185,6 +189,10 @@ export default buildConfigWithDefaults({
|
|||||||
},
|
},
|
||||||
label: 'Auth Debug',
|
label: 'Auth Debug',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'shouldNotShowInClientConfigUnlessAuthenticated',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -182,6 +182,32 @@ describe('Auth', () => {
|
|||||||
await saveDocAndAssert(page, '#action-save')
|
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 () => {
|
test('should allow change password', async () => {
|
||||||
await page.goto(url.account)
|
await page.goto(url.account)
|
||||||
const emailBeforeSave = await page.locator('#field-email').inputValue()
|
const emailBeforeSave = await page.locator('#field-email').inputValue()
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ export interface ApiKey {
|
|||||||
*/
|
*/
|
||||||
export interface PublicUser {
|
export interface PublicUser {
|
||||||
id: string;
|
id: string;
|
||||||
|
shouldNotShowInClientConfigUnlessAuthenticated?: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -611,6 +612,7 @@ export interface ApiKeysSelect<T extends boolean = true> {
|
|||||||
* via the `definition` "public-users_select".
|
* via the `definition` "public-users_select".
|
||||||
*/
|
*/
|
||||||
export interface PublicUsersSelect<T extends boolean = true> {
|
export interface PublicUsersSelect<T extends boolean = true> {
|
||||||
|
shouldNotShowInClientConfigUnlessAuthenticated?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
email?: T;
|
email?: T;
|
||||||
|
|||||||
2
test/next-env.d.ts
vendored
2
test/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
|||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user