diff --git a/API.md b/API.md index b2641609..d8e861ed 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 9904088f..8658a40c 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 60e7e935..e7b59776 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 121e9470..26e39cc8 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()', () => {