Compare commits

...

2 Commits

Author SHA1 Message Date
Sasha
76d1f00765 remove auth 2024-11-26 16:47:09 +02:00
Sasha
0b05eb8f38 feat: enforce maximum call depth for local api operations 2024-11-26 16:25:25 +02:00
14 changed files with 154 additions and 4 deletions

View File

@@ -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). |

View File

@@ -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

View File

@@ -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',

View File

@@ -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.
*

View 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.`,
)
}
}

View File

@@ -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'

View File

@@ -15,4 +15,5 @@ export type ErrorName =
| 'MissingFile'
| 'NotFound'
| 'QueryError'
| 'ReachedMaxCallDepth'
| 'ValidationError'

View File

@@ -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

View File

@@ -1086,6 +1086,7 @@ export {
MissingFile,
NotFound,
QueryError,
ReachedMaxCallDepth,
ValidationError,
ValidationErrorName,
} from './errors/index.js'

View 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
}

View 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,
},
})
}
},
],
},
}

View File

@@ -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: [

View File

@@ -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', () => {

View File

@@ -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".