From b7ba43d9d549e37c7b1ed4679eb037efbfd68573 Mon Sep 17 00:00:00 2001 From: uw4 Date: Thu, 8 Oct 2026 17:17:10 +0200 Subject: [PATCH] Backport: Create guardrails to limit resource consumption Original commit: https://github.com/jsonata-js/jsonata/commit/0a7aa358b5f8e7ca0c32889e88704e4b84d5e504 --- .../java/com/dashjoin/jsonata/Functions.java | 36 ++++-- .../java/com/dashjoin/jsonata/Jsonata.java | 105 +++++++++++++++--- src/main/java/com/dashjoin/jsonata/Utils.java | 29 ++++- .../com/dashjoin/jsonata/RuntimeTest.java | 92 ++++++++++++++- 4 files changed, 230 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/dashjoin/jsonata/Functions.java b/src/main/java/com/dashjoin/jsonata/Functions.java index d389bab..a647dc4 100644 --- a/src/main/java/com/dashjoin/jsonata/Functions.java +++ b/src/main/java/com/dashjoin/jsonata/Functions.java @@ -740,7 +740,7 @@ public static List match(String str, Pattern regex, Integer limit) { ); } - var result = Utils.createSequence(); + var result = createSequence(); var matches = evaluateMatcher(regex, str); int max = Integer.MAX_VALUE; if (limit!=null) @@ -1591,7 +1591,7 @@ public static List map(List arr, Object func) throws Throwable { return null; } - List result = Utils.createSequence(); + List result = createSequence(); // do the map - iterate over the arrays, and invoke func for (int i=0; i(); + var results = (arr instanceof JList/*sequence*/) ? createSequence() : new ArrayList<>(); // Create distinct list of elements by adding all to a set, // and then adding the set to the result @@ -2099,7 +2099,7 @@ public static Object append(Object arg1, Object arg2) { // if either argument is not an array, make it so if (!(arg1 instanceof List)) { - arg1 = Utils.createSequence(arg1); + arg1 = createSequence(arg1); } if (!(arg2 instanceof List)) { arg2 = new JList<>(Arrays.asList(arg2)); @@ -2129,7 +2129,7 @@ public static Object lookup(Object input, String key) { Object result = null; if (input instanceof List) { List _input = (List)input; - result = Utils.createSequence(); + result = createSequence(); for(var ii = 0; ii < _input.size(); ii++) { var res = lookup(_input.get(ii), key); if (res != null) { @@ -2392,7 +2392,7 @@ public static Object functionEval(String expr, Object focus) { input = focus; // if the input is a JSON array, then wrap it in a singleton sequence so it gets treated as a single input if((input instanceof List) && !Utils.isSequence(input)) { - input = Utils.createSequence(input); + input = createSequence(input); ((JList)input).outerWrapper = true; } } @@ -2435,4 +2435,20 @@ public static long millis() { long t = Jsonata.current.get().timestamp; return t; } + + /** + * Creates a new sequence with guardrails of the current environment + * @return + */ + public static List createSequence() { + return Jsonata.current.get().environment.base.createSequence(); + } + + /** + * Creates a new sequence with guardrails of the current environment + * @return + */ + public static List createSequence(Object el) { + return Jsonata.current.get().environment.base.createSequence(el); + } } diff --git a/src/main/java/com/dashjoin/jsonata/Jsonata.java b/src/main/java/com/dashjoin/jsonata/Jsonata.java index fab2243..3eb3441 100644 --- a/src/main/java/com/dashjoin/jsonata/Jsonata.java +++ b/src/main/java/com/dashjoin/jsonata/Jsonata.java @@ -72,6 +72,9 @@ public static class Frame { public Frame(Frame enclosingEnvironment) { parent = enclosingEnvironment; + if (enclosingEnvironment!=null) { + base = enclosingEnvironment.base; + } } public void bind(String name, Object val) { @@ -115,6 +118,33 @@ public void setEvaluateEntryCallback(EntryCallback cb) { public void setEvaluateExitCallback(ExitCallback cb) { bind("__evaluate_exit", cb); } + + // Guardrails extensions + public Options options; + + Frame base; + + public int depth; + + public long time; + + public List createSequence() { return createSequence(Utils.NONE); } + + public List createSequence(Object el) { + JList seq = (JList) Utils.__createSequence(el); + if (options!=null && options.sequence!=null) + seq.setMaxSize(options.sequence); + return seq; + } + + public void guardrails() { + if (options!=null && options.stack!=null && depth>options.stack) { + throw new JException("D1011", -1, options.stack); + } + if (options!=null && options.timeout!=null && System.currentTimeMillis()-time > options.timeout) { + throw new JException("D1012", -1, options.timeout); + } + } } static Frame staticFrame;// = createFrame(null); @@ -152,6 +182,8 @@ Object _evaluate(Symbol expr, Object input, Frame environment) { if (parser.dbg) System.out.println("eval expr="+expr+" type="+expr.type);//+" input="+input); + environment.base.depth++; + environment.base.guardrails(); var entryCallback = environment.lookup("__evaluate_entry"); if(entryCallback!=null) { ((EntryCallback)entryCallback).callback(expr, input, environment); @@ -245,6 +277,8 @@ Object _evaluate(Symbol expr, Object input, Frame environment) { } } + environment.base.depth--; + return result; } @@ -264,7 +298,7 @@ Object _evaluate(Symbol expr, Object input, Frame environment) { inputSequence = (List)input; } else { // if input is not an array, make it so - inputSequence = Utils.createSequence(input); + inputSequence = environment.base.createSequence(input); } Object resultSequence = null; @@ -305,7 +339,7 @@ Object _evaluate(Symbol expr, Object input, Frame environment) { // tuple stream is carrying ancestry information - keep this resultSequence = tupleBindings; } else { - resultSequence = Utils.createSequence(); + resultSequence = environment.base.createSequence(); for (int ii = 0; ii < tupleBindings.size(); ii++) { ((List)resultSequence).add(tupleBindings.get(ii).get("@")); } @@ -320,7 +354,7 @@ Object _evaluate(Symbol expr, Object input, Frame environment) { // if the array is explicitly constructed in the expression and marked to promote singleton sequences to array if((resultSequence instanceof JList) && ((JList)resultSequence).cons && !((JList)resultSequence).sequence) { - resultSequence = Utils.createSequence(resultSequence); + resultSequence = environment.base.createSequence(resultSequence); } ((JList)resultSequence).keepSingleton = true; } @@ -358,7 +392,7 @@ Frame createFrameFromTuple(Frame environment, Map tuple) { return result; } - result = Utils.createSequence(); + result = environment.base.createSequence(); for(var ii = 0; ii < ((List)input).size(); ii++) { var res = /* await */ evaluate(expr, ((List)input).get(ii), environment); @@ -372,7 +406,7 @@ Frame createFrameFromTuple(Frame environment, Map tuple) { } } - var resultSequence = Utils.createSequence(); + var resultSequence = environment.base.createSequence(); if(lastStep && ((List)result).size()==1 && (((List)result).get(0) instanceof List) && !Utils.isSequence(((List)result).get(0))) { resultSequence = (List) ((List) result).get(0); } else { @@ -425,7 +459,7 @@ Frame createFrameFromTuple(Frame environment, Map tuple) { result = (List) /* await */ evaluateSortExpression(expr, tupleBindings, environment); } else { List sorted = (List) /* await */ evaluateSortExpression(expr, input, environment); - result = Utils.createSequence(); + result = environment.base.createSequence(); ((JList)result).tupleStream = true; for(var ss = 0; ss < ((List)sorted).size(); ss++) { var tuple = Map.of("@", sorted.get(ss), @@ -439,7 +473,7 @@ Frame createFrameFromTuple(Frame environment, Map tuple) { return result; } - result = Utils.createSequence(); + result = environment.base.createSequence(); ((JList)result).tupleStream = true; var stepEnv = environment; if(tupleBindings == null) { @@ -498,12 +532,12 @@ Frame createFrameFromTuple(Frame environment, Map tuple) { */ /* async */ Object evaluateFilter(Object _predicate, Object input, Frame environment) { Symbol predicate = (Symbol)_predicate; - var results = Utils.createSequence(); + var results = environment.base.createSequence(); if( input instanceof JList && ((JList)input).tupleStream) { ((JList)results).tupleStream = true; } if (!(input instanceof List)) { // isArray - input = Utils.createSequence(input == null ? Utils.NONE : input); + input = environment.base.createSequence(input == null ? Utils.NONE : input); } if (predicate.type.equals("number")) { var index = ((Number)predicate.value).intValue(); // round it down - was Math.floor @@ -534,7 +568,7 @@ Frame createFrameFromTuple(Frame environment, Map tuple) { } var res = /* await */ evaluate(predicate, context, env); if (Utils.isNumeric(res)) { - res = Utils.createSequence(res); + res = environment.base.createSequence(res); } if (Utils.isArrayOfNumbers(res)) { for (Object ires : ((List)res)) { @@ -717,7 +751,7 @@ Object evaluateLiteral(Symbol expr) { * @returns {*} Evaluated input data */ Object evaluateWildcard(Symbol expr, Object input) { - var results = Utils.createSequence(); + var results = environment.base.createSequence(); if ((input instanceof JList) && ((JList)input).outerWrapper && ((JList)input).size() > 0) { input = ((JList)input).get(0); } @@ -776,7 +810,7 @@ Object flatten(Object arg, List flattened) { */ Object evaluateDescendants(Symbol expr, Object input) { Object result = null; - var resultSequence = Utils.createSequence(); + var resultSequence = environment.base.createSequence(); if (input != null) { // traverse all descendants of this object/array recurseDescendants(input, resultSequence); @@ -1066,7 +1100,7 @@ static class GroupEntry { var reduce = (_input instanceof JList) && ((JList)_input).tupleStream ? true : false; // group the input sequence by "key" expression if (!(_input instanceof List)) { - _input = Utils.createSequence(_input); + _input = environment.base.createSequence(_input); } List input = (List)_input; @@ -1218,7 +1252,12 @@ Object evaluateRangeExpression(Object lhs, Object rhs) { size ); } - + if(environment.base.options!=null && size > environment.base.options.sequence) { + throw new JException("D2015", + -1, + size + ); + } return new Utils.RangeList(_lhs, _rhs); } @@ -2403,6 +2442,8 @@ static void registerFunctions() { put("T1008", "Attempted to partially apply a non-function"); put("D1009", "Multiple key definitions evaluate to same key: {{value}}"); put("T1010", "The matcher Object argument passed to Object {{token}} does not return the correct object structure"); + put("D1011", "Stack overflow. Check for non-terminating recursive function. Consider rewriting as tail-recursive"); + put("D1012", "Evaluation timeout after {{value}} milliseconds. Check for infinite loop"); put("T2001", "The left side of the {{token}} operator must evaluate to a number"); put("T2002", "The right side of the {{token}} operator must evaluate to a number"); put("T2003", "The left side of the range operator (..) must evaluate to an integer"); @@ -2416,7 +2457,8 @@ static void registerFunctions() { put("T2011", "The insert/update clause of the transform expression must evaluate to an object: {{value}}"); put("T2012", "The delete clause of the transform expression must evaluate to a string or array of strings: {{value}}"); put("T2013", "The transform expression clones the input object using the $clone() function. This has been overridden in the current scope by a non-function."); - put("D2014", "The size of the sequence allocated by the range operator (..) must not exceed 1e6. Attempted to allocate {{value}}."); + put("D2014", "The size of the sequence allocated by the range operator (..) must not exceed 1e7. Attempted to allocate {{value}}."); + put("D2015", "The maximum sequence length of {{value}} was exceeded."); put("D2016", "The size of the string requested by the $pad function must not exceed 1e7. Attempted to allocate {{value}}."); put("D3001", "Attempting to invoke string Object on Infinity or NaN"); put("D3010", "Second argument of replace Object cannot be an empty string"); @@ -2504,14 +2546,35 @@ Exception populateMessage(Exception err) { * @throws JException An exception if an error occured. */ public static Jsonata jsonata(String expression) { - return new Jsonata(expression); + return new Jsonata(expression, null); + } + public static Jsonata jsonata(String expression, Options options) { + return new Jsonata(expression, options); } + public static class Options { + public Long timeout; + public Integer stack; + public Integer sequence; + + public static OptionsBuilder builder() { return new OptionsBuilder(); } + public static class OptionsBuilder { + private Options options = new Options(); + public OptionsBuilder timeout(Long timeout) { options.timeout = timeout; return this; } + public OptionsBuilder stack(Integer stack) { options.stack = stack; return this; } + public OptionsBuilder sequence(Integer sequence) { options.sequence = sequence; return this; } + public Options build() { Options ret = options; options=null; return ret; } + } + } + + Options options; + /** * Internal constructor * @param expr */ - Jsonata(String expr) { // boolean optionsRecover) { + Jsonata(String expr, Options options) { // boolean optionsRecover) { + this.options = options; try { ast = parser.parse(expr);//, optionsRecover); errors = ast.errors; @@ -2635,13 +2698,19 @@ public Object evaluate(Object input, Frame bindings) { // FIXME:, callback) { // the $now() and $millis() functions will return this value - whenever it is called timestamp = System.currentTimeMillis(); //exec_env.timestamp = timestamp; + exec_env.time = timestamp; + exec_env.options = options; // if the input is a JSON array, then wrap it in a singleton sequence so it gets treated as a single input if((input instanceof List) && !Utils.isSequence(input)) { - input = Utils.createSequence(input); + input = exec_env.createSequence(input); ((JList)input).outerWrapper = true; } + // Porting JS to Java note: guardrails function is in Java class Frame + exec_env.base = exec_env; + exec_env.depth = 0; + if (validateInput) Functions.validateInput(input); diff --git a/src/main/java/com/dashjoin/jsonata/Utils.java b/src/main/java/com/dashjoin/jsonata/Utils.java index 60b9360..9c5e1c3 100644 --- a/src/main/java/com/dashjoin/jsonata/Utils.java +++ b/src/main/java/com/dashjoin/jsonata/Utils.java @@ -74,9 +74,9 @@ public static boolean isFunction(Object o) { * Create an empty sequence to contain query results * @returns {Array} - empty sequence */ - public static List createSequence() { return createSequence(NONE); } + public static List __createSequence() { return __createSequence(NONE); } - public static List createSequence(Object el) { + public static List __createSequence(Object el) { JList sequence = new JList<>(); sequence.sequence = true; if (el!=NONE) { @@ -102,6 +102,31 @@ public JList(Collection c) { public boolean keepSingleton; public boolean cons; + + protected int maxSize = Integer.MAX_VALUE; + + // Limit the max size of sequences + public void setMaxSize(int _maxSize) { + maxSize = _maxSize; + } + + public int getMaxSize() { + return maxSize; + } + + @Override + public boolean add(E e) { + if (size()>=maxSize) + throw new JException("D2015", -1, maxSize); + return super.add(e); + } + + @Override + public boolean addAll(Collection c) { + if (size()+c.size()>maxSize) + throw new JException("D2015", -1, maxSize); + return super.addAll(c); + } } public static boolean isSequence(Object result) { diff --git a/src/test/java/com/dashjoin/jsonata/RuntimeTest.java b/src/test/java/com/dashjoin/jsonata/RuntimeTest.java index dcc1bfe..b89857c 100644 --- a/src/test/java/com/dashjoin/jsonata/RuntimeTest.java +++ b/src/test/java/com/dashjoin/jsonata/RuntimeTest.java @@ -3,8 +3,6 @@ import static com.dashjoin.jsonata.Jsonata.jsonata; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -import com.dashjoin.jsonata.Jsonata.Frame; -import com.dashjoin.jsonata.Parser.Symbol; public class RuntimeTest { @@ -40,4 +38,94 @@ public void testCallbacks() { expr.evaluate(null, frame); Assertions.assertTrue(exited && entered); } + + @Test + public void testGuardrails() { + { + var options = Jsonata.Options.builder() + .stack(2) + .build(); + var expr = jsonata("(" + + "$a := function(){42};" + + "$b := function(){$a()};" + + "$c := function(){$b()};" + + "$a() )", options); + + Assertions.assertThrows(JException.class, () -> expr.evaluate(null)); + } + + { + var options = Jsonata.Options.builder() + .stack(3) + .build(); + var expr = jsonata("(" + + "$a := function(){42};" + + "$b := function(){$a()};" + + "$c := function(){$b()};" + + "$a() )", options); + + Assertions.assertEquals(42, expr.evaluate(null)); + } + + } + + @Test + public void testGuardrails1a() { + // stack overflow - infinite recursive function - non-tail call + var options = Jsonata.Options.builder() + .timeout(1000L) + .stack(300) + .build(); + var expr = jsonata("($inf := function($n){$n+$inf($n-1)}; $inf(5))", options); + Assertions.assertThrows(JException.class, () -> expr.evaluate(null)); + } + + @Test + public void testGuardrails1b() { + // stack overflow - infinite recursive function - tail call (no stack guardrail) + var options = Jsonata.Options.builder() + .timeout(1000L) + .build(); + var expr = jsonata("( $inf := function(){$inf()}; $inf())", options); + Assertions.assertThrows(JException.class, () -> expr.evaluate(null)); + } + + @Test + public void testGuardrails2() { + // guardrails on Ackermann function + var acker = "( $ack := function($m, $n) { $m = 0 ? $n + 1 : $n = 0 ? $ack($m - 1, 1) : $ack($m - 1, $ack($m, $n - 1)) }; "; + + { + // should complete for small parameters + var options = Jsonata.Options.builder() + .timeout(1000L) + .stack(300) + .build(); + var expr = jsonata(acker + " $ack(3,4))", options); + Assertions.assertEquals(125, expr.evaluate(null)); + } + + { + // larger inputs cause stack overflow + var options = Jsonata.Options.builder() + .stack(500) + .build(); + var expr = jsonata(acker + " $ack(4,4))", options); + Assertions.assertThrows(JException.class, () -> expr.evaluate(null)); + } + } + + @Test + public void testGuardrails3() { + // guardrails on sequence length + var options = Jsonata.Options.builder() + .sequence(1000) + .build(); + var expr = jsonata("[1..1001]", options); + Assertions.assertThrows(JException.class, () -> expr.evaluate(null)); + + // prevents large intermediate sequences + var expr2 = jsonata("[0..100].([0..100]) ~> $count()", options); + Assertions.assertThrows(JException.class, () -> expr2.evaluate(null)); + } }