Как правильно написать калькулятор на питоне с помощью eval()

В комментариях к статьям по синтаксическому анализу я иногда вижу такие:

на питоне калькулятор пишется проще простого — print(eval(input()))

Ну, вобщем‑то — да, но если, например, вы прикрутите такой калькулятор к своему сайту, то любой желающий вместо 2+2*2 может написать exec("import os; os.removedirs('/')"), предварительно изучив все ваши секретные файлы подобным же образом. Такая перспектива не может радовать, но и отказываться от eval() тоже не стоит.

— А что делать‑то? — спросите вы. Ответ простой: валидировать входящие данные, как вы это всегда делаете. Только не те, которые передаются функции eval() — для этого вам действительно пришлось бы написать свой синтаксический анализатор, а внутренние данные, которыми оперирует реализация eval().

— А! Я знаю — скажете вы — используем compile(), валидируем код и тогда уже вызываем eval(). Что-ж, возможно и так. Но я не знаю простого способа валидировать байт‑код. Нет. Нам надо валидировать результат питоновского синтаксического анализатора — абстрактное синтаксическое дерево или AST. Это гораздо проще, хоть и звучит пугающе.

Напишем свой eval() так:

def my_eval(expression):
    tree = ast.parse(expression, mode='eval')
    code = compile(tree, filename='', mode='eval')
    return eval(code)

Нам, естественнно, понадобится модуль ast:

>>> import ast

Проверяем - работает ли?

>>> print(my_eval(input()))
2+2*2
6

Итак, у нас есть tree. Вам, конечно же, любопытно, что там внутри, и действительно, надо же знать, что мы собираемся валидировать. Но просто так посмотреть не получится — это объект, и print() или pprint()выдадут всего лишь <ast.Expression object at 0x7f6de7eb3c70> Я пользовался такой функцией:

def dump(node):
    def _format(node, indent):
        if isinstance(node, ast.AST):
            print('%sAST %s' % (' ' * indent, node.__class__.__name__))
            for a, b in ast.iter_fields(node):
                print('%s%s' % (' ' * indent, a))
                _format(b, indent + 4)
        elif isinstance(node, list):
            print('%sLIST %s' % (' ' * indent, node.__class__.__name__))
            for x in node:
                _format(x, indent)
        else:
            print('%s%s' % (' ' * indent, repr(node)))
    _format(node, 0)

Можете вставить dump(tree) в my_eval(), поиграться и посмотреть, какое оно — это дерево. Только не надо копипастить мой пример с removedirs().

Кому играться лениво — вот пара примеров:

>>> print(my_eval(input()))
2+2*2
AST Expression
body
    AST BinOp
    left
        AST Constant
        value
            2
        kind
            None
    op
        AST Add
    right
        AST BinOp
        left
            AST Constant
            value
                2
            kind
                None
        op
            AST Mult
        right
            AST Constant
            value
                2
            kind
                None
6
>>> print(my_eval(input()))
print(globals())
AST Expression
body
    AST Call
    func
        AST Name
        id
            'print'
        ctx
            AST Load
    args
        LIST list
        AST Call
        func
            AST Name
            id
                'globals'
            ctx
                AST Load
        args
            LIST list
        keywords
            LIST list
    keywords
        LIST list
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, 'my_eval': <function my_eval at 0x7f6de9225480>, 'ast': <module 'ast' from '/usr/lib/python3.10/ast.py'>, 'dump': <function dump at 0x7f6de7efb490>}
None

Видите, чем отличаются безопасные выражения от небезопасных? Безопасные содержат только Constant и BinOp, а небезопасные — всякие Call и Name. Полный список операций и других узлов дерева можно посмотреть в документации к модулю ast. Это поможет определиться, что запретить, а что разрешить. Для простого калькулятора, я считаю, достаточно разрешить основные операции BinOp и UnaryOp. Плюс Constant.

Пора писать валидатор:

_allowed_nodes = (
    # базовые узлы:
    ast.BinOp, ast.UnaryOp, ast.Constant,

    # основные BinOps:
    ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.Mod, ast.Pow,

    # основные UnaryOps:
    ast.UAdd, ast.USub
)

