Skip to content

Commit

Permalink
Guard against recursive data
Browse files Browse the repository at this point in the history
  • Loading branch information
jg-rp committed Sep 18, 2023
1 parent ad01f3c commit ea7606a
Show file tree
Hide file tree
Showing 7 changed files with 108 additions and 26 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
**Breaking Changes**

- Rename `JSONPathEnvironment.filterRegister` to `JSONPathEnvironment.functionRegister`.
- Removed `JSONPathEnvironment.options` in favour of equivalent environment properties. For example, `JSONPathEnvironment.options.maxIntIndex` is now `JSONPathEnvironment.maxIntIndex`.

**Fixes**

Expand All @@ -15,6 +16,7 @@
**Features**

- Implement [Relative JSON Pointers](https://www.ietf.org/id/draft-hha-relative-json-pointer-00.html). Use the `to(rel)` method of `JSONPointer`, where `rel` is a relative JSON pointer string and a new `JSONPointer` is returned.
- Guard against recursive data structures by implementing the `JSONPathEnvironment.maxRecursionDepth` option. When using the recursive descent selector (`..`), if the maximum recursion depth is reached, a `JSONPathRecursionLimitError` is thrown.

# Version 0.1.1

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
JSONPathNodeList,
JSONPathSyntaxError,
JSONPathTypeError,
JSONPathRecursionLimitError,
Token,
TokenKind,
Nothing,
Expand Down
65 changes: 53 additions & 12 deletions src/path/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,37 +20,74 @@ import { Token, TokenStream } from "./token";
import { JSONValue } from "../types";

/**
*
* JSONPath environment options. The defaults are in compliance with JSONPath
* standards.
*/
export type JSONPathEnvironmentOptions = {
/**
* Indicates if the environment should to be strict about its compliance with
* JSONPath standards.
*
* Defaults to `true`. Setting `strict` to `false` currently has no effect.
* If/when we add non-standard features, the environment's strictness will
* control their availability.
*/
strict: boolean;
strict?: boolean;

/**
*
* The maximum number allowed when indexing or slicing an array. Defaults to
* 2**53 -1.
*/
maxIntIndex: number;
maxIntIndex?: number;

/**
*
* The minimum number allowed when indexing or slicing an array. Defaults to
* -(2**53) -1.
*/
minIntIndex: number;
};
minIntIndex?: number;

export const defaultOptions: JSONPathEnvironmentOptions = {
strict: true,
maxIntIndex: Math.pow(2, 53) - 1,
minIntIndex: -Math.pow(2, 53) - 1,
/**
* The maximum number of objects and/or arrays the recursive descent selector
* can visit before a `JSONPathRecursionLimitError` is thrown.
*/
maxRecursionDepth?: number;
};

/**
*
*/
export class JSONPathEnvironment {
/**
* Indicates if the environment should to be strict about its compliance with
* JSONPath standards.
*
* Defaults to `true`. Setting `strict` to `false` currently has no effect.
* If/when we add non-standard features, the environment's strictness will
* control their availability.
*/
readonly strict: boolean;

/**
* The maximum number allowed when indexing or slicing an array. Defaults to
* 2**53 -1.
*/
readonly maxIntIndex: number;

/**
* The minimum number allowed when indexing or slicing an array. Defaults to
* -(2**53) -1.
*/
readonly minIntIndex: number;

/**
* The maximum number of objects and/or arrays the recursive descent selector
* can visit before a `JSONPathRecursionLimitError` is thrown.
*/
readonly maxRecursionDepth: number;

/**
* A map of function names to objects implementing the {@link FilterFunction}
* interface. You are free to set or delete custom filter functions directly.
*/
public functionRegister: Map<string, FilterFunction> = new Map();

Expand All @@ -60,7 +97,11 @@ export class JSONPathEnvironment {
*
* @param options -
*/
constructor(readonly options: JSONPathEnvironmentOptions = defaultOptions) {
constructor(options: JSONPathEnvironmentOptions = {}) {
this.strict = options.strict ?? true;
this.maxIntIndex = options.maxIntIndex ?? Math.pow(2, 53) - 1;
this.minIntIndex = options.maxIntIndex ?? -Math.pow(2, 53) - 1;
this.maxRecursionDepth = options.maxRecursionDepth ?? 50;
this.parser = new Parser(this);
this.setupFilterFunctions();
}
Expand Down
15 changes: 15 additions & 0 deletions src/path/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,18 @@ export class JSONPathSyntaxError extends JSONPathError {
this.message = withErrorContext(message, token);
}
}

/**
* Error thrown when the maximum recursion depth is reached.
*/
export class JSONPathRecursionLimitError extends JSONPathError {
constructor(
readonly message: string,
readonly token: Token,
) {
super(message, token);
Object.setPrototypeOf(this, new.target.prototype);
this.name = "JSONPathRecursionLimitError";
this.message = withErrorContext(message, token);
}
}
1 change: 1 addition & 0 deletions src/path/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export {
JSONPathLexerError,
JSONPathSyntaxError,
JSONPathTypeError,
JSONPathRecursionLimitError,
} from "./errors";

export { Nothing } from "./types";
Expand Down
28 changes: 14 additions & 14 deletions src/path/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { JSONPathEnvironment } from "./environment";
import { JSONPathIndexError } from "./errors";
import { JSONPathIndexError, JSONPathRecursionLimitError } from "./errors";
import { LogicalExpression } from "./expression";
import { JSONPathNode, JSONPathNodeList } from "./node";
import { Token } from "./token";
Expand Down Expand Up @@ -74,8 +74,8 @@ export class IndexSelector extends JSONPathSelector {
) {
super(environment, token);
if (
index < this.environment.options.minIntIndex ||
index > this.environment.options.maxIntIndex
index < this.environment.minIntIndex ||
index > this.environment.maxIntIndex
) {
throw new JSONPathIndexError("index out of range", this.token);
}
Expand Down Expand Up @@ -151,20 +151,14 @@ export class SliceSelector extends JSONPathSelector {
for (const index of indices) {
if (
index !== undefined &&
(index < this.environment.options.minIntIndex ||
index > this.environment.options.maxIntIndex)
(index < this.environment.minIntIndex ||
index > this.environment.maxIntIndex)
) {
throw new JSONPathIndexError("index out of range", this.token);
}
}
}

private normalizedIndex(length: number, index: number): number {
if (index < 0 && length >= Math.abs(index))
return Math.min(length + index, length - 1);
return Math.min(index, length - 1);
}

// eslint-disable-next-line sonarjs/cognitive-complexity
private slice(
arr: JSONValue[],
Expand Down Expand Up @@ -263,7 +257,13 @@ export class RecursiveDescentSegment extends JSONPathSelector {
return "..";
}

private visit(node: JSONPathNode): JSONPathNodeList {
private visit(node: JSONPathNode, depth: number = 1): JSONPathNodeList {
if (depth >= this.environment.maxRecursionDepth) {
throw new JSONPathRecursionLimitError(
"recursion limit reached",
this.token,
);
}
const rv: JSONPathNode[] = [];
if (node.value instanceof String) return new JSONPathNodeList(rv);
if (isArray(node.value)) {
Expand All @@ -273,7 +273,7 @@ export class RecursiveDescentSegment extends JSONPathSelector {
node.location.concat(i),
node.root,
);
rv.push(_node, ...this.visit(_node));
rv.push(_node, ...this.visit(_node, depth + 1));
}
} else if (isObject(node.value)) {
for (const [key, value] of Object.entries(node.value)) {
Expand All @@ -282,7 +282,7 @@ export class RecursiveDescentSegment extends JSONPathSelector {
node.location.concat(key),
node.root,
);
rv.push(_node, ...this.visit(_node));
rv.push(_node, ...this.visit(_node, depth + 1));
}
}
return new JSONPathNodeList(rv);
Expand Down
22 changes: 22 additions & 0 deletions tests/path/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { JSONValue } from "../../src";
import { JSONPathEnvironment } from "../../src/path/environment";
import {
JSONPathIndexError,
JSONPathRecursionLimitError,
JSONPathSyntaxError,
JSONPathTypeError,
UndefinedFilterFunctionError,
Expand Down Expand Up @@ -70,3 +72,23 @@ describe("undefined filter function", () => {
);
});
});

describe("recursion limit reached", () => {
test("recursive data", () => {
const env = new JSONPathEnvironment();
const query = "$..a";
const arr: JSONValue[] = [];
const data = { foo: arr };
arr.push(data);
expect(() => env.query(query, data)).toThrow(JSONPathRecursionLimitError);
expect(() => env.query(query, data)).toThrow("recursion limit reached");
});

test("nested data with low limit", () => {
const env = new JSONPathEnvironment({ maxRecursionDepth: 2 });
const query = "$..a";
const data = { foo: [{ bar: [1, 2, 3] }] };
expect(() => env.query(query, data)).toThrow(JSONPathRecursionLimitError);
expect(() => env.query(query, data)).toThrow("recursion limit reached");
});
});

0 comments on commit ea7606a

Please sign in to comment.