Skip to content
Merged
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
18 changes: 18 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReferenceOptions, "prefix"> {
Expand Down
22 changes: 21 additions & 1 deletion lib/types/link.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
78 changes: 78 additions & 0 deletions test/types/link.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down
Loading