def validate_ast(tree):

    # валидируем корень дерева
    if not isinstance(tree, ast.Expression):
        raise Exception('Неправильное выражение')

    # валидируем узлы
    def validate_children(node):
        for child in ast.iter_child_nodes(node):
            if not isinstance(child, _allowed_nodes):
                raise Exception('Неправильное выражение')
            validate_children(child)

    validate_children(tree)

Вот так всё просто. Смело вызывайте эту функцию из my_eval() перед compile():

def my_eval(expression):
    tree = ast.parse(expression, mode='eval')
    validate_ast(tree)
    code = compile(tree, filename='', mode='eval')
    return eval(code)

и будет вам счастье. Но, имейте ввиду, документация к функции compile() сообщает:

Warning

It is possible to crash the Python interpreter with a sufficiently large/complex string when compiling to an AST object due to stack depth limitations in Python’s AST compiler.

Так что ограничивайте длину строки, передаваемой my_eval(). И тогда счастье будет настоящим.

— Постойте! — скажете вы — Мне не нужен простой бухгалтерский калькулятор! Где хоть какой-нибудь минимальный набор математических функций? Не отправлять же пользователя в пешее путешествие искать косинус фи?

М-да, пожалуй, соглашусь с вами. Давайте разрешим кое-что из модуля math. Но это будут вызовы функций, а значит — небезопасные области тьмы. Нам придётся добавить ast.Call, ast.Name и ast.Load в _allowed_nodes, и это открывает путь к exec(). Чтобы избежать неприятностей, надо ограничить контекст выполнения, в котором важно наличие пустого __builtins__:

def my_eval(expression):
    tree = ast.parse(expression, mode='eval')
    validate_ast(tree)
    code = compile(tree, filename='', mode='eval')
    context = {'__builtins__': {}}
    return eval(code, context)

Плюс, надо добавить поддержку узлов-списков в валидатор:

def validate_ast(tree):

    # валидируем корень дерева
    if not isinstance(tree, ast.Expression):
        raise Exception('Неправильное выражение')

    # валидируем узлы
    def validate_children(node):
        for child in ast.iter_child_nodes(node):
            if isinstance(child, list):
                for grandchild in child:
                    validate_children(grandchild)
            else:
                if not isinstance(child, _allowed_nodes):
                    raise Exception('Неправильное выражение')
                validate_children(child)

    validate_children(tree)

Теперь, если мы введём

exec('print(42)')

то получим

NameError: name 'exec' is not defined

Ура! Ничего лучшего я, конечно, придумать не могу, но надеюсь что те, кто умнее меня, напишут в комментариях как это взломать и, соответственно, защититься.

Итак, функции у нас разрешены, надо добавить нужные в контекст выполнения. Я ограничусь синусом и косинусом, остальное вы и сами сможете. По своему вкусу.

import math

def my_eval(expression):
    tree = ast.parse(expression, mode='eval')
    validate_ast(tree)
    code = compile(tree, filename='', mode='eval')
    context = {
        '__builtins__': {},
        'sin': math.sin,
        'cos': math.cos
    }
    return eval(code, context)

Может показаться, что достаточно создать контекст всего один раз, вне функции, как мы это сделали с _allowed_nodes в валидаторе, но! — _allowed_nodes у нас всё-таки immutable, а для контекста есть риск модификации, случайной или преднамеренной. Известные аналогичные грабли — использование dict и list в качестве значений по умолчанию для аргументов. Поэтому лучше создавать контекст каждый раз, внутри функции.

Ну, вот собственно, и всё. Пользуйтесь и наслаждайтесь. Для тех, кому лень собирать копипастой всё в кучу, ловите полный исходник:

import ast
import math


def my_eval(expression):
    tree = ast.parse(expression, mode='eval')
    #dump(tree)
    validate_ast(tree)
    code = compile(tree, filename='', mode='eval')
    context = {
        '__builtins__': {},
        'sin': math.sin,
        'cos': math.cos
    }
    return eval(code, context)


_allowed_nodes = (
    # базовые узлы:
    ast.BinOp, ast.UnaryOp, ast.Constant, ast.Call, ast.Name, ast.Load,

    # основные BinOps:
    ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.Mod, ast.Pow,

    # основные UnaryOps:
    ast.UAdd, ast.USub
)

