|
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. |
|
0 commit comments