Как стать автором
Обновить

Комментарии 27

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

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

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

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

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

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

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

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

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

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

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

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

bad saturday ¯_(ツ)_/¯

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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)

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

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

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

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

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

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

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

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

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

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

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

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории