From 27e34b0173c015edc904e51bc7c28f3e14439988 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Fri, 6 Mar 2026 16:21:49 -0700 Subject: [PATCH 1/9] Remove eval() from validate_cycler, which might allow code execution through a malicious matplotlibrc --- lib/matplotlib/rcsetup.py | 61 +++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index e56da5200386..f6487996a0b4 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -815,11 +815,44 @@ def cycler(*args, **kwargs): return reduce(operator.add, (ccycler(k, v) for k, v in validated)) -class _DunderChecker(ast.NodeVisitor): - def visit_Attribute(self, node): - if node.attr.startswith("__") and node.attr.endswith("__"): - raise ValueError("cycler strings with dunders are forbidden") - self.generic_visit(node) +def _parse_cycler_string(s): + """ + Parse a string representation of a cycler into a Cycler object safely, + without using eval(). + + Accepts expressions like:: + + cycler('color', ['r', 'g', 'b']) + cycler('color', 'rgb') + cycler('linewidth', [1, 2, 3]) + cycler(c='rgb', lw=[1, 2, 3]) + cycler('c', 'rgb') * cycler('linestyle', ['-', '--']) + """ + try: + tree = ast.parse(s, mode='eval') + except SyntaxError as e: + raise ValueError(f"Could not parse {s!r}: {e}") from e + return _eval_cycler_expr(tree.body) + + +def _eval_cycler_expr(node): + """Recursively evaluate an AST node to build a Cycler object.""" + if isinstance(node, ast.BinOp): + left = _eval_cycler_expr(node.left) + right = _eval_cycler_expr(node.right) + if isinstance(node.op, ast.Add): + return left + right + if isinstance(node.op, ast.Mult): + return left * right + raise ValueError(f"Unsupported operator: {type(node.op).__name__}") + if isinstance(node, ast.Call): + if not (isinstance(node.func, ast.Name) and node.func.id == 'cycler'): + raise ValueError("only the 'cycler()' function is allowed") + args = [ast.literal_eval(ast.unparse(a)) for a in node.args] + kwargs = {kw.arg: ast.literal_eval(ast.unparse(kw.value)) + for kw in node.keywords} + return cycler(*args, **kwargs) + raise ValueError( + f"Unsupported expression in cycler string: {ast.dump(node)}") # A validator dedicated to the named legend loc @@ -870,25 +903,11 @@ def _validate_legend_loc(loc): def validate_cycler(s): """Return a Cycler object from a string repr or the object itself.""" if isinstance(s, str): - # TODO: We might want to rethink this... - # While I think I have it quite locked down, it is execution of - # arbitrary code without sanitation. - # Combine this with the possibility that rcparams might come from the - # internet (future plans), this could be downright dangerous. - # I locked it down by only having the 'cycler()' function available. - # UPDATE: Partly plugging a security hole. - # I really should have read this: - # https://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html - # We should replace this eval with a combo of PyParsing and - # ast.literal_eval() try: - _DunderChecker().visit(ast.parse(s)) - s = eval(s, {'cycler': cycler, '__builtins__': {}}) - except BaseException as e: + s = _parse_cycler_string(s) + except Exception as e: raise ValueError(f"{s!r} is not a valid cycler construction: {e}" ) from e - # Should make sure what comes from the above eval() - # is a Cycler object. if isinstance(s, Cycler): cycler_inst = s else: From 9e7e141de2b72d696b6b9bbd1b347d90be452045 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Fri, 6 Mar 2026 17:38:53 -0700 Subject: [PATCH 2/9] Test that validate_cycler does not execute code passed in --- lib/matplotlib/rcsetup.py | 2 +- lib/matplotlib/tests/test_rcparams.py | 18 +++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index f6487996a0b4..89050ff91c0f 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1179,7 +1179,7 @@ def _convert_validator_spec(key, conv): "axes.formatter.offset_threshold": validate_int, "axes.unicode_minus": validate_bool, # This entry can be either a cycler object or a string repr of a - # cycler-object, which gets eval()'ed to create the object. + # cycler-object, which is parsed safely via AST. "axes.prop_cycle": validate_cycler, # If "data", axes limits are set close to the data. # If "round_numbers" axes limits are set to the nearest round numbers. diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py index 525a9ff60d1a..245d5e493b89 100644 --- a/lib/matplotlib/tests/test_rcparams.py +++ b/lib/matplotlib/tests/test_rcparams.py @@ -276,13 +276,9 @@ def generate_validator_testcases(valid): (cycler(mew=[2, 5]), cycler('markeredgewidth', [2, 5])), ), - # This is *so* incredibly important: validate_cycler() eval's - # an arbitrary string! I think I have it locked down enough, - # and that is what this is testing. - # TODO: Note that these tests are actually insufficient, as it may - # be that they raised errors, but still did an action prior to - # raising the exception. We should devise some additional tests - # for that... + # validate_cycler() parses an arbitrary string using a safe + # AST-based parser (no eval). These tests verify that only valid + # cycler expressions are accepted. 'fail': ((4, ValueError), # Gotta be a string or Cycler object ('cycler("bleh, [])', ValueError), # syntax error ('Cycler("linewidth", [1, 2, 3])', @@ -464,6 +460,14 @@ def test_validate_cycler_bad_color_string(): validate_cycler("cycler('color', 'foo')") +def test_validate_cycler_no_code_execution(): + # List comprehensions are arbitrary code. The old eval()-based parser + # would execute this successfully, but the AST-based parser rejects it + # because only literal values are allowed in cycler arguments. + with pytest.raises(ValueError): + validate_cycler("cycler('color', [x for x in ['r', 'g', 'b']])") + + @pytest.mark.parametrize('weight, parsed_weight', [ ('bold', 'bold'), ('BOLD', ValueError), # weight is case-sensitive From f974ca898d617b48c28a1a16e1ba0356b5cd946f Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Mon, 9 Mar 2026 14:12:40 -0600 Subject: [PATCH 3/9] Whats new for prop_cycle rcparam --- doc/release/next_whats_new/cycler_rcparam_security.rst | 10 ++++++++++ lib/matplotlib/rcsetup.py | 5 ++--- 2 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 doc/release/next_whats_new/cycler_rcparam_security.rst diff --git a/doc/release/next_whats_new/cycler_rcparam_security.rst b/doc/release/next_whats_new/cycler_rcparam_security.rst new file mode 100644 index 000000000000..139c39d0baf6 --- /dev/null +++ b/doc/release/next_whats_new/cycler_rcparam_security.rst @@ -0,0 +1,10 @@ +``axes.prop_cycle`` rcParam must be literal +-------------------------------------------- + +The ``axes.prop_cycle`` rcParam is now parsed safely without ``eval()``. Only +literal ``cycler()`` calls combined with ``+`` and ``*`` are allowed. All +previously valid cycler strings continue to work, for example: + +.. code-block:: none + + axes.prop_cycle : cycler('color', ['r', 'g', 'b']) + cycler('linewidth', [1, 2, 3]) diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 89050ff91c0f..0ba27da202e0 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -847,9 +847,8 @@ def _eval_cycler_expr(node): if isinstance(node, ast.Call): if not (isinstance(node.func, ast.Name) and node.func.id == 'cycler'): raise ValueError("only the 'cycler()' function is allowed") - args = [ast.literal_eval(ast.unparse(a)) for a in node.args] - kwargs = {kw.arg: ast.literal_eval(ast.unparse(kw.value)) - for kw in node.keywords} + args = [ast.literal_eval(a) for a in node.args] + kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in node.keywords} return cycler(*args, **kwargs) raise ValueError( f"Unsupported expression in cycler string: {ast.dump(node)}") From 3016cc7db3347eea18e50366bc887665f8fe83a5 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Mon, 9 Mar 2026 16:08:12 -0600 Subject: [PATCH 4/9] Support and test cycler integer multiplication, concat, and slicing --- .../cycler_rcparam_security.rst | 8 +++-- lib/matplotlib/rcsetup.py | 33 +++++++++++++++---- lib/matplotlib/tests/test_rcparams.py | 6 ++++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/doc/release/next_whats_new/cycler_rcparam_security.rst b/doc/release/next_whats_new/cycler_rcparam_security.rst index 139c39d0baf6..efbf8ab4dfe9 100644 --- a/doc/release/next_whats_new/cycler_rcparam_security.rst +++ b/doc/release/next_whats_new/cycler_rcparam_security.rst @@ -2,9 +2,13 @@ -------------------------------------------- The ``axes.prop_cycle`` rcParam is now parsed safely without ``eval()``. Only -literal ``cycler()`` calls combined with ``+`` and ``*`` are allowed. All -previously valid cycler strings continue to work, for example: +literal ``cycler()`` and ``concat()`` calls combined with ``+``, ``*``, and +slicing are allowed. All previously valid cycler strings continue to work, +for example: .. code-block:: none axes.prop_cycle : cycler('color', ['r', 'g', 'b']) + cycler('linewidth', [1, 2, 3]) + axes.prop_cycle : 2 * cycler('color', 'rgb') + axes.prop_cycle : concat(cycler('color', 'rgb'), cycler('color', 'cmk')) + axes.prop_cycle : cycler('color', 'rgbcmk')[:3] diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 0ba27da202e0..726cd22b78e9 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -36,7 +36,7 @@ from matplotlib._enums import JoinStyle, CapStyle # Don't let the original cycler collide with our validating cycler -from cycler import Cycler, cycler as ccycler +from cycler import Cycler, concat as cconcat, cycler as ccycler class ValidateInStrings: @@ -845,13 +845,32 @@ def _eval_cycler_expr(node): return left * right raise ValueError(f"Unsupported operator: {type(node.op).__name__}") if isinstance(node, ast.Call): - if not (isinstance(node.func, ast.Name) and node.func.id == 'cycler'): - raise ValueError("only the 'cycler()' function is allowed") - args = [ast.literal_eval(a) for a in node.args] + if not (isinstance(node.func, ast.Name) + and node.func.id in ('cycler', 'concat')): + raise ValueError( + "only the 'cycler()' and 'concat()' functions are allowed") + func = cycler if node.func.id == 'cycler' else cconcat + args = [_eval_cycler_expr(a) for a in node.args] kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in node.keywords} - return cycler(*args, **kwargs) - raise ValueError( - f"Unsupported expression in cycler string: {ast.dump(node)}") + return func(*args, **kwargs) + if isinstance(node, ast.Subscript): + value = _eval_cycler_expr(node.value) + sl = node.slice + if isinstance(sl, ast.Slice): + s = slice( + ast.literal_eval(sl.lower) if sl.lower else None, + ast.literal_eval(sl.upper) if sl.upper else None, + ast.literal_eval(sl.step) if sl.step else None, + ) + return value[s] + raise ValueError("only slicing is supported, not indexing") + # Allow literal values (int, strings, lists, tuples) as arguments + # to cycler() and concat(). + try: + return ast.literal_eval(node) + except (ValueError, TypeError): + raise ValueError( + f"Unsupported expression in cycler string: {ast.dump(node)}") # A validator dedicated to the named legend loc diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py index 245d5e493b89..764deca06c66 100644 --- a/lib/matplotlib/tests/test_rcparams.py +++ b/lib/matplotlib/tests/test_rcparams.py @@ -275,6 +275,12 @@ def generate_validator_testcases(valid): cycler('linestyle', ['-', '--'])), (cycler(mew=[2, 5]), cycler('markeredgewidth', [2, 5])), + ("2 * cycler('color', 'rgb')", 2 * cycler('color', 'rgb')), + ("cycler('color', 'rgb') * 2", cycler('color', 'rgb') * 2), + ("concat(cycler('color', 'rgb'), cycler('color', 'cmk'))", + cycler('color', list('rgbcmk'))), + ("cycler('color', 'rgbcmk')[:3]", cycler('color', list('rgb'))), + ("cycler('color', 'rgb')[::-1]", cycler('color', list('bgr'))), ), # validate_cycler() parses an arbitrary string using a safe # AST-based parser (no eval). These tests verify that only valid From 408ad1c437d4e6c173b74bf8de6d6954c91b7503 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 10 Mar 2026 12:08:16 -0600 Subject: [PATCH 5/9] Consolidate tests --- lib/matplotlib/tests/test_rcparams.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py index 764deca06c66..56ef06bdb2bf 100644 --- a/lib/matplotlib/tests/test_rcparams.py +++ b/lib/matplotlib/tests/test_rcparams.py @@ -300,6 +300,9 @@ def generate_validator_testcases(valid): ValueError), ("cycler('c', [j.__class__(j).lower() for j in ['r', 'b']])", ValueError), + # list comprehensions are arbitrary code, even if "safe" + ("cycler('color', [x for x in ['r', 'g', 'b']])", + ValueError), ('1 + 2', ValueError), # doesn't produce a Cycler object ('os.system("echo Gotcha")', ValueError), # os not available ('import os', ValueError), # should not be able to import @@ -466,14 +469,6 @@ def test_validate_cycler_bad_color_string(): validate_cycler("cycler('color', 'foo')") -def test_validate_cycler_no_code_execution(): - # List comprehensions are arbitrary code. The old eval()-based parser - # would execute this successfully, but the AST-based parser rejects it - # because only literal values are allowed in cycler arguments. - with pytest.raises(ValueError): - validate_cycler("cycler('color', [x for x in ['r', 'g', 'b']])") - - @pytest.mark.parametrize('weight, parsed_weight', [ ('bold', 'bold'), ('BOLD', ValueError), # weight is case-sensitive From 2b02b0cab25a3612aed25dcd3a2d0c6284aa566d Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 10 Mar 2026 12:21:15 -0600 Subject: [PATCH 6/9] Deprecation notice --- doc/api/next_api_changes/deprecations/31248-SS.rst | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 doc/api/next_api_changes/deprecations/31248-SS.rst diff --git a/doc/api/next_api_changes/deprecations/31248-SS.rst b/doc/api/next_api_changes/deprecations/31248-SS.rst new file mode 100644 index 000000000000..f45fa724a265 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/31248-SS.rst @@ -0,0 +1,7 @@ +Arbitrary code in ``axes.prop_cycle`` rcParam strings +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``axes.prop_cycle`` rcParam previously accepted arbitrary Python +expressions by passing the string to ``eval()``. This is deprecated immediately +without replacement for security reasons. The previously documented cycler +operations at https://matplotlib.org/cycler/ are still supported. From e01b8a49314af14c45b3a2a8251f6f3b38e28f5d Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 10 Mar 2026 14:16:46 -0600 Subject: [PATCH 7/9] Code review updates --- doc/api/next_api_changes/deprecations/31248-SS.rst | 10 ++++++---- .../next_whats_new/cycler_rcparam_security.rst | 12 ++++++------ lib/matplotlib/tests/test_rcparams.py | 3 +++ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/doc/api/next_api_changes/deprecations/31248-SS.rst b/doc/api/next_api_changes/deprecations/31248-SS.rst index f45fa724a265..1d7adbdf0fde 100644 --- a/doc/api/next_api_changes/deprecations/31248-SS.rst +++ b/doc/api/next_api_changes/deprecations/31248-SS.rst @@ -1,7 +1,9 @@ Arbitrary code in ``axes.prop_cycle`` rcParam strings ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The ``axes.prop_cycle`` rcParam previously accepted arbitrary Python -expressions by passing the string to ``eval()``. This is deprecated immediately -without replacement for security reasons. The previously documented cycler -operations at https://matplotlib.org/cycler/ are still supported. +The ``axes.prop_cycle`` rcParam accepts Python expressions that are evaluated +in a limited context. The evaluation context has been further limited and some +expressions that previously worked (list comprehensions, for example) no longer +will. This change is made without a deprecation period to improve security. +The previously documented cycler operations at +https://matplotlib.org/cycler/ are still supported. diff --git a/doc/release/next_whats_new/cycler_rcparam_security.rst b/doc/release/next_whats_new/cycler_rcparam_security.rst index efbf8ab4dfe9..e4e2893aa994 100644 --- a/doc/release/next_whats_new/cycler_rcparam_security.rst +++ b/doc/release/next_whats_new/cycler_rcparam_security.rst @@ -1,10 +1,10 @@ -``axes.prop_cycle`` rcParam must be literal --------------------------------------------- +``axes.prop_cycle`` rcParam security improvements +------------------------------------------------- -The ``axes.prop_cycle`` rcParam is now parsed safely without ``eval()``. Only -literal ``cycler()`` and ``concat()`` calls combined with ``+``, ``*``, and -slicing are allowed. All previously valid cycler strings continue to work, -for example: +The ``axes.prop_cycle`` rcParam is now parsed in a safer and more restricted +manner. Only literals, ``cycler()`` and ``concat()`` calls, the operators +``+`` and ``*``, and slicing are allowed. All previously valid cycler strings +documented at https://matplotlib.org/cycler/ are still supported, for example: .. code-block:: none diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py index 56ef06bdb2bf..2b384e2b4757 100644 --- a/lib/matplotlib/tests/test_rcparams.py +++ b/lib/matplotlib/tests/test_rcparams.py @@ -276,6 +276,7 @@ def generate_validator_testcases(valid): (cycler(mew=[2, 5]), cycler('markeredgewidth', [2, 5])), ("2 * cycler('color', 'rgb')", 2 * cycler('color', 'rgb')), + ("2 * cycler('color', 'r' + 'gb')", 2 * cycler('color', 'rgb')), ("cycler('color', 'rgb') * 2", cycler('color', 'rgb') * 2), ("concat(cycler('color', 'rgb'), cycler('color', 'cmk'))", cycler('color', list('rgbcmk'))), @@ -287,6 +288,8 @@ def generate_validator_testcases(valid): # cycler expressions are accepted. 'fail': ((4, ValueError), # Gotta be a string or Cycler object ('cycler("bleh, [])', ValueError), # syntax error + ("cycler('color', 'rgb') * * cycler('color', 'rgb')", # syntax error + ValueError), ('Cycler("linewidth", [1, 2, 3])', ValueError), # only 'cycler()' function is allowed # do not allow dunder in string literals From bb6eb2943e5f7d15b9da3aa3e9538bf41d036399 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Sat, 21 Mar 2026 11:00:14 -0600 Subject: [PATCH 8/9] Code review update --- lib/matplotlib/rcsetup.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 726cd22b78e9..209ef6f0e858 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -854,16 +854,16 @@ def _eval_cycler_expr(node): kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in node.keywords} return func(*args, **kwargs) if isinstance(node, ast.Subscript): - value = _eval_cycler_expr(node.value) sl = node.slice - if isinstance(sl, ast.Slice): - s = slice( - ast.literal_eval(sl.lower) if sl.lower else None, - ast.literal_eval(sl.upper) if sl.upper else None, - ast.literal_eval(sl.step) if sl.step else None, - ) - return value[s] - raise ValueError("only slicing is supported, not indexing") + if not isinstance(sl, ast.Slice): + raise ValueError("only slicing is supported, not indexing") + s = slice( + ast.literal_eval(sl.lower) if sl.lower else None, + ast.literal_eval(sl.upper) if sl.upper else None, + ast.literal_eval(sl.step) if sl.step else None, + ) + value = _eval_cycler_expr(node.value) + return value[s] # Allow literal values (int, strings, lists, tuples) as arguments # to cycler() and concat(). try: From 99d07bcbcde9adf2834a16857d4235298bc03a29 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Thu, 26 Mar 2026 14:57:42 -0600 Subject: [PATCH 9/9] Recursively eval cycler kwargs --- lib/matplotlib/rcsetup.py | 2 +- lib/matplotlib/tests/test_rcparams.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 209ef6f0e858..e84a8c70bc48 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -851,7 +851,7 @@ def _eval_cycler_expr(node): "only the 'cycler()' and 'concat()' functions are allowed") func = cycler if node.func.id == 'cycler' else cconcat args = [_eval_cycler_expr(a) for a in node.args] - kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in node.keywords} + kwargs = {kw.arg: _eval_cycler_expr(kw.value) for kw in node.keywords} return func(*args, **kwargs) if isinstance(node, ast.Subscript): sl = node.slice diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py index 2b384e2b4757..0479fc7a02a8 100644 --- a/lib/matplotlib/tests/test_rcparams.py +++ b/lib/matplotlib/tests/test_rcparams.py @@ -277,6 +277,8 @@ def generate_validator_testcases(valid): cycler('markeredgewidth', [2, 5])), ("2 * cycler('color', 'rgb')", 2 * cycler('color', 'rgb')), ("2 * cycler('color', 'r' + 'gb')", 2 * cycler('color', 'rgb')), + ("cycler(c='r' + 'gb', lw=[1, 2, 3])", + cycler('color', 'rgb') + cycler('linewidth', [1, 2, 3])), ("cycler('color', 'rgb') * 2", cycler('color', 'rgb') * 2), ("concat(cycler('color', 'rgb'), cycler('color', 'cmk'))", cycler('color', list('rgbcmk'))),