From 9c7a44326629a730d91523b0fc6d0ee518d53f18 Mon Sep 17 00:00:00 2001 From: Nicolas Morel Date: Mon, 4 May 2026 17:06:22 +0200 Subject: [PATCH] feat: add maxRecursion limit to links --- API.md | 18 +++++++++++ lib/index.d.ts | 6 ++++ lib/types/link.js | 22 ++++++++++++- test/types/link.js | 78 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 1 deletion(-) diff --git a/API.md b/API.md index b26416090..d8e861ed8 100755 --- a/API.md +++ b/API.md @@ -2034,6 +2034,10 @@ Note that named links must be found in a direct ancestor of the link. The names Links are resolved once (per runtime) and the result schema cached. If you reuse a link in different places, the first time it is resolved at run-time, the result will be used by all other instances. If you want each link to resolve relative to the place it is used, use a separate `Joi.link()` statement in each place or set the `relative()` flag. +::: warning +It is strongly advised to set a [`link.maxRecursion(limit)`](#linkmaxrecursionlimit) on recursive links to bound the validation depth and protect against deeply nested inputs. +::: + Named links: ```js @@ -2090,6 +2094,20 @@ const schema = Joi.object({ Same as [`any.concat()`](#anyconcatschema) but the schema is merged after the link is resolved which allows merging with schemas of the same type as the resolved link. Will throw an exception during validation if the merged types are not compatible. +#### `link.maxRecursion(limit)` + +Limits the maximum number of times the same link is allowed to resolve within a single validation chain, where: +- `limit` - a positive integer specifying how many times the link may be entered. + +Validation fails with the `link.maxRecursion` error code when the limit is exceeded. Useful to guard recursive schemas against deeply nested inputs. + +```js +const schema = Joi.object({ + name: Joi.string().required(), + keys: Joi.array().items(Joi.link('...').maxRecursion(10)) +}); +``` + ### `number` Generates a schema object that matches a number data type (as well as strings that can be converted to numbers). diff --git a/lib/index.d.ts b/lib/index.d.ts index 9904088f1..8658a40c2 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -2138,6 +2138,12 @@ declare namespace Joi { * If `ref` was not passed to the constructor, `link.ref()` must be called prior to usage. */ ref(ref: string): this; + + /** + * Limits the maximum recursion depth allowed when resolving the link. + * Validation fails with `link.maxRecursion` when the link is entered more than `limit` times in the same validation chain. + */ + maxRecursion(limit: number): this; } interface Reference extends Exclude { diff --git a/lib/types/link.js b/lib/types/link.js index 60e7e935f..e7b597766 100755 --- a/lib/types/link.js +++ b/lib/types/link.js @@ -52,10 +52,17 @@ module.exports = Any.extend({ return res; }, - validate(value, { schema, state, prefs }) { + validate(value, { schema, state, prefs, error }) { assert(schema.$_terms.link, 'Uninitialized link schema'); + const limit = schema._flags.maxRecursion; + if (limit !== undefined && + state.schemas.filter((entry) => entry.schema === schema).length > limit) { + + return { value, errors: error('link.maxRecursion', { limit }) }; + } + const linked = internals.generate(schema, value, state, prefs); const ref = schema.$_terms.link[0].ref; return linked.$_validate(value, state.nest(linked, `link:${ref.display}:${linked.type}`), prefs); @@ -89,9 +96,22 @@ module.exports = Any.extend({ return this.$_setFlag('relative', enabled); } + }, + + maxRecursion: { + method(limit) { + + assert(Number.isSafeInteger(limit) && limit >= 1, 'limit must be a positive integer'); + + return this.$_setFlag('maxRecursion', limit); + } } }, + messages: { + 'link.maxRecursion': '{{#label}} exceeds maximum recursion depth of {{#limit}}' + }, + overrides: { concat(source) { diff --git a/test/types/link.js b/test/types/link.js index 121e9470e..26e39cc89 100755 --- a/test/types/link.js +++ b/test/types/link.js @@ -319,6 +319,84 @@ describe('link', () => { expect(() => schema.validate({ x: 123 })).to.throw('"x" contains link reference "ref:y" which is another link'); }); + describe('maxRecursion()', () => { + + it('limits recursion depth', () => { + + const schema = Joi.object({ + name: Joi.string().required(), + keys: Joi.array().items(Joi.link('...').maxRecursion(2)) + }); + + Helper.validate(schema, [ + [{ name: 'a' }, true], + [{ name: 'a', keys: [{ name: 'b' }] }, true], + [{ name: 'a', keys: [{ name: 'b', keys: [{ name: 'c' }] }] }, true], + [{ name: 'a', keys: [{ name: 'b', keys: [{ name: 'c', keys: [{ name: 'd' }] }] }] }, false, { + message: '"keys[0].keys[0].keys[0]" exceeds maximum recursion depth of 2', + path: ['keys', 0, 'keys', 0, 'keys', 0], + type: 'link.maxRecursion', + context: { limit: 2, label: 'keys[0].keys[0].keys[0]', value: { name: 'd' }, key: 0 } + }] + ]); + }); + + it('limits recursion depth (root link)', () => { + + const schema = Joi.object({ + name: Joi.string().required(), + keys: Joi.array().items(Joi.link('/').maxRecursion(1)) + }); + + Helper.validate(schema, [ + [{ name: 'a' }, true], + [{ name: 'a', keys: [{ name: 'b' }] }, true], + [{ name: 'a', keys: [{ name: 'b', keys: [{ name: 'c' }] }] }, false, { + message: '"keys[0].keys[0]" exceeds maximum recursion depth of 1', + path: ['keys', 0, 'keys', 0], + type: 'link.maxRecursion', + context: { limit: 1, label: 'keys[0].keys[0]', value: { name: 'c' }, key: 0 } + }] + ]); + }); + + it('limits recursion depth (named link via id)', () => { + + const schema = Joi.object({ + firstName: Joi.string().required(), + lastName: Joi.string().required(), + friends: Joi.array().items(Joi.link('#person').maxRecursion(2)) + }).id('person'); + + Helper.validate(schema, [ + [{ firstName: 'a', lastName: 'a' }, true], + [{ firstName: 'a', lastName: 'a', friends: [{ firstName: 'b', lastName: 'b' }] }, true], + [{ + firstName: 'a', lastName: 'a', + friends: [{ + firstName: 'b', lastName: 'b', + friends: [{ + firstName: 'c', lastName: 'c', + friends: [{ firstName: 'd', lastName: 'd' }] + }] + }] + }, false, { + message: '"friends[0].friends[0].friends[0]" exceeds maximum recursion depth of 2', + path: ['friends', 0, 'friends', 0, 'friends', 0], + type: 'link.maxRecursion', + context: { limit: 2, label: 'friends[0].friends[0].friends[0]', value: { firstName: 'd', lastName: 'd' }, key: 0 } + }] + ]); + }); + + it('errors on invalid limit', () => { + + expect(() => Joi.link('...').maxRecursion(0)).to.throw('limit must be a positive integer'); + expect(() => Joi.link('...').maxRecursion(1.5)).to.throw('limit must be a positive integer'); + expect(() => Joi.link('...').maxRecursion('2')).to.throw('limit must be a positive integer'); + }); + }); + describe('when()', () => { it('validates a schema with when()', () => {