Skip to content

Commit 0a7aa35

Browse files
Create guardrails to limit resource consumption (#795)
Signed-off-by: Andrew Coleman <andrew_coleman@uk.ibm.com>
1 parent 4c5f4ad commit 0a7aa35

7 files changed

Lines changed: 445 additions & 128 deletions

File tree

docs/embedding-extending.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ sidebar_label: Embedding and Extending JSONata
66

77
## API
88

9-
### jsonata(str)
9+
### jsonata(str[, options])
1010

1111
Parse a string `str` as a JSONata expression and return a compiled JSONata expression object.
1212

13+
`options`, if present, is used to control certain aspects of the evaluator, and can be used to protect the server from expressions that take longer to execute than expected. See [Configuring Guardrails](guardrails) for more details.
14+
1315
```javascript
1416
var expression = jsonata("$sum(example.value)");
1517
```

docs/guardrails.md

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
---
2+
id: guardrails
3+
title: Configuring Guardrails
4+
sidebar_label: Configuring Guardrails
5+
---
6+
7+
## Guardrails
8+
9+
This page contains information relating to the JavaScript [reference implementation](https://github.com/jsonata-js/jsonata) of JSONata, and not the JSONata expression language itself.
10+
11+
JSONata is a Turing-complete expression language, and as such, it is possible to write unbounded, or infinite loops. This can be a potential problem if an application using JSONata is exposing the ability for client users to input expressions that are evaluated on the server. A user could accidently or maliciously provide an expression that, if evaluated unchecked, could cause a denial of service situation.
12+
13+
This JSONata library provides a set of configurable 'guardrails' that limit the compute and memory resources that a single expression can consume. If this library is being used in a hosted environment to allow end users to provide their own expressions, then it would be prudent to set constraints. The following sections describe each of the guardrails and how to configure them. It does not provide recommended values or defaults.
14+
15+
### Stack overflow
16+
17+
In common with other functional languages, JSONata supports looping by writing [recursive functions](https://en.wikipedia.org/wiki/Functional_programming#Recursion). The JSONata evaluator processes an expression using a set of mutually recursive functions (eval-apply cycle). When a function is invoked (by itself or by another function), the call stack in the host JavaScript runtime will grow. If this stack grows too deep, evaluator could exhaust the memory of the host process causing it to crash.
18+
19+
The JSONata evaluator can be configured with a maximum stack[^stack] limit to prevent an expression from doing this by specifying the `stack` option. Error `D1011` will be thrown if the expression grows the stack beyond the specified limit.
20+
21+
```javascript
22+
const jsonata = require('jsonata');
23+
24+
const data = {JSON: data};
25+
const options = {
26+
stack: 500
27+
};
28+
29+
(async () => {
30+
const expression = jsonata('<JSONata expression>', options);
31+
const result = await expression.evaluate(data);
32+
})()
33+
```
34+
35+
36+
As an example, the [Ackermann function](https://en.wikipedia.org/wiki/Ackermann_function) could be implemented in JSONata using:
37+
38+
```
39+
(
40+
$ack := function($m, $n) {
41+
$m = 0 ? $n + 1 :
42+
$n = 0 ? $ack($m - 1, 1) :
43+
$ack($m - 1, $ack($m, $n - 1))
44+
};
45+
46+
$ack(3, 4)
47+
)
48+
```
49+
50+
Invoked as `$ack(3, 4)` would quickly evaluate to `125`. However, `$ack(4, 3)`, although theoretically computable, will readily hit the configured stack guardrail before causing any problems to the host server.
51+
52+
[^stack]: The term 'stack' is a slight misnomer here; it actually limits the number of times round the eval-apply cycle, which is related to the JavaScript stack depth.
53+
54+
### Excessive execution time
55+
56+
It's possible (and desirable) to write [tail recursive](programming#tail-call-optimization-tail-recursion) functions that don't grow the stack at all. For these types of functions, a [stack guardrail](#stack-overflow) would not be sufficient to protect against unbounded loops.
57+
58+
The JSONata evaluator can be configured with a maximum time limit to protect against runaway expressions by specifying the `timeout` option. Error `D1012` will be thrown if the expression runs for longer than the specified timeout (in milliseconds).
59+
60+
It's good practice to specify both `stack` and `timeout`.
61+
62+
```javascript
63+
const jsonata = require('jsonata');
64+
65+
const data = {JSON: data};
66+
const options = {
67+
stack: 500,
68+
timeout: 1000 // in milliseconds
69+
};
70+
71+
(async () => {
72+
const expression = jsonata('<JSONata expression>', options);
73+
const result = await expression.evaluate(data);
74+
})()
75+
```
76+
77+
As an example, an infinite loop could be written in JSONata:
78+
79+
```
80+
(
81+
$inf := function() {
82+
$inf()
83+
};
84+
85+
$inf()
86+
)
87+
```
88+
89+
This is tail recursive, and would run forever without the timeout guardrail.
90+
91+
### Excessive sequence length
92+
93+
It's possible to write expressions that result in excessively long result sequences. This could ultimately lead to memory exhaustion in the host server. The `sequence` option can be set to specify the maximum sequence length that can be created by an expression, including any intermediate sequences created by sub-expressions. Error `D2015` will be thrown if, during the evaluation of an expression, the evaluator attempts to generate a sequence exceeding this upper limit.
94+
95+
96+
```javascript
97+
const jsonata = require('jsonata');
98+
99+
const data = {JSON: data};
100+
const options = {
101+
sequence: 1e6 // maximum of one million items in a sequence
102+
};
103+
104+
(async () => {
105+
const expression = jsonata('<JSONata expression>', options);
106+
const result = await expression.evaluate(data);
107+
})()
108+
```
109+
110+
As an example, the following JSONata expression attempts to generate a sequence of 100 million numbers. The guardrail configured above would prevent this.
111+
112+
```
113+
[1..10000].([1..10000])
114+
```
115+
116+
### Rogue regular expressions
117+
118+
A number of functions use [regular expressions](regex) to process strings. Alongside the power and flexibility that regexes provide, there are situations whereby badly crafted or malicious expressions could cause the processing engine take an [excessive amount of time](https://en.wikipedia.org/wiki/ReDoS) (exponential to the input string length). Since the regex processing is not implemented in the core JSONata (eval-apply) evaluator, the `timeout` guardrail cannot protect against this.
119+
120+
It is possible to specify which regex processor is invoked by the JSONata evaluator. This is configured using the `RegexEngine` option. When this is not set, the evaluator will use the default JavaScript [RegExp](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp) class.
121+
122+
The [packaged version of JSONata](https://www.npmjs.com/package/jsonata) has no runtime dependencies on other packages, but it is possible to use the `RegexEngine` option to invoke a third-party ReDoS library whenever a regular expression is encountered in a JSONata expression.
123+
124+
The following code shows how this is done using the [redos-detector](https://github.com/tjenkinson/redos-detector) module:
125+
126+
```javascript
127+
const jsonata = require('jsonata');
128+
const redos = require('redos-detector');
129+
130+
// Simple wrapper that invokes redos-detector before delegating
131+
// to built-in RegExp class
132+
const SafeRegExp = function(regex) {
133+
if (!redos.isSafe(regex).safe) {
134+
throw {
135+
code: 'U1001',
136+
stack: (new Error()).stack,
137+
value: regex,
138+
message: 'Rejecting regex (potential ReDoS): ' + regex
139+
};
140+
}
141+
this.regex = regex;
142+
};
143+
144+
SafeRegExp.prototype.exec = function(str) {
145+
return this.regex.exec(str);
146+
}
147+
148+
const data = {JSON: data};
149+
const options = {
150+
RegexEngine: SafeRegExp
151+
};
152+
153+
(async () => {
154+
const expression = jsonata('<JSONata expression>', options);
155+
const result = await expression.evaluate(data);
156+
})()
157+
```
158+
159+
Other similar libraries are available. This is not an endorsement of any particular one. The developer should choose one according to their requirements.

src/functions.js

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ const functions = (() => {
1212
var isNumeric = utils.isNumeric;
1313
var isArrayOfStrings = utils.isArrayOfStrings;
1414
var isArrayOfNumbers = utils.isArrayOfNumbers;
15-
var createSequence = utils.createSequence;
1615
var isSequence = utils.isSequence;
1716
var isFunction = utils.isFunction;
1817
var isLambda = utils.isLambda;
@@ -382,7 +381,7 @@ const functions = (() => {
382381
};
383382
}
384383

385-
var result = createSequence();
384+
var result = this.createSequence();
386385

387386
if (typeof limit === 'undefined' || limit > 0) {
388387
var count = 0;
@@ -1490,7 +1489,7 @@ const functions = (() => {
14901489
return undefined;
14911490
}
14921491

1493-
var result = createSequence();
1492+
var result = this.createSequence();
14941493
// do the map - iterate over the arrays, and invoke func
14951494
for (var i = 0; i < arr.length; i++) {
14961495
var func_args = hofFuncArgs(func, arr[i], i, arr);
@@ -1516,7 +1515,7 @@ const functions = (() => {
15161515
return undefined;
15171516
}
15181517

1519-
var result = createSequence();
1518+
var result = this.createSequence();
15201519

15211520
for (var i = 0; i < arr.length; i++) {
15221521
var entry = arr[i];
@@ -1659,18 +1658,18 @@ const functions = (() => {
16591658
* @returns {Array} Array of keys
16601659
*/
16611660
function keys(arg) {
1662-
var result = createSequence();
1661+
var result = this.createSequence();
16631662

16641663
if (Array.isArray(arg)) {
16651664
// merge the keys of all of the items in the array
16661665
var merge = {};
1667-
arg.forEach(function (item) {
1668-
var allkeys = keys(item);
1666+
for(var ii = 0; ii < arg.length; ii++) {
1667+
var allkeys = keys.call(this, arg[ii]);
16691668
allkeys.forEach(function (key) {
16701669
merge[key] = true;
16711670
});
1672-
});
1673-
result = keys(merge);
1671+
}
1672+
result = keys.call(this, merge);
16741673
} else if (arg !== null && typeof arg === 'object' && !isFunction(arg)) {
16751674
Object.keys(arg).forEach(key => result.push(key));
16761675
}
@@ -1687,9 +1686,9 @@ const functions = (() => {
16871686
// lookup the 'name' item in the input
16881687
var result;
16891688
if (Array.isArray(input)) {
1690-
result = createSequence();
1689+
result = this.createSequence();
16911690
for(var ii = 0; ii < input.length; ii++) {
1692-
var res = lookup(input[ii], key);
1691+
var res = lookup.call(this, input[ii], key);
16931692
if (typeof res !== 'undefined') {
16941693
if (Array.isArray(res)) {
16951694
res.forEach(val => result.push(val));
@@ -1720,7 +1719,7 @@ const functions = (() => {
17201719
}
17211720
// if either argument is not an array, make it so
17221721
if (!Array.isArray(arg1)) {
1723-
arg1 = createSequence(arg1);
1722+
arg1 = this.createSequence(arg1);
17241723
}
17251724
if (!Array.isArray(arg2)) {
17261725
arg2 = [arg2];
@@ -1747,13 +1746,13 @@ const functions = (() => {
17471746
* @returns {*} - the array
17481747
*/
17491748
function spread(arg) {
1750-
var result = createSequence();
1749+
var result = this.createSequence();
17511750

17521751
if (Array.isArray(arg)) {
17531752
// spread all of the items in the array
1754-
arg.forEach(function (item) {
1755-
result = append(result, spread(item));
1756-
});
1753+
for(var ii = 0; ii < arg.length; ii++) {
1754+
result = append.call(this, result, spread.call(this, arg[ii]));
1755+
}
17571756
} else if (arg !== null && typeof arg === 'object' && !isLambda(arg)) {
17581757
for (var key in arg) {
17591758
var obj = {};
@@ -1819,7 +1818,7 @@ const functions = (() => {
18191818
* @returns {Array} - the resultant array
18201819
*/
18211820
async function each(obj, func) {
1822-
var result = createSequence();
1821+
var result = this.createSequence();
18231822

18241823
for (var key in obj) {
18251824
var func_args = hofFuncArgs(func, obj[key], key, obj);
@@ -2020,7 +2019,7 @@ const functions = (() => {
20202019
return arr;
20212020
}
20222021

2023-
var results = isSequence(arr) ? createSequence() : [];
2022+
var results = isSequence(arr) ? this.createSequence() : [];
20242023

20252024
for(var ii = 0; ii < arr.length; ii++) {
20262025
var value = arr[ii];

0 commit comments

Comments
 (0)