diff --git a/CHANGES b/CHANGES index 6583363..1758e68 100644 --- a/CHANGES +++ b/CHANGES @@ -57,6 +57,12 @@ plusminus Change Log A customizable parser attribute maximum_formula_depth will limit the number of formula indirections. The default value is 12. + - An attack may try to define too many variables and crash an application + by consuming excessive memory. A value to limit the number of variables and + their respective memory usage was previously hard-coded. These are now + part of the public API for parsers: max_number_of_vars (default = 1000) + and max_var_memory (default = 10MB). + 0.2.0 - diff --git a/plusminus/plusminus.py b/plusminus/plusminus.py index ce38ec5..a19c851 100644 --- a/plusminus/plusminus.py +++ b/plusminus/plusminus.py @@ -387,8 +387,6 @@ class ArithmeticParser: LEFT = pp.opAssoc.LEFT RIGHT = pp.opAssoc.RIGHT - MAX_VARS = 1000 - MAX_VAR_MEMORY = 10 ** 6 def usage(self): import textwrap @@ -539,6 +537,9 @@ def __repr__(self): return "{}({})".format(self.tokens[0], ", ".join(map(repr, self.tokens[1:]))) def __init__(self): + self.max_number_of_vars = 1000 + self.max_var_memory = 10 ** 6 + self._added_operator_specs = [] self._added_function_specs = {} self._base_operators = ( @@ -951,7 +952,7 @@ def eval_and_store_value(tokens): var_name = lhs_name.name if ( var_name not in assigned_vars - and len(assigned_vars) >= self.MAX_VARS + and len(assigned_vars) >= self.max_number_of_vars ): raise Exception("too many variables defined") assigned_vars[var_name] = rval @@ -1024,8 +1025,8 @@ def get_depth(formula_node): assigned_vars = identifier_node_class._assigned_vars if ( dest_var_name not in assigned_vars - and len(assigned_vars) >= self.MAX_VARS - or sum(sys.getsizeof(vv) for vv in assigned_vars.values()) > self.MAX_VAR_MEMORY + and len(assigned_vars) >= self.max_number_of_vars + or sum(sys.getsizeof(vv) for vv in assigned_vars.values()) > self.max_var_memory ): raise Exception("too many variables defined") assigned_vars[dest_var_name] = rval diff --git a/test/arith_tests.py b/test/arith_tests.py index af76359..6608e29 100644 --- a/test/arith_tests.py +++ b/test/arith_tests.py @@ -244,7 +244,9 @@ def post_parse_evaluate(teststr, result): print('circle_area =', parser['circle_area']) print('circle_area =', parser.evaluate('circle_area')) +print("del parser['circle_radius']") del parser['circle_radius'] + try: print('circle_area =', end=' ') print(parser.evaluate('circle_area')) @@ -254,9 +256,9 @@ def post_parse_evaluate(teststr, result): print(parser.parse("6.02e24 * 100").evaluate()) - parser = CombinatoricsArithmeticParser() parser.runTests("""\ + # CombinatoricsArithmeticParser 3! -3! 3!! @@ -272,6 +274,7 @@ def post_parse_evaluate(teststr, result): parser = BusinessArithmeticParser() parser.runTests("""\ + # BusinessArithmeticParser 25% 20 * 50% 50% of 20 @@ -284,6 +287,7 @@ def post_parse_evaluate(teststr, result): """, postParse=post_parse_evaluate) + from datetime import datetime class DateTimeArithmeticParser(ArithmeticParser): SECONDS_PER_MINUTE = 60 @@ -302,8 +306,10 @@ def customize(self): microsecond=0).timestamp()) self.add_function('str', 1, lambda dt: str(datetime.fromtimestamp(dt))) + parser = DateTimeArithmeticParser() parser.runTests("""\ + # DateTimeArithmeticParser now() str(now()) str(today()) @@ -315,55 +321,10 @@ def customize(self): parser = DiceRollParser() -parser.runTests(""" -d20 -3d6 -d20 + 3d4 -(3d6)/3 -""", postParse=post_parse_evaluate) - - -print() - - -# override max number of variables -class restore: - """ - Context manager for restoring an object's attributes back the way they were if they were - changed or deleted, or to remove any attributes that were added. - """ - def __init__(self, obj, *attr_names): - self._obj = obj - self._attrs = attr_names - if not self._attrs: - self._attrs = [name for name in vars(obj) if name not in ('__dict__', '__slots__')] - self._no_attr_value = object() - self._save_values = {} - - def __enter__(self): - for attr in self._attrs: - self._save_values[attr] = getattr(self._obj, attr, self._no_attr_value) - return self - - def __exit__(self, *args): - for attr in self._attrs: - save_value = self._save_values[attr] - if save_value is not self._no_attr_value: - if getattr(self._obj, attr, self._no_attr_value) != save_value: - print("reset", attr, "to", save_value) - setattr(self._obj, attr, save_value) - else: - if hasattr(self._obj, attr): - delattr(self._obj, attr) - - -print('test defining too many vars (set max to 20)') -with restore(ArithmeticParser): - ArithmeticParser.MAX_VARS = 20 - parser = ArithmeticParser() - try: - for i in range(1000): - parser.evaluate("a{} = 0".format(i)) - except Exception as e: - print(len(parser.vars()), ArithmeticParser.MAX_VARS) - print("{}: {}".format(type(e).__name__, e)) +parser.runTests("""\ + # DiceRollParser + d20 + 3d6 + d20 + 3d4 + (3d6)/3 + """, postParse=post_parse_evaluate) diff --git a/test/test_unit.py b/test/test_unit.py index feef0f2..0b23ba5 100644 --- a/test/test_unit.py +++ b/test/test_unit.py @@ -222,3 +222,17 @@ def test_maximum_formula_depth(self, basic_arithmetic_parser): with pytest.raises(OverflowError): basic_arithmetic_parser.parse("e @= f") + def test_max_number_of_vars(self, basic_arithmetic_parser): + VAR_LIMIT = 20 + basic_arithmetic_parser.max_number_of_vars = VAR_LIMIT + + # compute number of vars that are safe to define by subtracting + # the number of predefined vars from the allowed limit + vars_to_define = VAR_LIMIT - len(basic_arithmetic_parser.vars()) + for i in range(vars_to_define): + basic_arithmetic_parser.evaluate("a{} = 0".format(i)) + + # now define one more, which should put us over the limit and raise + # the exception + with pytest.raises(Exception): + basic_arithmetic_parser.evaluate("a{} = 0".format(VAR_LIMIT))