Skip to content

Commit 09959ba

Browse files
committed
feat: handle cyclic reference chains safely
1 parent fa3cccb commit 09959ba

8 files changed

Lines changed: 212 additions & 39 deletions

File tree

lib/dereference.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
318318

319319
// Check for circular references
320320
const directCircular = pointer.circular;
321-
let circular = directCircular || parents.has(pointer.value);
321+
let circular = directCircular || pointer.chainCircular || parents.has(pointer.value);
322322
if (circular) {
323323
foundCircularReference(path, $refs, options);
324324
}
@@ -352,7 +352,7 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
352352
dereferencedValue = $ref;
353353
}
354354

355-
if (directCircular) {
355+
if (directCircular && dereferencedValue !== $ref) {
356356
// The pointer is a DIRECT circular reference (i.e. it references itself).
357357
// So replace the $ref path with the absolute path from the JSON Schema root
358358
dereferencedValue.$ref = pathFromRoot;

lib/options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export interface DereferenceOptions {
103103
* The maximum recursion depth for dereferencing nested schemas.
104104
* If the schema nesting exceeds this depth, a RangeError will be thrown
105105
* with a descriptive message instead of crashing with a stack overflow.
106+
* This limit also applies to `$ref` resolution chains.
106107
*
107108
* Default: 500
108109
*/

lib/pointer.ts

Lines changed: 99 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
5555
* Indicates whether the pointer references itself.
5656
*/
5757
circular: boolean;
58+
/**
59+
* Indicates whether the pointer encountered a cycle elsewhere in its reference chain.
60+
*/
61+
chainCircular: boolean;
5862
/**
5963
* The number of indirect references that were traversed to resolve the value.
6064
* Resolving a single pointer may require resolving multiple $Refs.
@@ -74,6 +78,8 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
7478

7579
this.circular = false;
7680

81+
this.chainCircular = false;
82+
7783
this.indirections = 0;
7884
}
7985

@@ -90,7 +96,13 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
9096
* the {@link Pointer#$ref} and {@link Pointer#path} will reflect the resolution path
9197
* of the resolved value.
9298
*/
93-
resolve(obj: S, options?: O, pathFromRoot?: string) {
99+
resolve(
100+
obj: S,
101+
options?: O,
102+
pathFromRoot?: string,
103+
visitedRefPaths = new Set<string>(),
104+
resolveFinalReference = true,
105+
) {
94106
const tokens = Pointer.parse(this.path, this.originalPath);
95107
const found: string[] = [];
96108

@@ -108,15 +120,17 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
108120
// This prevents false circular detection when e.g. a root schema has both
109121
// $ref: "#/$defs/Foo" and $defs: { Foo: {...} } as siblings.
110122
const wasCircular = this.circular;
123+
const wasChainCircular = this.chainCircular;
111124
const isExtendedRef = $Ref.isExtended$Ref(this.value);
112-
if (resolveIf$Ref(this, options, pathFromRoot)) {
125+
if (resolveIf$Ref(this, options, pathFromRoot, visitedRefPaths)) {
113126
// The $ref path has changed, so append the remaining tokens to the path
114127
this.path = Pointer.join(this.path, tokens.slice(i));
115-
} else if (!wasCircular && this.circular && isExtendedRef) {
128+
} else if (isExtendedRef) {
116129
// resolveIf$Ref set circular=true on an extended $ref during token walking.
117130
// Since we still have tokens to process, the object should be walked by its
118131
// properties, not treated as a circular self-reference.
119-
this.circular = false;
132+
this.circular = wasCircular;
133+
this.chainCircular = wasChainCircular;
120134
}
121135

122136
const token = tokens[i];
@@ -134,6 +148,7 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
134148
}
135149
}
136150
if (didFindSubstringSlashMatch) {
151+
this.chainCircular = wasChainCircular;
137152
continue;
138153
}
139154

@@ -144,6 +159,7 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
144159
// We use a `null` symbol for internal tracking to differentiate between a general `null`
145160
// value and our expected `null` value.
146161
this.value = nullSymbol;
162+
this.chainCircular = wasChainCircular;
147163
continue;
148164
}
149165

@@ -160,6 +176,7 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
160176
this.value = this.value[token];
161177
}
162178

179+
this.chainCircular = wasChainCircular;
163180
found.push(token);
164181
if (this.$ref.dynamicIdScope) {
165182
this.scopeBase = getSchemaBasePath(this.scopeBase, this.value);
@@ -168,8 +185,20 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
168185

169186
// Resolve the final value
170187
const finalResolutionBase = this.$ref.dynamicIdScope ? this.scopeBase : this.path;
171-
if (!this.value || (this.value.$ref && url.resolve(finalResolutionBase, this.value.$ref) !== pathFromRoot)) {
172-
resolveIf$Ref(this, options, pathFromRoot);
188+
if (resolveFinalReference) {
189+
const finalRefPath = this.value?.$ref ? url.resolve(finalResolutionBase, this.value.$ref) : undefined;
190+
const canonicalPathFromRoot =
191+
typeof pathFromRoot === "string" ? url.resolve(this.$ref.$refs._root$Ref.path!, pathFromRoot) : pathFromRoot;
192+
193+
if (
194+
$Ref.isAllowed$Ref(this.value, options) &&
195+
finalRefPath === canonicalPathFromRoot &&
196+
finalRefPath !== this.path
197+
) {
198+
this.chainCircular = true;
199+
} else if (!this.value || finalRefPath) {
200+
resolveIf$Ref(this, options, pathFromRoot, visitedRefPaths);
201+
}
173202
}
174203

175204
return this;
@@ -305,32 +334,74 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
305334
* @returns - Returns `true` if the resolution path changed
306335
*/
307336
function resolveIf$Ref<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOptions<S>>(
308-
pointer: Pointer,
337+
pointer: Pointer<S, O>,
309338
options: O | undefined,
310339
pathFromRoot?: string,
340+
visitedRefPaths = new Set<string>(),
311341
) {
312-
// Is the value a JSON reference? (and allowed?)
342+
let pathChanged = false;
343+
let currentPathFromRoot = pathFromRoot;
344+
const addedPaths: string[] = [];
345+
346+
try {
347+
// Pure reference chains can be followed iteratively. Extended refs still use
348+
// the existing one-hop behavior because their values must be merged on return.
349+
while ($Ref.isAllowed$Ref(pointer.value, options)) {
350+
const extended = $Ref.isExtended$Ref(pointer.value);
351+
const sourceValue = pointer.value;
352+
const parentPath = pointer.path;
353+
const resolutionBase = pointer.$ref.dynamicIdScope ? pointer.scopeBase : pointer.path;
354+
const $refPath = url.resolve(resolutionBase, pointer.value.$ref);
355+
356+
if ($refPath === pointer.path && !isRootPath(currentPathFromRoot)) {
357+
// The value is a reference to itself, so there's nothing to do.
358+
pointer.circular = true;
359+
pointer.chainCircular = true;
360+
return pathChanged;
361+
}
313362

314-
if ($Ref.isAllowed$Ref(pointer.value, options)) {
315-
const resolutionBase = pointer.$ref.dynamicIdScope ? pointer.scopeBase : pointer.path;
316-
const $refPath = url.resolve(resolutionBase, pointer.value.$ref);
363+
const canonicalPathFromRoot =
364+
typeof currentPathFromRoot === "string"
365+
? url.resolve(pointer.$ref.$refs._root$Ref.path!, currentPathFromRoot)
366+
: currentPathFromRoot;
367+
if ($refPath === canonicalPathFromRoot && $refPath !== pointer.path) {
368+
pointer.chainCircular = true;
369+
return pathChanged;
370+
}
317371

318-
if ($refPath === pointer.path && !isRootPath(pathFromRoot)) {
319-
// The value is a reference to itself, so there's nothing to do.
320-
pointer.circular = true;
321-
} else {
322-
const resolved = pointer.$ref.$refs._resolve($refPath, pointer.path, options);
372+
if (visitedRefPaths.has($refPath)) {
373+
pointer.chainCircular = true;
374+
return pathChanged;
375+
}
376+
377+
const maxDepth = options?.dereference?.maxDepth ?? 500;
378+
if (visitedRefPaths.size >= maxDepth) {
379+
throw new RangeError(
380+
`Maximum dereference depth (${maxDepth}) exceeded at ${pointer.path}. ` +
381+
`This likely indicates an extremely deep or recursive schema. ` +
382+
`You can increase this limit with the dereference.maxDepth option.`,
383+
);
384+
}
385+
386+
visitedRefPaths.add($refPath);
387+
addedPaths.push($refPath);
388+
389+
const resolved = pointer.$ref.$refs._resolve($refPath, parentPath, options, visitedRefPaths, extended);
323390
if (resolved === null) {
324-
return false;
391+
return pathChanged;
325392
}
326393

327394
pointer.indirections += resolved.indirections + 1;
395+
pointer.chainCircular ||= resolved.circular || resolved.chainCircular;
328396

329-
if ($Ref.isExtended$Ref(pointer.value)) {
397+
if (extended) {
330398
// This JSON reference "extends" the resolved value, rather than simply pointing to it.
331399
// So the resolved path does NOT change. Just the value does.
332-
pointer.value = $Ref.dereference(pointer.value, resolved.value, options);
333-
return false;
400+
if (pointer.chainCircular) {
401+
return pathChanged;
402+
}
403+
pointer.value = $Ref.dereference(sourceValue, resolved.value, options);
404+
return pathChanged;
334405
} else {
335406
// Resolve the reference
336407
pointer.$ref = resolved.$ref;
@@ -339,13 +410,18 @@ function resolveIf$Ref<S extends object = JSONSchema, O extends ParserOptions<S>
339410
// `pointer.$ref.path` is already the canonical location of the resolved resource.
340411
// Re-applying the resource's own `$id` here would duplicate nested path segments
341412
// such as `nested/nested/foo.json`.
342-
pointer.scopeBase = pointer.$ref.path!;
413+
pointer.scopeBase = resolved.scopeBase;
414+
currentPathFromRoot = parentPath;
415+
pathChanged = true;
343416
}
417+
}
344418

345-
return true;
419+
return pathChanged;
420+
} finally {
421+
for (const path of addedPaths) {
422+
visitedRefPaths.delete(path);
346423
}
347424
}
348-
return undefined;
349425
}
350426
export default Pointer;
351427

lib/ref.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,21 @@ class $Ref<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOpt
120120
* @param options
121121
* @param friendlyPath - The original user-specified path (used for error messages)
122122
* @param pathFromRoot - The path of `obj` from the schema root
123+
* @param visitedRefPaths - the active paths in the current reference chain
124+
* @param resolveFinalReference - whether to follow a `$ref` at the resolved value
123125
* @returns
124126
*/
125-
resolve(path: string, options?: O, friendlyPath?: string, pathFromRoot?: string) {
127+
resolve(
128+
path: string,
129+
options?: O,
130+
friendlyPath?: string,
131+
pathFromRoot?: string,
132+
visitedRefPaths?: Set<string>,
133+
resolveFinalReference = true,
134+
) {
126135
const pointer = new Pointer<S, O>(this, path, friendlyPath);
127136
try {
128-
const resolved = pointer.resolve(this.value, options, pathFromRoot);
137+
const resolved = pointer.resolve(this.value, options, pathFromRoot, visitedRefPaths, resolveFinalReference);
129138
if (resolved.value === nullSymbol) {
130139
resolved.value = null;
131140
}

lib/refs.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,18 +155,26 @@ export default class $Refs<S extends object = JSONSchema, O extends ParserOption
155155
* @param path - The path being resolved, optionally with a JSON pointer in the hash
156156
* @param pathFromRoot - The path of `obj` from the schema root
157157
* @param [options]
158+
* @param visitedRefPaths - the active paths in the current reference chain
159+
* @param resolveFinalReference - whether to follow a `$ref` at the resolved value
158160
* @returns
159161
* @protected
160162
*/
161-
_resolve(path: string, pathFromRoot: string, options?: O) {
163+
_resolve(
164+
path: string,
165+
pathFromRoot: string,
166+
options?: O,
167+
visitedRefPaths?: Set<string>,
168+
resolveFinalReference = true,
169+
) {
162170
const absPath = url.resolve(this._root$Ref.path!, path);
163171
const $ref = this._getRef(absPath);
164172

165173
if (!$ref) {
166174
throw new Error(`Error resolving $ref pointer "${path}". \n"${url.stripHash(absPath)}" not found.`);
167175
}
168176

169-
return $ref.resolve(absPath, options, path, pathFromRoot);
177+
return $ref.resolve(absPath, options, path, pathFromRoot, visitedRefPaths, resolveFinalReference);
170178
}
171179

172180
/**
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it } from "vitest";
2+
import $RefParser from "../../../lib/index.js";
3+
4+
describe("Circular $ref chains", () => {
5+
for (const length of [3, 4]) {
6+
it(`detects a cycle of length ${length} without overflowing`, async () => {
7+
const input = createCircularSchema(length);
8+
const parser = new $RefParser();
9+
10+
const result = await parser.dereference(input);
11+
12+
expect(result).to.be.an("object");
13+
expect(parser.$refs.circular).to.equal(true);
14+
});
15+
}
16+
17+
it("honors dereference.circular=false", async () => {
18+
const parser = new $RefParser();
19+
20+
await expect(
21+
parser.dereference(createCircularSchema(3), {
22+
dereference: { circular: false },
23+
}),
24+
).rejects.toThrowError(ReferenceError);
25+
});
26+
27+
it("bundles a multi-reference cycle without overflowing", async () => {
28+
const parser = new $RefParser();
29+
30+
const result = await parser.bundle(createCircularSchema(3));
31+
32+
expect(result).to.be.an("object");
33+
});
34+
});
35+
36+
function createCircularSchema(length: number) {
37+
const schemas: Record<string, { $ref: string }> = {};
38+
for (let index = 0; index < length; index++) {
39+
schemas[`node${index}`] = {
40+
$ref: `#/components/schemas/node${(index + 1) % length}`,
41+
};
42+
}
43+
44+
return {
45+
components: { schemas },
46+
entry: { $ref: "#/components/schemas/node0" },
47+
};
48+
}

test/specs/dereference-max-depth/dereference-max-depth.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,29 @@ describe("Dereference max depth", () => {
4343
expect(result.type).toBe("object");
4444
});
4545

46+
it("should apply max depth to reference chains", async () => {
47+
const parser = new $RefParser();
48+
await parser.resolve(createReferenceChain(5_000));
49+
50+
expect(() => parser.$refs.get("#/entry")).to.throw(/Maximum dereference depth \(500\)/);
51+
expect(() =>
52+
parser.$refs.get("#/entry", {
53+
dereference: { maxDepth: 1_000 },
54+
}),
55+
).to.throw(/Maximum dereference depth \(1000\)/);
56+
57+
expect(
58+
parser.$refs.get("#/entry", {
59+
dereference: { maxDepth: 6_000 },
60+
}),
61+
).to.deep.equal({ type: "string" });
62+
63+
const dereferenced = await new $RefParser().dereference(createReferenceChain(600), {
64+
dereference: { maxDepth: 1_000 },
65+
});
66+
expect(dereferenced.entry).to.deep.equal({ type: "string" });
67+
});
68+
4669
it("should dereference normally when within max depth", async () => {
4770
const schema = {
4871
type: "object",
@@ -58,3 +81,16 @@ describe("Dereference max depth", () => {
5881
expect(result.properties.name.type).toBe("string");
5982
});
6083
});
84+
85+
function createReferenceChain(length: number) {
86+
const definitions: Record<string, { $ref: string } | { type: string }> = {};
87+
for (let index = 0; index < length; index++) {
88+
definitions[`node${index}`] =
89+
index === length - 1 ? { type: "string" } : { $ref: `#/definitions/node${index + 1}` };
90+
}
91+
92+
return {
93+
definitions,
94+
entry: { $ref: "#/definitions/node0" },
95+
};
96+
}

0 commit comments

Comments
 (0)