diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ae24f4..3442cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Python JSONPath Change Log +# Version 2.2.1 (unreleased) + +**Fixes** + +- JSONPath parser recursion limit failures now raise `JSONPathRecursionError`, preserving the original `RecursionError` as the cause. `JSONPathRecursionError` now also subclasses `RecursionError`, allowing existing except `RecursionError` handlers to continue working. + ## Version 2.2.0 **Fixes** diff --git a/docs/index.md b/docs/index.md index abf4179..e9fd751 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,10 @@ Python JSONPath is a non-evaluating, read-only implementation of JSONPath, suita We also include implementations of [JSON Pointer](pointers.md) ([RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901)) and [JSON Patch](api.md#jsonpath.JSONPatch) ([RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902)), plus methods for converting a [JSONPathMatch](api.md#jsonpath.JSONPathMatch) to a `JSONPointer`. +!!! warning + + If you are accepting JSONPath queries from untrusted users, make sure you handle `JSONPathRecursionError` (a subclass of `JSONPathError` and Python's `RecursionError`). `JSONPathRecursionError` can be thrown at compile time if a query has been crafted to cause our recursive parser to reach Python's recursion limit. Or at path resolution time if `max_recursion_depth` is reached with the descendent segment. + ## Install Install Python JSONPath using [pip](https://pip.pypa.io/en/stable/getting-started/): diff --git a/jsonpath/__about__.py b/jsonpath/__about__.py index f1a6891..3aaeb51 100644 --- a/jsonpath/__about__.py +++ b/jsonpath/__about__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2023-present James Prior # # SPDX-License-Identifier: MIT -__version__ = "2.2.0" +__version__ = "2.2.1" diff --git a/jsonpath/__init__.py b/jsonpath/__init__.py index 03a7ef4..86156bc 100644 --- a/jsonpath/__init__.py +++ b/jsonpath/__init__.py @@ -111,6 +111,10 @@ def compile(path: str, *, strict: bool = False) -> Union[JSONPath, CompoundJSONP JSONPathSyntaxError: If _path_ is invalid. JSONPathTypeError: If filter functions are given arguments of an unacceptable type. + JSONPathRecursionError: If _path_ has been crafted in such a way as to + cause our recursive parser to reach Python's recursion limit. + `JSONPathRecursionError` inherits from both `JSONPathError` and + `RecursionError`. """ return _STRICT_ENV.compile(path) if strict else DEFAULT_ENV.compile(path) @@ -144,6 +148,10 @@ def findall( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: At path resolution time if `max_recursion_depth` + is reached with the descendent segment. Or at compile time if _path_ + has been crafted to cause our recursive parser to reach Python's + recursion limit. """ return ( _STRICT_ENV.findall(path, data, filter_context=filter_context) @@ -181,6 +189,10 @@ async def findall_async( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: At path resolution time if `max_recursion_depth` + is reached with the descendent segment. Or at compile time if _path_ + has been crafted to cause our recursive parser to reach Python's + recursion limit. """ return ( await _STRICT_ENV.findall_async(path, data, filter_context=filter_context) @@ -217,6 +229,10 @@ def finditer( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: At path resolution time if `max_recursion_depth` + is reached with the descendent segment. Or at compile time if _path_ + has been crafted to cause our recursive parser to reach Python's + recursion limit. """ return ( _STRICT_ENV.finditer(path, data, filter_context=filter_context) @@ -254,6 +270,10 @@ async def finditer_async( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: At path resolution time if `max_recursion_depth` + is reached with the descendent segment. Or at compile time if _path_ + has been crafted to cause our recursive parser to reach Python's + recursion limit. """ return ( await _STRICT_ENV.finditer_async(path, data, filter_context=filter_context) @@ -290,6 +310,10 @@ def match( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: At path resolution time if `max_recursion_depth` + is reached with the descendent segment. Or at compile time if _path_ + has been crafted to cause our recursive parser to reach Python's + recursion limit. """ return ( _STRICT_ENV.match(path, data, filter_context=filter_context) @@ -357,6 +381,10 @@ def query( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: At path resolution time if `max_recursion_depth` + is reached with the descendent segment. Or at compile time if _path_ + has been crafted to cause our recursive parser to reach Python's + recursion limit. """ return ( _STRICT_ENV.query(path, data, filter_context=filter_context) diff --git a/jsonpath/env.py b/jsonpath/env.py index afc214d..0612a29 100644 --- a/jsonpath/env.py +++ b/jsonpath/env.py @@ -217,6 +217,10 @@ def compile(self, path: str) -> Union[JSONPath, CompoundJSONPath]: # noqa: A003 JSONPathSyntaxError: If _path_ is invalid. JSONPathTypeError: If filter functions are given arguments of an unacceptable type. + JSONPathRecursionError: If _path_ has been crafted in such a way as + to cause our recursive parser to reach Python's recursion limit. + JSONPathRecursionError inherits from both `JSONPathError` and + `RecursionError`. """ tokens = self.lexer.tokenize(path) stream = TokenStream(tokens) @@ -298,6 +302,10 @@ def findall( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: At path resolution time if `max_recursion_depth` + is reached with the descendent segment. Or at compile time if + _path_ has been crafted to cause our recursive parser to reach + Python's recursion limit. """ return self.compile(path).findall(data, filter_context=filter_context) @@ -327,6 +335,10 @@ def finditer( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: At path resolution time if `max_recursion_depth` + is reached with the descendent segment. Or at compile time if + _path_ has been crafted to cause our recursive parser to reach + Python's recursion limit. """ return self.compile(path).finditer(data, filter_context=filter_context) @@ -356,6 +368,10 @@ def match( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: At path resolution time if `max_recursion_depth` + is reached with the descendent segment. Or at compile time if + _path_ has been crafted to cause our recursive parser to reach + Python's recursion limit. """ return self.compile(path).match(data, filter_context=filter_context) @@ -416,6 +432,10 @@ def query( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: At path resolution time if `max_recursion_depth` + is reached with the descendent segment. Or at compile time if + _path_ has been crafted to cause our recursive parser to reach + Python's recursion limit. """ return Query(self.finditer(path, data, filter_context=filter_context), self) diff --git a/jsonpath/exceptions.py b/jsonpath/exceptions.py index 0007f58..0585022 100644 --- a/jsonpath/exceptions.py +++ b/jsonpath/exceptions.py @@ -135,15 +135,19 @@ def __init__(self, *args: object, token: Token) -> None: self.token = token -class JSONPathRecursionError(JSONPathError): +class JSONPathRecursionError(JSONPathError, RecursionError): """An exception raised when the maximum recursion depth is reached. + This could be raised at parse time if a JSONPath query is constructed in + such a way to hit Python's recursion limit. Or at query resolution time + if `max_recursion_depth` is reached by the descendant segment (`..`). + Arguments: args: Arguments passed to `Exception`. token: The token that caused the error. """ - def __init__(self, *args: object, token: Token) -> None: + def __init__(self, *args: object, token: Optional[Token]) -> None: super().__init__(*args) self.token = token diff --git a/jsonpath/parse.py b/jsonpath/parse.py index 107d9a0..cfe07b7 100644 --- a/jsonpath/parse.py +++ b/jsonpath/parse.py @@ -16,6 +16,7 @@ from jsonpath.function_extensions.filter_function import ExpressionType from jsonpath.function_extensions.filter_function import FilterFunction +from .exceptions import JSONPathRecursionError from .exceptions import JSONPathSyntaxError from .exceptions import JSONPathTypeError from .filter import CURRENT_KEY @@ -324,7 +325,10 @@ def parse(self, stream: TokenStream) -> Iterator[JSONPathSegment]: # Raises a syntax error because the current token is not TOKEN_ROOT. stream.expect(TOKEN_ROOT) - yield from self.parse_query(stream) + try: + yield from self.parse_query(stream) + except RecursionError as err: + raise JSONPathRecursionError(str(err), token=None) from err if stream.current().kind not in (TOKEN_EOF, TOKEN_INTERSECTION, TOKEN_UNION): raise JSONPathSyntaxError( diff --git a/jsonpath/path.py b/jsonpath/path.py index 7e70021..cf305e3 100644 --- a/jsonpath/path.py +++ b/jsonpath/path.py @@ -87,6 +87,8 @@ def findall( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: If `max_recursion_depth` is reached with + the descendent segment. """ return [ match.obj for match in self.finditer(data, filter_context=filter_context) @@ -113,6 +115,8 @@ def finditer( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: If `max_recursion_depth` is reached with + the descendent segment. """ _data = load_data(data) path = self.env.pseudo_root_token if self.pseudo_root else self.env.root_token @@ -189,6 +193,8 @@ def match( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: If `max_recursion_depth` is reached with + the descendent segment. """ try: return next(iter(self.finditer(data, filter_context=filter_context))) @@ -213,6 +219,8 @@ def query( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: If `max_recursion_depth` is reached with + the descendent segment. """ return Query(self.finditer(data, filter_context=filter_context), self.env) @@ -291,6 +299,9 @@ def findall( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: If `max_recursion_depth` is reached with + the descendent segment. + """ objs = self.path.findall(data, filter_context=filter_context) @@ -325,6 +336,8 @@ def finditer( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: If `max_recursion_depth` is reached with + the descendent segment. """ matches = self.path.finditer(data, filter_context=filter_context) @@ -360,6 +373,8 @@ def match( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: If `max_recursion_depth` is reached with + the descendent segment. """ try: return next(iter(self.finditer(data, filter_context=filter_context))) @@ -417,6 +432,8 @@ def query( JSONPathSyntaxError: If the path is invalid. JSONPathTypeError: If a filter expression attempts to use types in an incompatible way. + JSONPathRecursionError: If `max_recursion_depth` is reached with + the descendent segment. """ return Query(self.finditer(data, filter_context=filter_context), self.env) diff --git a/tests/test_errors.py b/tests/test_errors.py index b39caab..d1ff5bc 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -105,3 +105,11 @@ class MockEnv(JSONPathEnvironment): with pytest.raises(JSONPathRecursionError): env.findall(query, data) + + +def test_compile_time_recursion_error(env: JSONPathEnvironment) -> None: + with pytest.raises(JSONPathRecursionError): + env.compile("$[?" + "!" * 493 + "@.a]") + + with pytest.raises(RecursionError): + env.compile("$[?" + "!" * 493 + "@.a]")