Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enforce maximum call depth for local api operations #9315

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/configuration/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand All @@ -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). |
Expand Down
11 changes: 10 additions & 1 deletion packages/payload/src/collections/operations/local/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand All @@ -26,3 +27,11 @@ export default {
restoreVersion,
update,
}

for (const operation in local) {
if (operation !== 'auth') {
local[operation] = enforceCallDepth(local[operation])
}
}

export default local
1 change: 1 addition & 0 deletions packages/payload/src/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 9 additions & 0 deletions packages/payload/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,7 @@ export type Config = {
`* ErrorDeletingFile: 'error',
`* FileRetrievalError: 'error',
`* FileUploadError: 'error',
`* ReachedMaxCallDepth: 'error',
`* Forbidden: 'info',
`* Locked: 'info',
`* LockedAuth: 'error',
Expand All @@ -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.
*
Expand Down
9 changes: 9 additions & 0 deletions packages/payload/src/errors/ReachedMaxCallDepth.ts
Original file line number Diff line number Diff line change
@@ -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.`,
)
}
}
1 change: 1 addition & 0 deletions packages/payload/src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
1 change: 1 addition & 0 deletions packages/payload/src/errors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export type ErrorName =
| 'MissingFile'
| 'NotFound'
| 'QueryError'
| 'ReachedMaxCallDepth'
| 'ValidationError'
9 changes: 8 additions & 1 deletion packages/payload/src/globals/operations/local/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { enforceCallDepth } from '../../../utilities/enforceCallDepth.js'
import countGlobalVersions from './countGlobalVersions.js'
import findOne from './findOne.js'
import findVersionByID from './findVersionByID.js'
import findVersions from './findVersions.js'
import restoreVersion from './restoreVersion.js'
import update from './update.js'

export default {
const local = {
countGlobalVersions,
findOne,
findVersionByID,
findVersions,
restoreVersion,
update,
}

for (const operation in local) {
local[operation] = enforceCallDepth(local[operation])
}

export default local
1 change: 1 addition & 0 deletions packages/payload/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,7 @@ export {
MissingFile,
NotFound,
QueryError,
ReachedMaxCallDepth,
ValidationError,
ValidationErrorName,
} from './errors/index.js'
Expand Down
37 changes: 37 additions & 0 deletions packages/payload/src/utilities/enforceCallDepth.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 27 additions & 0 deletions test/hooks/collections/InfinityLoop/index.ts
Original file line number Diff line number Diff line change
@@ -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,
},
})
}
},
],
},
}
2 changes: 2 additions & 0 deletions test/hooks/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -33,6 +34,7 @@ export const HooksConfig: Promise<SanitizedConfig> = buildConfigWithDefaults({
Relations,
Users,
DataHooks,
InfinityLoop,
],
globals: [DataHooksGlobal],
endpoints: [
Expand Down
24 changes: 23 additions & 1 deletion test/hooks/int.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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', () => {
Expand Down
23 changes: 23 additions & 0 deletions test/hooks/payload-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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>;
Expand Down Expand Up @@ -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".
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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".
Expand Down