def validate_ast(tree):

    # валидируем корень дерева
    if not isinstance(tree, ast.Expression):
        raise Exception('Неправильное выражение')

    # валидируем узлы
    def validate_children(node):
        for child in ast.iter_child_nodes(node):
            if isinstance(child, list):
                for grandchild in child:
                    validate_children(grandchild)
            else:
                if not isinstance(child, _allowed_nodes):
                    raise Exception('Неправильное выражение')
                validate_children(child)

    validate_children(tree)


def dump(node):
    def _format(node, indent):
        if isinstance(node, ast.AST):
            print('%sAST %s' % (' ' * indent, node.__class__.__name__))
            for a, b in ast.iter_fields(node):
                print('%s%s' % (' ' * indent, a))
                _format(b, indent + 4)
        elif isinstance(node, list):
            print('%sLIST %s' % (' ' * indent, node.__class__.__name__))
            for x in node:
                _format(x, indent)
        else:
            print('%s%s' % (' ' * indent, repr(node)))
    _format(node, 0)


print(my_eval(input()))

@amateur80lvl
21.07.2023 16:37 UTC
Первоисточник

Комментарии

@datacompboy
21.07.2023 15:16 UTC
0

Хм. Хоть и удаётся залезть куда вроде бы не стоит (например, `sin.__self__`), но сделать с этим знанием ничего нельзя благодаря не самым плохим ограничениям.

Худше что можно получить -- MemoryError на запрос типа "asdf"*10000000000

@amateur80lvl
21.07.2023 18:37 UTC
+2

Ага, со строками недоработка. Можно добавить в валидатор

if isinstance(child, ast.Constant) and not isinstance(child.value, (int, float, complex)):
    raise Exception('Неправильное выражение')

или попытаться преобразовать их в число как, это бывает в известных языках ?

21.07.2023 19:49 UTC
+1

Комп не под рукой... Что на тему (sin.__name__*10000000)?

22.07.2023 05:27 UTC
0

— сейчас всё исправим, насяльника ?

Если посмотреть дерево для sin.__name__, то наблюдаем Attribute

AST Expression
body
    AST Attribute
    value
        AST Name
        id
            'sin'
        ctx
            AST Load
    attr
        '__name__'
    ctx
        AST Load

Я аж протёр глаза, и подумал что Attribute каким-то образом унаследован от Name, поэтому isinstance его пропускает и даже полез смотреть Python-ast.c как они это делают, но всё оказалось гораздо проще. Вот правильная (надеюсь) версия валидатора:

def validate_ast(node):
    for child in ast.iter_child_nodes(node):
        if isinstance(child, list):
            for grandchild in child:
                validate_ast(grandchild)
        else:
            if not isinstance(child, _allowed_nodes):
                raise Exception('Неправильное выражение')
            if isinstance(child, ast.Constant) and not isinstance(child.value, (int, float, complex)):
                raise Exception('Неправильное выражение')
            validate_ast(child)

А всё потому, что скопипастил из аналогичного проекта, но там у меня разрешались только Call и Constant.

Была также мысль запретить Name везде, кроме вызовов функций, но это усложнило бы валидатор, и нельзя было бы добавить в контекст математические константы pi, e, tau

22.07.2023 08:33 UTC
0

Да, забыл сказать, что ast.Expression тогда надо добавить в _allowed_nodes . Хотя, минимально достаточный патч для исправления этой баги будет таким:

                     raise Exception('Неправильное выражение')
                 validate_children(child)
 
-    validate_children(tree)
+    validate_children(tree.body)
 
 def dump(node):
     def _format(node, indent):

Поправил в статье, хотя вредительство уже успело расползтись.

Спасибо за +2 к миллиону глаз ?

22.07.2023 12:44 UTC
0

bad saturday ¯_(ツ)_/¯

-    validate_children(tree.body)
+    validate_children(tree)

@vagon333
21.07.2023 16:53 UTC
0

Как вариант, сделать калькулятор на pyodide.
Использую для более сложных задач.
Довольно мощное решение и абсолютно безопасное.

@Abobcum
21.07.2023 19:28 UTC
+1

Есть два подхода к калькулятору на питоне: print(eval(input(),{'exec':None}))

