Skip to content

Commit 66154da

Browse files
committed
Add timeout and stack guardrails
1 parent e722d28 commit 66154da

5 files changed

Lines changed: 88 additions & 29 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,36 @@ JSONata> (a & b)
107107
hello world
108108
```
109109

110+
## Guardrails
111+
112+
JSONata is Turing-complete, so it's possible to write expressions that loop forever or exhaust memory. If you evaluate
113+
untrusted expressions, configure these guardrails (see the JS reference implementation's
114+
[guardrails docs](https://docs.jsonata.org/guardrails) for more background):
115+
116+
- **Stack overflow** — the `stack` parameter caps the depth of the eval-apply cycle. Exceeding it raises `D1011`.
117+
- **Excessive execution time** — the `timeout` parameter (in milliseconds) catches tail-recursive infinite loops that
118+
`stack` can't. Exceeding it raises `D1012`.
119+
- **Rogue regular expressions** — the `regex_engine` parameter lets you swap in a linear-time engine (e.g.
120+
[`google-re2`](https://pypi.org/project/google-re2/)) to protect against [ReDoS](https://en.wikipedia.org/wiki/ReDoS),
121+
since the `timeout` guardrail can't interrupt a regex match in progress.
122+
123+
```python
124+
import re2 # pip install google-re2
125+
import jsonata
126+
from jsonata.regex_engine import RegexFlags
127+
128+
129+
def re2_regex_engine(pattern: str, flags: RegexFlags):
130+
options = re2.Options()
131+
options.case_sensitive = not flags.case_insensitive
132+
options.one_line = not flags.multiline
133+
return re2.compile(pattern, options)
134+
135+
136+
expr = jsonata.Jsonata("<JSONata expression>", re2_regex_engine, timeout=1000, stack=500)
137+
result = expr.evaluate(data)
138+
```
139+
110140
## Running Tests
111141

112142
This project uses the repository of the reference implementation as a submodule. This allows referencing the current version of the unit tests. To clone this repository, run:

src/jsonata/functions.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2228,22 +2228,45 @@ def function_eval(expr: Optional[str], focus: Optional[Any]) -> Optional[Any]:
22282228
ast = None
22292229
try:
22302230
try:
2231+
# Only used to parse expr into an AST (ast.ast below); the
2232+
# actual evaluation reuses the enclosing instance directly
2233+
# (see below), so regex_engine here just needs to be valid
2234+
# for parsing -- it does not need to be evaluated against.
22312235
ast = jsonata.Jsonata(expr, enclosing.regex_engine)
22322236
except Exception as err:
22332237
# error parsing the expression passed to $eval
22342238
# populateMessage(err)
22352239
raise jexception.JException("D3120", -1)
2240+
2241+
# Constructing `ast` overwrote Jsonata.CURRENT.jsonata as a side
2242+
# effect (see Jsonata.__init__); restore it to the enclosing
2243+
# instance before evaluating, so enclosing.eval()'s own
2244+
# get_per_thread_instance() lookup resolves correctly.
2245+
jsonata.Jsonata.CURRENT.jsonata = enclosing
2246+
22362247
result = None
22372248
try:
2238-
result = ast.evaluate(input, enclosing.environment)
2249+
# Evaluate ast.ast (the parsed tree) using the *enclosing*
2250+
# instance's low-level eval(), reusing enclosing.environment
2251+
# directly rather than calling ast.evaluate(input, environment)
2252+
# (which copies environment's bindings one level deep into a
2253+
# fresh child frame rooted at ast's own static frame). This
2254+
# mirrors jsonata-java's Functions.functionEval, which calls
2255+
# Jsonata.current.get().evaluate(ast.ast, input, env) rather
2256+
# than constructing a second, disconnected evaluation
2257+
# context. Reusing the environment directly means $eval sees
2258+
# bindings at every level of the enclosing scope chain (not
2259+
# just the immediate frame) and correctly inherits any
2260+
# stack/timeout guardrails registered on an ancestor frame,
2261+
# since Frame.lookup walks the parent chain.
2262+
result = enclosing.eval(ast.ast, input, enclosing.environment)
22392263
except Exception as err:
22402264
# error evaluating the expression passed to $eval
22412265
# populateMessage(err)
22422266
raise jexception.JException("D3121", -1)
22432267
finally:
2244-
# Restore the enclosing instance as current now that the nested
2245-
# expression's construction/evaluation (which needed CURRENT.jsonata
2246-
# to be the *inner* ast -- see Jsonata.get_per_thread_instance) is done.
2268+
# Restore the enclosing instance as current now that $eval's
2269+
# parsing/evaluation is done.
22472270
jsonata.Jsonata.CURRENT.jsonata = enclosing
22482271

22492272
return result

src/jsonata/jexception.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ def msg(error: str, location: int, arg1: Optional[Any], arg2: Optional[Any], det
175175
"T1007": "Attempted to partially apply a non-function. Did you mean ${{{token}}}?",
176176
"T1008": "Attempted to partially apply a non-function",
177177
"D1009": "Multiple key definitions evaluate to same key: {{value}}",
178+
"D1011": "Stack overflow. Check for non-terminating recursive function. Consider rewriting as tail-recursive",
179+
"D1012": "Evaluation timeout after {{value}} milliseconds. Check for infinite loop",
178180
"T1010": "The matcher Object argument passed to Object {{token}} does not return the correct object structure",
179181
"T2001": "The left side of the {{token}} operator must evaluate to a number",
180182
"T2002": "The right side of the {{token}} operator must evaluate to a number",

src/jsonata/jsonata.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def lookup(self, name: str) -> Optional[Any]:
7272
# @param timeout Timeout in millis
7373
# @param maxRecursionDepth Max recursion depth
7474
#
75-
def set_runtime_bounds(self, timeout: int, max_recursion_depth: int) -> None:
75+
def set_runtime_bounds(self, timeout: Optional[int], max_recursion_depth: Optional[int]) -> None:
7676
timebox.Timebox(self, timeout, max_recursion_depth)
7777

7878
def set_evaluate_entry_callback(self, cb: Callable) -> None:
@@ -1904,12 +1904,17 @@ def _static_initializer() -> None:
19041904
# compile JSONata regex literals. Every engine, including the
19051905
# default, must translate RegexFlags into its own native
19061906
# representation -- see jsonata.regex_engine.default_regex_engine.
1907+
# @param {Integer} timeout - max evaluation time in milliseconds, or None
1908+
# for no limit. Raises D1012 if exceeded.
1909+
# @param {Integer} stack - max eval-apply recursion depth, or None for
1910+
# no limit. Raises D1011 if exceeded.
19071911
# @returns Evaluated expression
19081912
# @throws jexception.JException An exception if an error occured.
19091913
#
19101914
@staticmethod
1911-
def jsonata(expression: Optional[str], regex_engine: RegexEngine = default_regex_engine) -> 'Jsonata':
1912-
return Jsonata(expression, regex_engine)
1915+
def jsonata(expression: Optional[str], regex_engine: RegexEngine = default_regex_engine,
1916+
timeout: Optional[int] = None, stack: Optional[int] = None) -> 'Jsonata':
1917+
return Jsonata(expression, regex_engine, timeout, stack)
19131918

19141919
#
19151920
# Internal constructor
@@ -1923,9 +1928,14 @@ def jsonata(expression: Optional[str], regex_engine: RegexEngine = default_regex
19231928
timestamp: int
19241929
input: Optional[Any]
19251930
regex_engine: RegexEngine
1931+
timeout: Optional[int]
1932+
stack: Optional[int]
19261933

1927-
def __init__(self, expr: Optional[str], regex_engine: RegexEngine = default_regex_engine) -> None:
1934+
def __init__(self, expr: Optional[str], regex_engine: RegexEngine = default_regex_engine,
1935+
timeout: Optional[int] = None, stack: Optional[int] = None) -> None:
19281936
self.regex_engine = regex_engine
1937+
self.timeout = timeout
1938+
self.stack = stack
19291939
try:
19301940
self.parser = Jsonata.get_parser()
19311941
self.ast = self.parser.parse(expr, regex_engine) # , optionsRecover);
@@ -1936,6 +1946,8 @@ def __init__(self, expr: Optional[str], regex_engine: RegexEngine = default_rege
19361946
# populateMessage(err); // possible side-effects on `err`
19371947
raise err
19381948
self.environment = self.create_frame(Jsonata.static_frame)
1949+
if timeout is not None or stack is not None:
1950+
self.environment.set_runtime_bounds(timeout, stack)
19391951

19401952
self.timestamp = timebox.Timebox.current_milli_time() # will be overridden on each call to evalute()
19411953

src/jsonata/timebox.py

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,30 +21,31 @@
2121
#
2222

2323
import time
24+
from typing import Optional
2425

2526
from jsonata import jexception
2627

2728

2829
#
2930
# Configure max runtime / max recursion depth.
30-
# See Frame.setRuntimeBounds - usually not used directly
31-
#
31+
# See Frame.set_runtime_bounds - usually not used directly
32+
#
3233
class Timebox:
3334
#
34-
# Protect the process/browser from a runnaway expression
35+
# Protect the process from a runaway expression
3536
# i.e. Infinite loop (tail recursion), or excessive stack growth
3637
#
3738
# @param {Object} expr - expression to protect
38-
# @param {Number} timeout - max time in ms
39-
# @param {Number} max_depth - max stack depth
39+
# @param {Number} timeout - max time in ms, or None for no time limit
40+
# @param {Number} max_depth - max stack depth, or None for no depth limit
4041
#
4142

42-
timeout: int
43-
max_depth: int
43+
timeout: Optional[int]
44+
max_depth: Optional[int]
4445
time: int
4546
depth: int
4647

47-
def __init__(self, expr, timeout=10000, max_depth=100):
48+
def __init__(self, expr, timeout: Optional[int] = None, max_depth: Optional[int] = None):
4849
self.timeout = timeout
4950
self.max_depth = max_depth
5051
self.time = Timebox.current_milli_time()
@@ -68,21 +69,12 @@ def exit_callback(exp, input, env, res):
6869
expr.set_evaluate_exit_callback(exit_callback)
6970

7071
def check_runaway(self) -> None:
71-
if self.depth > self.max_depth:
72+
if self.max_depth is not None and self.depth > self.max_depth:
7273
# stack too deep
73-
raise jexception.JException(
74-
"Stack overflow error: Check for non-terminating recursive function. Consider rewriting as tail-recursive. Depth=" + str(
75-
self.depth) + " max=" + str(self.max_depth), -1)
76-
# stack: new Error().stack,
77-
# code: "U1001"
78-
# }
79-
if Timebox.current_milli_time() - self.time > self.timeout:
74+
raise jexception.JException("D1011", -1)
75+
if self.timeout is not None and Timebox.current_milli_time() - self.time > self.timeout:
8076
# expression has run for too long
81-
raise jexception.JException(
82-
"Expression evaluation timeout: " + str(self.timeout) + "ms. Check for infinite loop", -1)
83-
# stack: new Error().stack,
84-
# code: "U1001"
85-
# }
77+
raise jexception.JException("D1012", -1, self.timeout)
8678

8779
@staticmethod
8880
def current_milli_time() -> int:

0 commit comments

Comments
 (0)