From 2428aa8261e3365ea13cc6d41ba272b71af212fa Mon Sep 17 00:00:00 2001 From: William Date: Sat, 16 May 2026 22:55:12 +0800 Subject: [PATCH] Avoid eval when parsing btrun kwargs Parse btrun key=value arguments with ast and literal values instead of evaluating arbitrary Python code. This keeps the existing CLI kwargs syntax for literal values while rejecting function calls such as __import__(). --- backtrader/btrun/btrun.py | 48 +++++++++++++++++++++++++++++----- tests/test_btrun.py | 54 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 tests/test_btrun.py diff --git a/backtrader/btrun/btrun.py b/backtrader/btrun/btrun.py index f93727629..259c7ec7e 100644 --- a/backtrader/btrun/btrun.py +++ b/backtrader/btrun/btrun.py @@ -22,6 +22,7 @@ unicode_literals) import argparse +import ast import datetime import inspect import itertools @@ -79,7 +80,7 @@ def btrun(pargs=''): stdstats = not args.nostdstats cer_kwargs_str = args.cerebro - cer_kwargs = eval('dict(' + cer_kwargs_str + ')') + cer_kwargs = parse_kwargs(cer_kwargs_str) if 'stdstats' not in cer_kwargs: cer_kwargs.update(stdstats=stdstats) @@ -134,7 +135,7 @@ def btrun(pargs=''): setbroker(args, cerebro) for wrkwargs_str in args.writers or []: - wrkwargs = eval('dict(' + wrkwargs_str + ')') + wrkwargs = parse_kwargs(wrkwargs_str) cerebro.addwriter(bt.WriterFile, **wrkwargs) ans = getfunctions(args.hooks, bt.Cerebro) @@ -161,7 +162,7 @@ def btrun(pargs=''): pkwargs = dict(style='bar') if args.plot is not True: # evaluates to True but is not "True" - args were passed - ekwargs = eval('dict(' + args.plot + ')') + ekwargs = parse_kwargs(args.plot) pkwargs.update(ekwargs) # cerebro.plot(numfigs=args.plotfigs, style=args.plotstyle) @@ -276,6 +277,41 @@ def getmodfunctions(mod, funcname=None): return funclist +def parse_kwargs(kwargs): + if not kwargs: + return dict() + + try: + tree = ast.parse('dict(' + kwargs + ')', mode='eval') + except SyntaxError as e: + raise ValueError('Invalid kwargs syntax: %s' % e) + + call = tree.body + if not isinstance(call, ast.Call): + raise ValueError('Invalid kwargs syntax') + + if not isinstance(call.func, ast.Name) or call.func.id != 'dict': + raise ValueError('Invalid kwargs syntax') + + if call.args: + raise ValueError('Only keyword arguments are supported') + + if getattr(call, 'starargs', None) is not None: + raise ValueError('Only keyword arguments are supported') + + if getattr(call, 'kwargs', None) is not None: + raise ValueError('Only keyword arguments are supported') + + parsed = dict() + for keyword in call.keywords: + if keyword.arg is None: + raise ValueError('Only keyword arguments are supported') + + parsed[keyword.arg] = ast.literal_eval(keyword.value) + + return parsed + + def loadmodule(modpath, modname=''): # generate a random name for the module @@ -344,8 +380,7 @@ def getobjects(iterable, clsbase, modbase, issignal=False): kwargs = dict() else: name = kwtokens[0] - kwtext = 'dict(' + kwtokens[1] + ')' - kwargs = eval(kwtext) + kwargs = parse_kwargs(kwtokens[1]) if modpath: mod, e = loadmodule(modpath) @@ -388,8 +423,7 @@ def getfunctions(iterable, modbase): kwargs = dict() else: name = kwtokens[0] - kwtext = 'dict(' + kwtokens[1] + ')' - kwargs = eval(kwtext) + kwargs = parse_kwargs(kwtokens[1]) if modpath: mod, e = loadmodule(modpath) diff --git a/tests/test_btrun.py b/tests/test_btrun.py new file mode 100644 index 000000000..6e4c01f72 --- /dev/null +++ b/tests/test_btrun.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# -*- coding: utf-8; py-indent-offset:4 -*- +############################################################################### +# +# Copyright (C) 2015-2023 Daniel Rodriguez +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +############################################################################### +from __future__ import (absolute_import, division, print_function, + unicode_literals) + +import testcommon + +from backtrader.btrun.btrun import parse_kwargs + + +def test_parse_kwargs_literals(): + kwargs = parse_kwargs( + "preload=True, maxcpus=2, name='sample', values=[1, 2]") + + assert kwargs == dict(preload=True, + maxcpus=2, + name='sample', + values=[1, 2]) + + +def test_parse_kwargs_empty(): + assert parse_kwargs('') == dict() + + +def test_parse_kwargs_rejects_code_execution(): + try: + parse_kwargs("x=__import__('os').system('echo vulnerable')") + except ValueError: + pass + else: + raise AssertionError('parse_kwargs should reject function calls') + + +if __name__ == '__main__': + test_parse_kwargs_literals() + test_parse_kwargs_empty() + test_parse_kwargs_rejects_code_execution()