From 03d854ed18c6b48f1eadf4e0f95a717b1c4eac23 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 12 Jul 2024 16:27:10 -0400 Subject: [PATCH] feat: validate field names for reserved names (#7130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We now validate the names of the field against an array of protected field names. Also added JSDoc since we can't enforce type strictness yet if `string | const[]` as it always evaluates to `string`. ``` The name of the field. Must be alphanumeric and cannot contain ' . ' Must not be one of protected field names: ['__v', 'salt', 'hash', 'file'] @link — [https://payloadcms.com/docs/fields/overview#field-names](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-sandbox/workbench/workbench.html) ``` --- docs/fields/overview.mdx | 1 + .../src/collections/config/sanitize.ts | 24 +++++++++---------- .../payload/src/errors/ReservedFieldName.ts | 9 +++++++ packages/payload/src/errors/index.ts | 1 + .../src/fields/config/sanitize.spec.ts | 24 ++++++++++++++++++- .../payload/src/fields/config/sanitize.ts | 8 +++++++ packages/payload/src/fields/config/types.ts | 6 +++++ .../payload/src/globals/config/sanitize.ts | 17 ++++++------- 8 files changed, 69 insertions(+), 21 deletions(-) create mode 100644 packages/payload/src/errors/ReservedFieldName.ts diff --git a/docs/fields/overview.mdx b/docs/fields/overview.mdx index 5cb6d6875..bed67d4dc 100644 --- a/docs/fields/overview.mdx +++ b/docs/fields/overview.mdx @@ -143,6 +143,7 @@ The following field names are forbidden and cannot be used: - `__v` - `salt` - `hash` + - `file` ### Field-level Hooks diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index bad656961..01a97797e 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -36,6 +36,18 @@ export const sanitizeCollection = async ( isMergeableObject: isPlainObject, }) + // ///////////////////////////////// + // Sanitize fields + // ///////////////////////////////// + + const validRelationships = config.collections.map((c) => c.slug) || [] + sanitized.fields = await sanitizeFields({ + config, + fields: sanitized.fields, + richTextSanitizationPromises, + validRelationships, + }) + if (sanitized.timestamps !== false) { // add default timestamps fields only as needed let hasUpdatedAt = null @@ -162,17 +174,5 @@ export const sanitizeCollection = async ( sanitized.fields = mergeBaseFields(sanitized.fields, authFields) } - // ///////////////////////////////// - // Sanitize fields - // ///////////////////////////////// - - const validRelationships = config.collections.map((c) => c.slug) || [] - sanitized.fields = await sanitizeFields({ - config, - fields: sanitized.fields, - richTextSanitizationPromises, - validRelationships, - }) - return sanitized as SanitizedCollectionConfig } diff --git a/packages/payload/src/errors/ReservedFieldName.ts b/packages/payload/src/errors/ReservedFieldName.ts new file mode 100644 index 000000000..d9317fa96 --- /dev/null +++ b/packages/payload/src/errors/ReservedFieldName.ts @@ -0,0 +1,9 @@ +import type { FieldAffectingData } from '../fields/config/types.js' + +import { APIError } from './APIError.js' + +export class ReservedFieldName extends APIError { + constructor(field: FieldAffectingData, fieldName: string) { + super(`Field ${field.label} has reserved name '${fieldName}'.`) + } +} diff --git a/packages/payload/src/errors/index.ts b/packages/payload/src/errors/index.ts index 6548568e2..6025b3db0 100644 --- a/packages/payload/src/errors/index.ts +++ b/packages/payload/src/errors/index.ts @@ -18,4 +18,5 @@ export { MissingFieldType } from './MissingFieldType.js' export { MissingFile } from './MissingFile.js' export { NotFound } from './NotFound.js' export { QueryError } from './QueryError.js' +export { ReservedFieldName } from './ReservedFieldName.js' export { ValidationError } from './ValidationError.js' diff --git a/packages/payload/src/fields/config/sanitize.spec.ts b/packages/payload/src/fields/config/sanitize.spec.ts index 623aabbe8..75ec4cce4 100644 --- a/packages/payload/src/fields/config/sanitize.spec.ts +++ b/packages/payload/src/fields/config/sanitize.spec.ts @@ -9,7 +9,12 @@ import type { TextField, } from './types.js' -import { InvalidFieldName, InvalidFieldRelationship, MissingFieldType } from '../../errors/index.js' +import { + InvalidFieldName, + InvalidFieldRelationship, + MissingFieldType, + ReservedFieldName, +} from '../../errors/index.js' import { sanitizeFields } from './sanitize.js' describe('sanitizeFields', () => { @@ -47,6 +52,23 @@ describe('sanitizeFields', () => { }).rejects.toThrow(InvalidFieldName) }) + it('should throw on a reserved field name', async () => { + const fields: Field[] = [ + { + name: 'hash', + type: 'text', + label: 'hash', + }, + ] + await expect(async () => { + await sanitizeFields({ + config, + fields, + validRelationships: [], + }) + }).rejects.toThrow(ReservedFieldName) + }) + describe('auto-labeling', () => { it('should populate label if missing', async () => { const fields: Field[] = [ diff --git a/packages/payload/src/fields/config/sanitize.ts b/packages/payload/src/fields/config/sanitize.ts index 3276bb9f2..03a2e0662 100644 --- a/packages/payload/src/fields/config/sanitize.ts +++ b/packages/payload/src/fields/config/sanitize.ts @@ -7,6 +7,7 @@ import { InvalidFieldName, InvalidFieldRelationship, MissingFieldType, + ReservedFieldName, } from '../../errors/index.js' import { deepMerge } from '../../utilities/deepMerge.js' import { formatLabels, toWords } from '../../utilities/formatLabels.js' @@ -39,6 +40,8 @@ type Args = { validRelationships: null | string[] } +export const reservedFieldNames = ['__v', 'salt', 'hash', 'file'] + export const sanitizeFields = async ({ config, existingFieldNames = new Set(), @@ -59,6 +62,11 @@ export const sanitizeFields = async ({ throw new InvalidFieldName(field, field.name) } + // assert that field names are not one of reserved names + if (fieldAffectsData(field) && reservedFieldNames.includes(field.name)) { + throw new ReservedFieldName(field, field.name) + } + // Auto-label if ( 'name' in field && diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 6883982c6..582172566 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -229,6 +229,12 @@ export interface FieldBase { index?: boolean label?: LabelFunction | Record | false | string localized?: boolean + /** + * The name of the field. Must be alphanumeric and cannot contain ' . ' + * + * Must not be one of reserved field names: ['__v', 'salt', 'hash', 'file'] + * @link https://payloadcms.com/docs/fields/overview#field-names + */ name: string required?: boolean saveToJWT?: boolean | string diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index 0f6e35f0b..0140a2adf 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -41,6 +41,15 @@ export const sanitizeGlobals = async ( if (!global.hooks.beforeRead) global.hooks.beforeRead = [] if (!global.hooks.afterRead) global.hooks.afterRead = [] + // Sanitize fields + const validRelationships = collections.map((c) => c.slug) || [] + global.fields = await sanitizeFields({ + config, + fields: global.fields, + richTextSanitizationPromises, + validRelationships, + }) + if (global.versions) { if (global.versions === true) global.versions = { drafts: false } @@ -103,14 +112,6 @@ export const sanitizeGlobals = async ( }) } - const validRelationships = collections.map((c) => c.slug) || [] - global.fields = await sanitizeFields({ - config, - fields: global.fields, - richTextSanitizationPromises, - validRelationships, - }) - globals[i] = global }