| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,9 @@ | |||
| 1 | + Arbitrary code in ``axes.prop_cycle`` rcParam strings | ||
| 2 | + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||
| 3 | + | ||
| 4 | + The ``axes.prop_cycle`` rcParam accepts Python expressions that are evaluated | ||
| 5 | + in a limited context. The evaluation context has been further limited and some | ||
| 6 | + expressions that previously worked (list comprehensions, for example) no longer | ||
| 7 | + will. This change is made without a deprecation period to improve security. | ||
| 8 | + The previously documented cycler operations at | ||
| 9 | + https://matplotlib.org/cycler/ are still supported. | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,14 @@ | |||
| 1 | + ``axes.prop_cycle`` rcParam security improvements | ||
| 2 | + ------------------------------------------------- | ||
| 3 | + | ||
| 4 | + The ``axes.prop_cycle`` rcParam is now parsed in a safer and more restricted | ||
| 5 | + manner. Only literals, ``cycler()`` and ``concat()`` calls, the operators | ||
| 6 | + ``+`` and ``*``, and slicing are allowed. All previously valid cycler strings | ||
| 7 | + documented at https://matplotlib.org/cycler/ are still supported, for example: | ||
| 8 | + | ||
| 9 | + .. code-block:: none | ||
| 10 | + | ||
| 11 | + axes.prop_cycle : cycler('color', ['r', 'g', 'b']) + cycler('linewidth', [1, 2, 3]) | ||
| 12 | + axes.prop_cycle : 2 * cycler('color', 'rgb') | ||
| 13 | + axes.prop_cycle : concat(cycler('color', 'rgb'), cycler('color', 'cmk')) | ||
| 14 | + axes.prop_cycle : cycler('color', 'rgbcmk')[:3] | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -30,7 +30,7 @@ | |||
| 30 | 30 | from matplotlib._enums import JoinStyle, CapStyle | |
| 31 | 31 | ||
| 32 | 32 | # Don't let the original cycler collide with our validating cycler | |
| 33 | - from cycler import Cycler, cycler as ccycler | ||
| 33 | + from cycler import Cycler, concat as cconcat, cycler as ccycler | ||
| 34 | 34 | ||
| 35 | 35 | ||
| 36 | 36 | @_api.caching_module_getattr | |
@@ -759,11 +759,62 @@ def cycler(*args, **kwargs): | |||
| 759 | 759 | return reduce(operator.add, (ccycler(k, v) for k, v in validated)) | |
| 760 | 760 | ||
| 761 | 761 | ||
| 762 | - class _DunderChecker(ast.NodeVisitor): | ||
| 763 | - def visit_Attribute(self, node): | ||
| 764 | - if node.attr.startswith("__") and node.attr.endswith("__"): | ||
| 765 | - raise ValueError("cycler strings with dunders are forbidden") | ||
| 766 | - self.generic_visit(node) | ||
| 762 | + def _parse_cycler_string(s): | ||
| 763 | + """ | ||
| 764 | + Parse a string representation of a cycler into a Cycler object safely, | ||
| 765 | + without using eval(). | ||
| 766 | + | ||
| 767 | + Accepts expressions like:: | ||
| 768 | + | ||
| 769 | + cycler('color', ['r', 'g', 'b']) | ||
| 770 | + cycler('color', 'rgb') + cycler('linewidth', [1, 2, 3]) | ||
| 771 | + cycler(c='rgb', lw=[1, 2, 3]) | ||
| 772 | + cycler('c', 'rgb') * cycler('linestyle', ['-', '--']) | ||
| 773 | + """ | ||
| 774 | + try: | ||
| 775 | + tree = ast.parse(s, mode='eval') | ||
| 776 | + except SyntaxError as e: | ||
| 777 | + raise ValueError(f"Could not parse {s!r}: {e}") from e | ||
| 778 | + return _eval_cycler_expr(tree.body) | ||
| 779 | + | ||
| 780 | + | ||
| 781 | + def _eval_cycler_expr(node): | ||
| 782 | + """Recursively evaluate an AST node to build a Cycler object.""" | ||
| 783 | + if isinstance(node, ast.BinOp): | ||
| 784 | + left = _eval_cycler_expr(node.left) | ||
| 785 | + right = _eval_cycler_expr(node.right) | ||
| 786 | + if isinstance(node.op, ast.Add): | ||
| 787 | + return left + right | ||
| 788 | + if isinstance(node.op, ast.Mult): | ||
| 789 | + return left * right | ||
| 790 | + raise ValueError(f"Unsupported operator: {type(node.op).__name__}") | ||
| 791 | + if isinstance(node, ast.Call): | ||
| 792 | + if not (isinstance(node.func, ast.Name) | ||
| 793 | + and node.func.id in ('cycler', 'concat')): | ||
| 794 | + raise ValueError( | ||
| 795 | + "only the 'cycler()' and 'concat()' functions are allowed") | ||
| 796 | + func = cycler if node.func.id == 'cycler' else cconcat | ||
| 797 | + args = [_eval_cycler_expr(a) for a in node.args] | ||
| 798 | + kwargs = {kw.arg: _eval_cycler_expr(kw.value) for kw in node.keywords} | ||
| 799 | + return func(*args, **kwargs) | ||
| 800 | + if isinstance(node, ast.Subscript): | ||
| 801 | + sl = node.slice | ||
| 802 | + if not isinstance(sl, ast.Slice): | ||
| 803 | + raise ValueError("only slicing is supported, not indexing") | ||
| 804 | + s = slice( | ||
| 805 | + ast.literal_eval(sl.lower) if sl.lower else None, | ||
| 806 | + ast.literal_eval(sl.upper) if sl.upper else None, | ||
| 807 | + ast.literal_eval(sl.step) if sl.step else None, | ||
| 808 | + ) | ||
| 809 | + value = _eval_cycler_expr(node.value) | ||
| 810 | + return value[s] | ||
| 811 | + # Allow literal values (int, strings, lists, tuples) as arguments | ||
| 812 | + # to cycler() and concat(). | ||
| 813 | + try: | ||
| 814 | + return ast.literal_eval(node) | ||
| 815 | + except (ValueError, TypeError): | ||
| 816 | + raise ValueError( | ||
| 817 | + f"Unsupported expression in cycler string: {ast.dump(node)}") | ||
| 767 | 818 | ||
| 768 | 819 | ||
| 769 | 820 | # A validator dedicated to the named legend loc | |
@@ -814,25 +865,11 @@ def _validate_legend_loc(loc): | |||
| 814 | 865 | def validate_cycler(s): | |
| 815 | 866 | """Return a Cycler object from a string repr or the object itself.""" | |
| 816 | 867 | if isinstance(s, str): | |
| 817 | - # TODO: We might want to rethink this... | ||
| 818 | - # While I think I have it quite locked down, it is execution of | ||
| 819 | - # arbitrary code without sanitation. | ||
| 820 | - # Combine this with the possibility that rcparams might come from the | ||
| 821 | - # internet (future plans), this could be downright dangerous. | ||
| 822 | - # I locked it down by only having the 'cycler()' function available. | ||
| 823 | - # UPDATE: Partly plugging a security hole. | ||
| 824 | - # I really should have read this: | ||
| 825 | - # https://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html | ||
| 826 | - # We should replace this eval with a combo of PyParsing and | ||
| 827 | - # ast.literal_eval() | ||
| 828 | 868 | try: | |
| 829 | - _DunderChecker().visit(ast.parse(s)) | ||
| 830 | - s = eval(s, {'cycler': cycler, '__builtins__': {}}) | ||
| 831 | - except BaseException as e: | ||
| 869 | + s = _parse_cycler_string(s) | ||
| 870 | + except Exception as e: | ||
| 832 | 871 | raise ValueError(f"{s!r} is not a valid cycler construction: {e}" | |
| 833 | 872 | ) from e | |
| 834 | - # Should make sure what comes from the above eval() | ||
| 835 | - # is a Cycler object. | ||
| 836 | 873 | if isinstance(s, Cycler): | |
| 837 | 874 | cycler_inst = s | |
| 838 | 875 | else: | |
@@ -1101,7 +1138,7 @@ def _convert_validator_spec(key, conv): | |||
| 1101 | 1138 | "axes.formatter.offset_threshold": validate_int, | |
| 1102 | 1139 | "axes.unicode_minus": validate_bool, | |
| 1103 | 1140 | # This entry can be either a cycler object or a string repr of a | |
| 1104 | - # cycler-object, which gets eval()'ed to create the object. | ||
| 1141 | + # cycler-object, which is parsed safely via AST. | ||
| 1105 | 1142 | "axes.prop_cycle": validate_cycler, | |
| 1106 | 1143 | # If "data", axes limits are set close to the data. | |
| 1107 | 1144 | # If "round_numbers" axes limits are set to the nearest round numbers. | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -272,16 +272,23 @@ def generate_validator_testcases(valid): | |||
| 272 | 272 | cycler('linestyle', ['-', '--'])), | |
| 273 | 273 | (cycler(mew=[2, 5]), | |
| 274 | 274 | cycler('markeredgewidth', [2, 5])), | |
| 275 | + ("2 * cycler('color', 'rgb')", 2 * cycler('color', 'rgb')), | ||
| 276 | + ("2 * cycler('color', 'r' + 'gb')", 2 * cycler('color', 'rgb')), | ||
| 277 | + ("cycler(c='r' + 'gb', lw=[1, 2, 3])", | ||
| 278 | + cycler('color', 'rgb') + cycler('linewidth', [1, 2, 3])), | ||
| 279 | + ("cycler('color', 'rgb') * 2", cycler('color', 'rgb') * 2), | ||
| 280 | + ("concat(cycler('color', 'rgb'), cycler('color', 'cmk'))", | ||
| 281 | + cycler('color', list('rgbcmk'))), | ||
| 282 | + ("cycler('color', 'rgbcmk')[:3]", cycler('color', list('rgb'))), | ||
| 283 | + ("cycler('color', 'rgb')[::-1]", cycler('color', list('bgr'))), | ||
| 275 | 284 | ), | |
| 276 | - # This is *so* incredibly important: validate_cycler() eval's | ||
| 277 | - # an arbitrary string! I think I have it locked down enough, | ||
| 278 | - # and that is what this is testing. | ||
| 279 | - # TODO: Note that these tests are actually insufficient, as it may | ||
| 280 | - # be that they raised errors, but still did an action prior to | ||
| 281 | - # raising the exception. We should devise some additional tests | ||
| 282 | - # for that... | ||
| 285 | + # validate_cycler() parses an arbitrary string using a safe | ||
| 286 | + # AST-based parser (no eval). These tests verify that only valid | ||
| 287 | + # cycler expressions are accepted. | ||
| 283 | 288 | 'fail': ((4, ValueError), # Gotta be a string or Cycler object | |
| 284 | 289 | ('cycler("bleh, [])', ValueError), # syntax error | |
| 290 | + ("cycler('color', 'rgb') * * cycler('color', 'rgb')", # syntax error | ||
| 291 | + ValueError), | ||
| 285 | 292 | ('Cycler("linewidth", [1, 2, 3])', | |
| 286 | 293 | ValueError), # only 'cycler()' function is allowed | |
| 287 | 294 | # do not allow dunder in string literals | |
@@ -295,6 +302,9 @@ def generate_validator_testcases(valid): | |||
| 295 | 302 | ValueError), | |
| 296 | 303 | ("cycler('c', [j.__class__(j).lower() for j in ['r', 'b']])", | |
| 297 | 304 | ValueError), | |
| 305 | + # list comprehensions are arbitrary code, even if "safe" | ||
| 306 | + ("cycler('color', [x for x in ['r', 'g', 'b']])", | ||
| 307 | + ValueError), | ||
| 298 | 308 | ('1 + 2', ValueError), # doesn't produce a Cycler object | |
| 299 | 309 | ('os.system("echo Gotcha")', ValueError), # os not available | |
| 300 | 310 | ('import os', ValueError), # should not be able to import | |
| Back | FazBrowse Home | New Git URL |
0 commit comments