joi has an uncaught RangeError on deeply nested input through recursive `link()` schemas
Description
Uncaught RangeError in Joi when validating deeply nested input with recursive link() schemas can cause denial of service.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Uncaught RangeError in Joi when validating deeply nested input with recursive link() schemas can cause denial of service.
Vulnerability
In Joi versions before 18.2.1, validating user-supplied JSON or object input with recursive link() schemas can cause an untrapped RangeError due to call stack overflow [1][2][3]. The issue affects all versions prior to the fix.
Exploitation
An attacker can send deeply nested input that triggers recursion in link() schemas, exceeding the runtime call stack. If validate() is called without try/catch in a request handler, the unhandled exception can crash the process. Even when using try/catch, the error is a RangeError instead of a structured ValidationError, complicating error handling [3][4].
Impact
Denial of service: an unhandled exception can crash the application. In cases where try/catch is used, validation fails with a RangeError, disrupting normal error flow and requiring non-standard handling.
Mitigation
Upgrade to Joi >= 18.2.1, which catches the recursion and returns a link.depth error code instead of throwing [1][2][3]. As a workaround, ensure all validation calls are wrapped in try/catch to prevent uncaught exceptions [3][4].
AI Insight generated on Jun 11, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
12392713d3e9dMerge pull request #3113 from hapijs/fix/link-max-call-stack
3 files changed · +54 −2
API.md+20 −1 modified@@ -2035,9 +2035,11 @@ 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. +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. As a safety net, when validation exceeds the runtime call stack while resolving a link, validation fails with the `link.depth` error code instead of crashing the process. ::: +Possible validation errors: [`link.depth`](#linkdepth) + Named links: ```js @@ -2108,6 +2110,8 @@ const schema = Joi.object({ }); ``` +Possible validation errors: [`link.maxRecursion`](#linkmaxrecursion) + ### `number` Generates a schema object that matches a number data type (as well as strings that can be converted to numbers). @@ -3952,6 +3956,21 @@ Additional local context properties: } ``` +#### `link.depth` + +The validation chain exceeded the runtime call stack while resolving a recursive link. Returned instead of throwing a `RangeError`. Set [`link.maxRecursion(limit)`](#linkmaxrecursionlimit) to bound the depth deterministically. + +#### `link.maxRecursion` + +The link was entered more times in a single validation chain than the limit set via [`link.maxRecursion(limit)`](#linkmaxrecursionlimit). + +Additional local context properties: +```ts +{ + limit: number // Maximum number of times the link may be entered +} +``` + #### `number.base` The value is not a number or could not be cast to a number.
lib/types/link.js+14 −1 modified@@ -65,7 +65,19 @@ module.exports = Any.extend({ 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); + + try { + return linked.$_validate(value, state.nest(linked, `link:${ref.display}:${linked.type}`), prefs); + } + catch (err) { + /* $lab:coverage:off$ */ + if (!(err instanceof RangeError)) { + throw err; + } + /* $lab:coverage:on$ */ + + return { value, errors: error('link.depth') }; + } }, generate(schema, value, state, prefs) { @@ -109,6 +121,7 @@ module.exports = Any.extend({ }, messages: { + 'link.depth': '{{#label}} exceeds maximum recursion depth supported by the runtime', 'link.maxRecursion': '{{#label}} exceeds maximum recursion depth of {{#limit}}' },
test/types/link.js+20 −0 modified@@ -397,6 +397,26 @@ describe('link', () => { }); }); + describe('runtime stack overflow', () => { + + it('reports a validation error instead of crashing on deeply nested recursive input', () => { + + const schema = Joi.object({ + a: Joi.link('/') + }); + + let value = {}; + for (let i = 0; i < 5000; ++i) { + value = { a: value }; + } + + const { error } = schema.validate(value); + expect(error).to.exist(); + expect(error.details[0].type).to.equal('link.depth'); + expect(error.message).to.contain('exceeds maximum recursion depth supported by the runtime'); + }); + }); + describe('when()', () => { it('validates a schema with when()', () => {
Vulnerability mechanics
Root cause
"Missing exception handling around recursive link validation allows a `RangeError` to propagate uncaught, causing a denial of service."
Attack vector
An attacker sends a JSON payload with deeply nested objects that reference a recursive `Joi.link()` schema. Because the link is resolved recursively during validation, a sufficiently deep nesting causes a `RangeError` (stack overflow). If the application calls `validate()` without a `try/catch` in a request handler, this unhandled exception can crash the process, resulting in a denial of service [ref_id=1].
Affected code
The vulnerability resides in `lib/types/link.js` where `$_validate` is called without protection against `RangeError` thrown when recursive link resolution exceeds the runtime call stack. The patch adds a `try/catch` around that call and returns a structured `link.depth` validation error instead of letting the exception propagate.
What the fix does
The patch wraps the recursive `$_validate` call in `lib/types/link.js` inside a `try/catch` block. When a `RangeError` is caught, it returns a `link.depth` validation error instead of throwing. This prevents the unhandled exception from crashing the process and gives callers a structured error they can handle gracefully [patch_id=5594804].
Preconditions
- configThe application uses Joi schemas with `Joi.link()` that reference themselves recursively (e.g., `Joi.object({ a: Joi.link('/') })`).
- inputThe attacker can supply input with deeply nested objects (e.g., 5000 levels of nesting) that trigger the recursive link resolution.
- configThe application does not set a `link.maxRecursion()` limit on the schema, or the limit is set high enough that the runtime call stack is exhausted first.
Generated on Jun 11, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.