Skip to content

Commit 2ad0aa7

Browse files
SEC: Remove eval() from validate_cycler (matplotlib#31248)
* Remove eval() from validate_cycler, which might allow code execution through a malicious matplotlibrc * Test that validate_cycler does not execute code passed in * Whats new for prop_cycle rcparam * Support and test cycler integer multiplication, concat, and slicing * Consolidate tests * Deprecation notice * Code review updates * Code review update * Recursively eval cycler kwargs --------- Co-authored-by: Scott Shambaugh <scottshambaugh@users.noreply.github.com>
1 parent 5bf23fd commit 2ad0aa7

File tree

4 files changed

+100
-30
lines changed

4 files changed

+100
-30
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff 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.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff 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]

lib/matplotlib/rcsetup.py

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from matplotlib._enums import JoinStyle, CapStyle
3737

3838
# Don't let the original cycler collide with our validating cycler
39-
from cycler import Cycler, cycler as ccycler
39+
from cycler import Cycler, concat as cconcat, cycler as ccycler
4040

4141

4242
class ValidateInStrings:
@@ -815,11 +815,62 @@ def cycler(*args, **kwargs):
815815
return reduce(operator.add, (ccycler(k, v) for k, v in validated))
816816

817817

818-
class _DunderChecker(ast.NodeVisitor):
819-
def visit_Attribute(self, node):
820-
if node.attr.startswith("__") and node.attr.endswith("__"):
821-
raise ValueError("cycler strings with dunders are forbidden")
822-
self.generic_visit(node)
818+
def _parse_cycler_string(s):
819+
"""
820+
Parse a string representation of a cycler into a Cycler object safely,
821+
without using eval().
822+
823+
Accepts expressions like::
824+
825+
cycler('color', ['r', 'g', 'b'])
826+
cycler('color', 'rgb') + cycler('linewidth', [1, 2, 3])
827+
cycler(c='rgb', lw=[1, 2, 3])
828+
cycler('c', 'rgb') * cycler('linestyle', ['-', '--'])
829+
"""
830+
try:
831+
tree = ast.parse(s, mode='eval')
832+
except SyntaxError as e:
833+
raise ValueError(f"Could not parse {s!r}: {e}") from e
834+
return _eval_cycler_expr(tree.body)
835+
836+
837+
def _eval_cycler_expr(node):
838+
"""Recursively evaluate an AST node to build a Cycler object."""
839+
if isinstance(node, ast.BinOp):
840+
left = _eval_cycler_expr(node.left)
841+
right = _eval_cycler_expr(node.right)
842+
if isinstance(node.op, ast.Add):
843+
return left + right
844+
if isinstance(node.op, ast.Mult):
845+
return left * right
846+
raise ValueError(f"Unsupported operator: {type(node.op).__name__}")
847+
if isinstance(node, ast.Call):
848+
if not (isinstance(node.func, ast.Name)
849+
and node.func.id in ('cycler', 'concat')):
850+
raise ValueError(
851+
"only the 'cycler()' and 'concat()' functions are allowed")
852+
func = cycler if node.func.id == 'cycler' else cconcat
853+
args = [_eval_cycler_expr(a) for a in node.args]
854+
kwargs = {kw.arg: _eval_cycler_expr(kw.value) for kw in node.keywords}
855+
return func(*args, **kwargs)
856+
if isinstance(node, ast.Subscript):
857+
sl = node.slice
858+
if not isinstance(sl, ast.Slice):
859+
raise ValueError("only slicing is supported, not indexing")
860+
s = slice(
861+
ast.literal_eval(sl.lower) if sl.lower else None,
862+
ast.literal_eval(sl.upper) if sl.upper else None,
863+
ast.literal_eval(sl.step) if sl.step else None,
864+
)
865+
value = _eval_cycler_expr(node.value)
866+
return value[s]
867+
# Allow literal values (int, strings, lists, tuples) as arguments
868+
# to cycler() and concat().
869+
try:
870+
return ast.literal_eval(node)
871+
except (ValueError, TypeError):
872+
raise ValueError(
873+
f"Unsupported expression in cycler string: {ast.dump(node)}")
823874

824875

