Compare commits
2 Commits
feat/sorta
...
feat/enfor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76d1f00765 | ||
|
|
0b05eb8f38 |
@@ -64,7 +64,7 @@ export default buildConfig({
|
||||
The following options are available:
|
||||
|
||||
| Option | Description |
|
||||
|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`admin`** | The configuration options for the Admin Panel, including Custom Components, Live Preview, etc. [More details](../admin/overview#admin-options). |
|
||||
| **`bin`** | Register custom bin scripts for Payload to execute. |
|
||||
| **`editor`** | The Rich Text Editor which will be used by `richText` fields. [More details](../rich-text/overview). |
|
||||
@@ -83,6 +83,7 @@ The following options are available:
|
||||
| **`defaultDepth`** | If a user does not specify `depth` while requesting a resource, this depth will be used. [More details](../queries/depth). |
|
||||
| **`defaultMaxTextLength`** | The maximum allowed string length to be permitted application-wide. Helps to prevent malicious public document creation. |
|
||||
| **`maxDepth`** | The maximum allowed depth to be permitted application-wide. This setting helps prevent against malicious queries. Defaults to `10`. [More details](../queries/depth). |
|
||||
| **`maxCallDepth`** | The maximum allowed call depth for Local API operations. This setting helps prevent against hooks that lead to infinity loops. Can be disabled with passing `false`. Defaults to `30`. |
|
||||
| **`indexSortableFields`** | Automatically index all sortable top-level fields in the database to improve sort performance and add database compatibility for Azure Cosmos and similar. |
|
||||
| **`upload`** | Base Payload upload configuration. [More details](../upload/overview#payload-wide-upload-options). |
|
||||
| **`routes`** | Control the routing structure that Payload binds itself to. [More details](../admin/overview#root-level-routes). |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable no-restricted-exports */
|
||||
import * as auth from '../../../auth/operations/local/index.js'
|
||||
import { enforceCallDepth } from '../../../utilities/enforceCallDepth.js'
|
||||
import count from './count.js'
|
||||
import countVersions from './countVersions.js'
|
||||
import create from './create.js'
|
||||
@@ -12,7 +13,7 @@ import findVersions from './findVersions.js'
|
||||
import restoreVersion from './restoreVersion.js'
|
||||
import update from './update.js'
|
||||
|
||||
export default {
|
||||
const local = {
|
||||
auth,
|
||||
count,
|
||||
countVersions,
|
||||
@@ -26,3 +27,11 @@ export default {
|
||||
restoreVersion,
|
||||
update,
|
||||
}
|
||||
|
||||
for (const operation in local) {
|
||||
if (operation !== 'auth') {
|
||||
local[operation] = enforceCallDepth(local[operation])
|
||||
}
|
||||
}
|
||||
|
||||
export default local
|
||||
|
||||
@@ -55,6 +55,7 @@ export const defaults: Omit<Config, 'db' | 'editor' | 'secret'> = {
|
||||
depth: 0,
|
||||
} as JobsConfig,
|
||||
localization: false,
|
||||
maxCallDepth: 30,
|
||||
maxDepth: 10,
|
||||
routes: {
|
||||
admin: '/admin',
|
||||
|
||||
@@ -996,6 +996,7 @@ export type Config = {
|
||||
`* ErrorDeletingFile: 'error',
|
||||
`* FileRetrievalError: 'error',
|
||||
`* FileUploadError: 'error',
|
||||
`* ReachedMaxCallDepth: 'error',
|
||||
`* Forbidden: 'info',
|
||||
`* Locked: 'info',
|
||||
`* LockedAuth: 'error',
|
||||
@@ -1007,6 +1008,14 @@ export type Config = {
|
||||
*/
|
||||
loggingLevels?: Partial<Record<ErrorName, false | Level>>
|
||||
|
||||
/**
|
||||
* The maximum allowed call depth for Local API operations. This setting helps prevent against hooks that lead to infinity loops.
|
||||
* Pass `false` to disable it.
|
||||
*
|
||||
* @default 30
|
||||
*/
|
||||
maxCallDepth?: false | number
|
||||
|
||||
/**
|
||||
* The maximum allowed depth to be permitted application-wide. This setting helps prevent against malicious queries.
|
||||
*
|
||||
|
||||
9
packages/payload/src/errors/ReachedMaxCallDepth.ts
Normal file
9
packages/payload/src/errors/ReachedMaxCallDepth.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { APIError } from './APIError.js'
|
||||
|
||||
export class ReachedMaxCallDepth extends APIError {
|
||||
constructor(maxCallDepth: number) {
|
||||
super(
|
||||
`Max call depth (${maxCallDepth}) for Local API operations is reached. This can be caused by hooks that lead to infinity loops. Verify if there are any and use the 'context' property to avoid this error.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export { MissingFieldType } from './MissingFieldType.js'
|
||||
export { MissingFile } from './MissingFile.js'
|
||||
export { NotFound } from './NotFound.js'
|
||||
export { QueryError } from './QueryError.js'
|
||||
export { ReachedMaxCallDepth } from './ReachedMaxCallDepth.js'
|
||||
export { ReservedFieldName } from './ReservedFieldName.js'
|
||||
export { ValidationError, ValidationErrorName } from './ValidationError.js'
|
||||
export type { ValidationFieldError } from './ValidationError.js'
|
||||
|
||||
@@ -15,4 +15,5 @@ export type ErrorName =
|
||||
| 'MissingFile'
|
||||
| 'NotFound'
|
||||
| 'QueryError'
|
||||
| 'ReachedMaxCallDepth'
|
||||
| 'ValidationError'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { enforceCallDepth } from '../../../utilities/enforceCallDepth.js'
|
||||
import countGlobalVersions from './countGlobalVersions.js'
|
||||
import findOne from './findOne.js'
|
||||
import findVersionByID from './findVersionByID.js'
|
||||
@@ -5,7 +6,7 @@ import findVersions from './findVersions.js'
|
||||
import restoreVersion from './restoreVersion.js'
|
||||
import update from './update.js'
|
||||
|
||||
export default {
|
||||
const local = {
|
||||
countGlobalVersions,
|
||||
findOne,
|
||||
findVersionByID,
|
||||
@@ -13,3 +14,9 @@ export default {
|
||||
restoreVersion,
|
||||
update,
|
||||
}
|
||||
|
||||
for (const operation in local) {
|
||||
local[operation] = enforceCallDepth(local[operation])
|
||||
}
|
||||
|
||||
export default local
|
||||
|
||||
@@ -1086,6 +1086,7 @@ export {
|
||||
MissingFile,
|
||||
NotFound,
|
||||
QueryError,
|
||||
ReachedMaxCallDepth,
|
||||
ValidationError,
|
||||
ValidationErrorName,
|
||||
} from './errors/index.js'
|
||||
|
||||
37
packages/payload/src/utilities/enforceCallDepth.ts
Normal file
37
packages/payload/src/utilities/enforceCallDepth.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { AsyncLocalStorage } from 'async_hooks'
|
||||
|
||||
import type { Payload } from '../types/index.js'
|
||||
|
||||
import { ReachedMaxCallDepth } from '../errors/index.js'
|
||||
|
||||
const callDepthAls = new AsyncLocalStorage<{ currentDepth: number }>()
|
||||
|
||||
export const enforceCallDepth = <
|
||||
T extends (payload: Payload, options: unknown) => Promise<unknown>,
|
||||
>(
|
||||
operation: T,
|
||||
): T => {
|
||||
const withEnforcedCallDepth = async (payload: Payload, options: unknown) => {
|
||||
const {
|
||||
config: { maxCallDepth },
|
||||
} = payload
|
||||
|
||||
if (maxCallDepth === false) {
|
||||
return operation(payload, options)
|
||||
}
|
||||
|
||||
const store = callDepthAls.getStore()
|
||||
|
||||
return callDepthAls.run({ currentDepth: store?.currentDepth ?? 0 }, async () => {
|
||||
const store = callDepthAls.getStore()
|
||||
store.currentDepth++
|
||||
if (store.currentDepth > maxCallDepth) {
|
||||
throw new ReachedMaxCallDepth(maxCallDepth)
|
||||
}
|
||||
|
||||
return operation(payload, options)
|
||||
})
|
||||
}
|
||||
|
||||
return withEnforcedCallDepth as T
|
||||
}
|
||||
27
test/hooks/collections/InfinityLoop/index.ts
Normal file
27
test/hooks/collections/InfinityLoop/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const InfinityLoop: CollectionConfig = {
|
||||
slug: 'infinity-loop',
|
||||
fields: [],
|
||||
hooks: {
|
||||
afterRead: [
|
||||
async ({ req, context, doc }) => {
|
||||
if (typeof context.callDepth === 'number') {
|
||||
if (context.callDepth === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// fetch self
|
||||
await req.payload.findByID({
|
||||
req,
|
||||
id: doc.id,
|
||||
collection: 'infinity-loop',
|
||||
context: {
|
||||
callDepth: context.callDepth - 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import ChainingHooks from './collections/ChainingHooks/index.js'
|
||||
import ContextHooks from './collections/ContextHooks/index.js'
|
||||
import { DataHooks } from './collections/Data/index.js'
|
||||
import Hooks, { hooksSlug } from './collections/Hook/index.js'
|
||||
import { InfinityLoop } from './collections/InfinityLoop/index.js'
|
||||
import NestedAfterReadHooks from './collections/NestedAfterReadHooks/index.js'
|
||||
import Relations from './collections/Relations/index.js'
|
||||
import TransformHooks from './collections/Transform/index.js'
|
||||
@@ -33,6 +34,7 @@ export const HooksConfig: Promise<SanitizedConfig> = buildConfigWithDefaults({
|
||||
Relations,
|
||||
Users,
|
||||
DataHooks,
|
||||
InfinityLoop,
|
||||
],
|
||||
globals: [DataHooksGlobal],
|
||||
endpoints: [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import path from 'path'
|
||||
import { AuthenticationError } from 'payload'
|
||||
import { AuthenticationError, ReachedMaxCallDepth } from 'payload'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
|
||||
@@ -328,6 +328,28 @@ describe('Hooks', () => {
|
||||
|
||||
expect(retrievedDoc.value).toEqual('data from rest API')
|
||||
})
|
||||
|
||||
it('should throw ReachedMaxCallDepth if reached call depth more than config.maxCallDepth', async () => {
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: 'infinity-loop',
|
||||
data: {},
|
||||
context: {
|
||||
callDepth: payload.config.maxCallDepth,
|
||||
},
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ReachedMaxCallDepth)
|
||||
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: 'infinity-loop',
|
||||
data: {},
|
||||
context: {
|
||||
callDepth: payload.config.maxCallDepth - 1,
|
||||
},
|
||||
}),
|
||||
).resolves.toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth collection hooks', () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Config {
|
||||
relations: Relation;
|
||||
'hooks-users': HooksUser;
|
||||
'data-hooks': DataHook;
|
||||
'infinity-loop': InfinityLoop;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
@@ -35,6 +36,7 @@ export interface Config {
|
||||
relations: RelationsSelect<false> | RelationsSelect<true>;
|
||||
'hooks-users': HooksUsersSelect<false> | HooksUsersSelect<true>;
|
||||
'data-hooks': DataHooksSelect<false> | DataHooksSelect<true>;
|
||||
'infinity-loop': InfinityLoopSelect<false> | InfinityLoopSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
@@ -211,6 +213,15 @@ export interface DataHook {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "infinity-loop".
|
||||
*/
|
||||
export interface InfinityLoop {
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents".
|
||||
@@ -253,6 +264,10 @@ export interface PayloadLockedDocument {
|
||||
| ({
|
||||
relationTo: 'data-hooks';
|
||||
value: string | DataHook;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'infinity-loop';
|
||||
value: string | InfinityLoop;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
@@ -418,6 +433,14 @@ export interface DataHooksSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "infinity-loop_select".
|
||||
*/
|
||||
export interface InfinityLoopSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents_select".
|
||||
|
||||
Reference in New Issue
Block a user