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

на питоне калькулятор пишется проще простого — 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()))

Комментарии (19)


  1. datacompboy
    21.07.2023 15:16

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

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


    1. amateur80lvl Автор
      21.07.2023 15:16
      +2

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

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

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


      1. datacompboy
        21.07.2023 15:16
        +1

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


        1. amateur80lvl Автор
          21.07.2023 15:16

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

          Если посмотреть дерево для 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


        1. amateur80lvl Автор
          21.07.2023 15:16

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

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

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

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


        1. amateur80lvl Автор
          21.07.2023 15:16

          bad saturday ¯_(ツ)_/¯

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


  1. vagon333
    21.07.2023 15:16

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


  1. Abobcum
    21.07.2023 15:16
    +1

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

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


    1. datacompboy
      21.07.2023 15:16

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


      1. baldr
        21.07.2023 15:16

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

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


        1. datacompboy
          21.07.2023 15:16

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


          1. baldr
            21.07.2023 15:16
            +1

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


    1. amateur80lvl Автор
      21.07.2023 15:16

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


  1. baldr
    21.07.2023 15:16
    +2

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

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

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

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

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

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

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


  1. igrishaev
    21.07.2023 15:16

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


    1. baldr
      21.07.2023 15:16

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


    1. amateur80lvl Автор
      21.07.2023 15:16

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


      1. datacompboy
        21.07.2023 15:16

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


        1. amateur80lvl Автор
          21.07.2023 15:16

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