Skip to content

fix(BREAKING CHANGE): dereference caching to prevent infinite loops on circular schemas #380

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 11, 2025
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
38 changes: 32 additions & 6 deletions lib/dereference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,8 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
circular: false,
};

if (options && options.timeoutMs) {
if (Date.now() - startTime > options.timeoutMs) {
throw new TimeoutError(options.timeoutMs);
}
}
checkDereferenceTimeout<S, O>(startTime, options);

const derefOptions = (options.dereference || {}) as DereferenceOptions;
const isExcludedPath = derefOptions.excludedPathMatcher || (() => false);

Expand All @@ -98,6 +95,8 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
result.value = dereferenced.value;
} else {
for (const key of Object.keys(obj)) {
checkDereferenceTimeout<S, O>(startTime, options);

const keyPath = Pointer.join(path, key);
const keyPathFromRoot = Pointer.join(pathFromRoot, key);

Expand Down Expand Up @@ -214,7 +213,17 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
const $refPath = url.resolve(shouldResolveOnCwd ? url.cwd() : path, $ref.$ref);

const cache = dereferencedCache.get($refPath);
if (cache && !cache.circular) {

if (cache) {
// If the object we found is circular we can immediately return it because it would have been
// cached with everything we need already and we don't need to re-process anything inside it.
//
// If the cached object however is _not_ circular and there are additional keys alongside our
// `$ref` pointer here we should merge them back in and return that.
if (cache.circular) {
return cache;
}

const refKeys = Object.keys($ref);
if (refKeys.length > 1) {
const extraKeys = {};
Expand Down Expand Up @@ -294,6 +303,23 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
return dereferencedObject;
}

/**
* Check if we've run past our allowed timeout and throw an error if we have.
*
* @param startTime - The time when the dereferencing started.
* @param options
*/
function checkDereferenceTimeout<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOptions<S>>(
startTime: number,
options: O,
): void {
if (options && options.timeoutMs) {
if (Date.now() - startTime > options.timeoutMs) {
throw new TimeoutError(options.timeoutMs);
}
}
}

/**
* Called when a circular reference is found.
* It sets the {@link $Refs#circular} flag, executes the options.dereference.onCircular callback,
Expand Down
107 changes: 107 additions & 0 deletions test/specs/circular-extensive/circular-extensive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, it } from "vitest";
import $RefParser from "../../../lib/index.js";
import helper from "../../utils/helper.js";
import path from "../../utils/path.js";

import { expect } from "vitest";

describe("Schema with an extensive amount of circular $refs", () => {
it("should dereference successfully", async () => {
const circularRefs = new Set<string>();

const parser = new $RefParser<Record<string, any>>();
const schema = await parser.dereference(path.rel("test/specs/circular-extensive/schema.json"), {
dereference: {
onCircular: (ref: string) => circularRefs.add(ref),
},
});

// Ensure that a non-circular $ref was dereferenced.
expect(schema.components?.schemas?.ArrayOfMappedData).toStrictEqual({
type: "array",
items: {
type: "object",
properties: {
mappingTypeName: { type: "string" },
sourceSystemValue: { type: "string" },
mappedValueID: { type: "string" },
mappedValue: { type: "string" },
},
additionalProperties: false,
},
});

// Ensure that a circular $ref **was** dereferenced.
expect(circularRefs).toHaveLength(23);
expect(schema.components?.schemas?.Customer?.properties?.customerNode).toStrictEqual({
type: "array",
items: {
type: "object",
properties: {
customerNodeGuid: expect.any(Object),
customerGuid: expect.any(Object),
nodeId: expect.any(Object),
customerGu: expect.any(Object),
},
additionalProperties: false,
},
});
});

it("should dereference successfully with `dereference.circular` is `ignore`", async () => {
const circularRefs = new Set<string>();

const parser = new $RefParser<Record<string, any>>();
const schema = await parser.dereference(path.rel("test/specs/circular-extensive/schema.json"), {
dereference: {
onCircular: (ref: string) => circularRefs.add(ref),
circular: "ignore",
},
});

// Ensure that a non-circular $ref was dereferenced.
expect(schema.components?.schemas?.ArrayOfMappedData).toStrictEqual({
type: "array",
items: {
type: "object",
properties: {
mappingTypeName: { type: "string" },
sourceSystemValue: { type: "string" },
mappedValueID: { type: "string" },
mappedValue: { type: "string" },
},
additionalProperties: false,
},
});

// Ensure that a circular $ref was **not** dereferenced.
expect(circularRefs).toHaveLength(23);
expect(schema.components?.schemas?.Customer?.properties?.customerNode).toStrictEqual({
type: "array",
items: {
$ref: "#/components/schemas/CustomerNode",
},
});
});

it('should throw an error if "options.dereference.circular" is false', async () => {
const parser = new $RefParser();

try {
await parser.dereference(path.rel("test/specs/circular-extensive/schema.json"), {
dereference: { circular: false },
});

helper.shouldNotGetCalled();
} catch (err) {
expect(err).to.be.an.instanceOf(ReferenceError);
expect(err.message).to.contain("Circular $ref pointer found at ");
expect(err.message).to.contain(
"specs/circular-extensive/schema.json#/components/schemas/AssignmentExternalReference/properties/assignment/oneOf/0",
);

// $Refs.circular should be true
expect(parser.$refs.circular).to.equal(true);
}
});
});
Loading