и написать свой парсер. Первый вариант абсолютно всегда можно взломать, поэтому где-то в недрах документации написано, мол eval и exec небезопасны.

@datacompboy
21.07.2023 19:50 UTC
0

А что понимается под "взломать"?

22.07.2023 06:54 UTC
0

А что понимается под "взломать"?

Понимается что если пользователю дали возможность выполнять только, например, сложение и вычитание, но его это не устраивает и он на базе этих операций, плюс некоторых непредусмотренных, пишет виртуальную машину с запуском Linux и скачивает порнуху.

22.07.2023 08:50 UTC
0

Ну вот арифметика в статье неплохо ограничена, вроде ничего не торчит....

22.07.2023 08:53 UTC
+1

Ну вот в этом-то и челендж :)

@amateur80lvl
22.07.2023 05:48 UTC
0

'exec':None бесполезен, в __builtins__ много всякого, например eval("__builtins__.import('os').removedirs('test-test-test')")

@baldr
22.07.2023 06:51 UTC
+2

Мда, делал примерно такое как в статье, лет 7-8 назад. У клиента была идея сделать что-то типа облачного калькулятора всяких сложных ETL-процессов, чтобы давать возможность юзерам писать свои обработчики данных, которые потом объединять в разные процессы, исполняемые распределенно. Например, на входе юзер определяет какие-то входные параметры и выход, а потом может этот "блок" прикреплять к другому, с совпадающими параметрами.. Внешне - примерно как AirFlow выглядит сейчас, наверное..

Внутри этих блоков все могло вычисляться на Python. Возможно, мы хотели еще добавить R и еще что-нибудь потом.. Но суть проблемы была такая же как в статье - дать юзеру возможность выполнять операции из большого списка, но "безопасно" - то есть чтобы он не смог вылезти куда-то наружу из "контейнера". Например, не мог отправить тучу HTTP-запросов, или файлы произвольно менять на диске, или устанавливать что-то на хост.

В то время я начинал это делать еще без всяких докеров - исполнение предполагалось в контексте отдельного процесса (ограниченного в правах) из-под celery-тасков на виртуалках, причем таски от разных юзеров вполне могли перемешиваться на одном хосте. Нужно было их как-то разделить.

На самом деле задача была сложна именно тем, что это был не просто "калькулятор". То есть функций надо будет много. Например, неплохо бы иметь регулярки, работу с промежуточными файлами, логи... А вычисления, конечно же, нужно делать с pandas! Я тоже написал свой "страж кода" для AST, как и автор. Мое решение тоже сначала свелось к __builtins__, однако быстро стало понятно что нереально просто запихать все функции и классы туда (да, классы тоже можно было определять в коде, но только отнаследованные от "хороших", из моих). Например, многие библиотеки неявно могут предлагать доступ к файловой системе, а там и до греха недалеко!

Я предоставлял доступ к файлам через свои функции-обертки, которые могли контролировать, чтобы пользователь не полез, скажем, в /dev/ или за пределы своей папки. Впрочем, довольно скоро я встретил методы в pandas типа DataFrame.to_csv, которые приходилось тоже либо блокировать, либо оборачивать во что-то... До конца я с этим так и не справился на том этапе.

Повторюсь, тогда я начинал это делать без докера и контейнеров как таковых. Однако к концу я уже начал осознавать необходимость этого, но, у клиента кончились деньги на разработку и проект свернули на стадии почти готового proof-of-concept.

Мда, много было идей там.. Спасибо автору что напомнил.

@igrishaev
22.07.2023 09:52 UTC
0

Вы делаете неправильно. У вас не должно быть никакого eval. Нужно распарсить выражение в дерево вида [* 2 [+ 2 2]], а затем выполнить его свертку.

@baldr
22.07.2023 10:12 UTC
0

Так AST и парсит это в дерево. И свертку выполняет. И позволяет вызывать функции.

@amateur80lvl
22.07.2023 12:55 UTC
0

Да, но идея была в том, чтобы от eval не отказываться. Про парсинг вручную есть у меня материал, этакий crash course, но он, сырой и, скорее, для детей. Не вижу смысла его тут публиковать.

22.07.2023 19:12 UTC
0