825876
# A validator dedicated to the named legend loc
@@ -870,25 +921,11 @@ def _validate_legend_loc(loc):
870921
def validate_cycler(s):
871922
"""Return a Cycler object from a string repr or the object itself."""
872923
if isinstance(s, str):
873-
# TODO: We might want to rethink this...
874-
# While I think I have it quite locked down, it is execution of
875-
# arbitrary code without sanitation.
876-
# Combine this with the possibility that rcparams might come from the
877-
# internet (future plans), this could be downright dangerous.
878-
# I locked it down by only having the 'cycler()' function available.
879-
# UPDATE: Partly plugging a security hole.
880-
# I really should have read this:
881-
# https://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html
882-
# We should replace this eval with a combo of PyParsing and
883-
# ast.literal_eval()
884924
try:
885-
_DunderChecker().visit(ast.parse(s))
886-
s = eval(s, {'cycler': cycler, '__builtins__': {}})
887-
except BaseException as e:
925+
s = _parse_cycler_string(s)
926+
except Exception as e:
888927
raise ValueError(f"{s!r} is not a valid cycler construction: {e}"
889928
) from e
890-
# Should make sure what comes from the above eval()
891-
# is a Cycler object.
892929
if isinstance(s, Cycler):
893930
cycler_inst = s
894931
else:
@@ -1160,7 +1197,7 @@ def _convert_validator_spec(key, conv):
11601197
"axes.formatter.offset_threshold": validate_int,
11611198
"axes.unicode_minus": validate_bool,
11621199
# This entry can be either a cycler object or a string repr of a
1163-
# cycler-object, which gets eval()'ed to create the object.
1200+
# cycler-object, which is parsed safely via AST.
11641201
"axes.prop_cycle": validate_cycler,
11651202
# If "data", axes limits are set close to the data.
11661203
# If "round_numbers" axes limits are set to the nearest round numbers.

lib/matplotlib/tests/test_rcparams.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -275,16 +275,23 @@ def generate_validator_testcases(valid):
275275
cycler('linestyle', ['-', '--'])),
276276
(cycler(mew=[2, 5]),
277277
cycler('markeredgewidth', [2, 5])),
278+
("2 * cycler('color', 'rgb')", 2 * cycler('color', 'rgb')),
279+
("2 * cycler('color', 'r' + 'gb')", 2 * cycler('color', 'rgb')),
280+
("cycler(c='r' + 'gb', lw=[1, 2, 3])",
281+
cycler('color', 'rgb') + cycler('linewidth', [1, 2, 3])),
282+
("cycler('color', 'rgb') * 2", cycler('color', 'rgb') * 2),
283+
("concat(cycler('color', 'rgb'), cycler('color', 'cmk'))",
284+
cycler('color', list('rgbcmk'))),
285+
("cycler('color', 'rgbcmk')[:3]", cycler('color', list('rgb'))),
286+
("cycler('color', 'rgb')[::-1]", cycler('color', list('bgr'))),
278287
),
279-
# This is *so* incredibly important: validate_cycler() eval's
280-
# an arbitrary string! I think I have it locked down enough,
281-
# and that is what this is testing.
282-
# TODO: Note that these tests are actually insufficient, as it may
283-
# be that they raised errors, but still did an action prior to
284-
# raising the exception. We should devise some additional tests
285-
# for that...
288+
# validate_cycler() parses an arbitrary string using a safe
289+
# AST-based parser (no eval). These tests verify that only valid
290+
# cycler expressions are accepted.
286291
'fail': ((4, ValueError), # Gotta be a string or Cycler object
287292
('cycler("bleh, [])', ValueError), # syntax error
293+
("cycler('color', 'rgb') * * cycler('color', 'rgb')", # syntax error
294+
ValueError),
288295
('Cycler("linewidth", [1, 2, 3])',
289296
ValueError), # only 'cycler()' function is allowed
290297
# do not allow dunder in string literals
@@ -298,6 +305,9 @@ def generate_validator_testcases(valid):
298305
ValueError),
299306
("cycler('c', [j.__class__(j).lower() for j in ['r', 'b']])",
300307
ValueError),
308+
# list comprehensions are arbitrary code, even if "safe"
309+
("cycler('color', [x for x in ['r', 'g', 'b']])",
310+
ValueError),
301311
('1 + 2', ValueError), # doesn't produce a Cycler object
302312
('os.system("echo Gotcha")', ValueError), # os not available
303313
('import os', ValueError), # should not be able to import

0 commit comments

Comments
 (0)