Совсем-совсем вручную, или используя ply ? ply вкусный, лайк-шер-рекоменд

23.07.2023 19:26 UTC
0

Совсем. Когда-то я так учудил наваять свой язык.

@ammo
24.07.2023 08:46 UTC
+1

https://github.com/danthedeckie/simpleeval - готовое решение для eval, из которого вырезаны все опасные штуки

@amateur80lvl
24.07.2023 08:55 UTC
0

Как я вижу они ничего не вырезают, просто определяют свой обработчик для каждого типа узлов AST (ну, не каждого, а только необходимых), а потом сами пробегаются по дереву и вызывают свои обработчики. Интересное решение.

@Shrram
25.07.2023 12:31 UTC
0
def calculator(command):
    if 'exec' in command or 'eval' in command:
        raise SyntaxError
    result = eval(command)
    if type(result) in [int, float]:
        return print(result) 
    else:
        raise SyntaxError 

try:
    calculator(input())
except SyntaxError as e:
      print('Syntax Error')

Бесспорно в статье разбирается достаточное количество материала которое я не знал и скорее всего буду использовать в будущем, за что спасибо автору.
Но если задача стоит конкретно в том чтобы ограничить пользователю доступ к окружению. Почему не использовать код представленный выше?

Логика кода если кому-то надо:
Мы не сразу вычисляем строку (не вызываем eval), а проверяем полученную строку.
Мы не допускаем возможность ввода `exec` и `eval` вовсе (если в строке всё же есть exec или eval, мы просто поднимаем SyntaxError). Далее проводим вычисление (вызываем eval) и если оно возвращает рациональное число, то выводим результат (в противном случае, снова поднимем SyntaxError)

Если я в чём то ошибся, то поправьте пожалуйста, благодарю.

@datacompboy
25.07.2023 13:57 UTC
+1

"e"+"v"+"a"+"l" позволит создать строку "eval" без триггера. По строке найти функцию и вызвать всегда можно

25.07.2023 20:55 UTC
0

позволит создать строку

Именно, позволит создать строку. `eval` для строки "some"+"thing" выполнит так называемую конкатенацию (склеивание) строки, а не саму строку. То есть в ответ мы получим строку которую уже никакой оператор не выполнит --> "something" (Можете посмотреть, он вернёт str, то есть только выполнит конкатенацию).

25.07.2023 21:33 UTC
0

globals()["eva"+"l"]()

26.07.2023 09:38 UTC
0

точнее вот так:

>>> calculator("globals()['__builtins__'].__dict__['eva'+'l']('1+2')")
3

@amateur80lvl
25.07.2023 14:00 UTC
0

Как решение "в лоб", то несомненно, оно простое и эффективное, если оценивать по количеству строк кода. Как мы с вами знаем, чем короче программа - тем проще для понимания. Не всегда, конечно, но в 95% случаев - да. Единственное, что я посоветовал бы (совет, конечно, плохой), так это добавить проверку на __builtins__ поскольку как показано в предыдущих комментариях, через эти самые __builtins__ можно снести всё, не вызывая никаких eval и exec.

Тем не менее, предложенное вами решение имеет подводные камни, как, впрочем, и моё. Почитайте, что пишет, например, автор simpleeval (см. ссылку на проект в предыдущих комментариях) про возведение в степень: 9**9**5 выполняется мгновенно, но 9**9**6 - уже за 30 секунд. Я попробовал - да, это так. Даже дожидаться не стал, нажал CTRL-C.

Поэтому любой калькулятор чуть сложнее бухгалтерского наверняка можно завалить такой формулой, которая вызовет отказ в обслуживании. И ограничение доступа к окружению вообще никакой роли не играет. Тут уже понадобится и виртуалка для питоновского процесса, и ограничение процессорного времени, и памяти... Всё сложно, в общем.

Но если без калькулятора никак, то для ограничения доступа к окружению всё-таки лучше использовать аргументы globals и locals (см. документацию для eval), а проверку на всякое ненужное лучше делать через AST. Это дерево открывает гораздо больше возможностей и пользоваться им гораздо правильнее. Простые проверки входной строки в вашем примере у программистов называются dirty hack, и, по возможности, такой подход лучше его избегать.