RestrictedPython: Вызывать другие функции внутри пользовательского кода?

Использование кода Юрия Нудельмана с пользовательским определением _import для указания модулей для ограничения служит хорошей основой, но при вызове функций внутри этого пользовательского_кода, естественно, из-за необходимости составлять белый список всего, есть ли способ разрешить вызов других функций, определенных пользователем? Я открыт для других решений по созданию песочницы, хотя Jupyter не кажется простым для встраивания в веб-интерфейс.

from RestrictedPython import safe_builtins, compile_restricted
from RestrictedPython.Eval import default_guarded_getitem

def _import(name, globals=None, locals=None, fromlist=(), level=0):
    safe_modules = ["math"]
    if name in safe_modules:
       globals[name] = __import__(name, globals, locals, fromlist, level)
    else:
        raise Exception("Don't you even think about it {0}".format(name))

safe_builtins['__import__'] = _import # Must be a part of builtins

def execute_user_code(user_code, user_func, *args, **kwargs):
    """ Executed user code in restricted env
        Args:
            user_code(str) - String containing the unsafe code
            user_func(str) - Function inside user_code to execute and return value
            *args, **kwargs - arguments passed to the user function
        Return:
            Return value of the user_func
    """

    def _apply(f, *a, **kw):
        return f(*a, **kw)

    try:
        # This is the variables we allow user code to see. @result will contain return value.
        restricted_locals = { 
            "result": None,
            "args": args,
            "kwargs": kwargs,
        }   

        # If you want the user to be able to use some of your functions inside his code,
        # you should add this function to this dictionary.
        # By default many standard actions are disabled. Here I add _apply_ to be able to access
        # args and kwargs and _getitem_ to be able to use arrays. Just think before you add
        # something else. I am not saying you shouldn't do it. You should understand what you
        # are doing thats all.
        restricted_globals = { 
            "__builtins__": safe_builtins,
            "_getitem_": default_guarded_getitem,
            "_apply_": _apply,
        }   

        # Add another line to user code that executes @user_func
        user_code += "\nresult = {0}(*args, **kwargs)".format(user_func)

        # Compile the user code
        byte_code = compile_restricted(user_code, filename="<user_code>", mode="exec")

        # Run it
        exec(byte_code, restricted_globals, restricted_locals)
       # User code has modified result inside restricted_locals. Return it.
        return restricted_locals["result"]

    except SyntaxError as e:
        # Do whaever you want if the user has code that does not compile
        raise
    except Exception as e:
        # The code did something that is not allowed. Add some nasty punishment to the user here.
        raise

i_example = """
import math

def foo():
    return 7

def myceil(x):
    return math.ceil(x)+foo()
"""
print(execute_user_code(i_example, "myceil", 1.5))

Запуск этого возвращает, что 'foo' не определен

В exec(byte_code, restricted_globals, restricted_locals):

  1. def foo(): изменяет restricted_locals.
  2. myceil может видеть только свои глобальные файлы, т.е. myceil.__globals__, который является restricted_globals.

Вы можете обновить f.__globals__/restricted_globals в _apply:

def _apply(f, *a, **kw):
    for k, v in restricted_locals.items():
        if k not in _restricted_locals_keys and k not in f.__globals__:
            f.__globals__[k] = v
    return f(*a, **kw)

где _restricted_locals_keys есть:

restricted_locals = {
    "result": None,
    "args": args,
    "kwargs": kwargs,
}
_restricted_locals_keys = set(restricted_locals.keys())

Если вы не хотите изменять restricted_globals, то скопируйте f с новыми глобалами в _apply:

import copy
import types

def _copy_func(f, func_globals=None):
    func_globals = func_globals or f.__globals__
    return types.FunctionType(f.__code__, func_globals, name=f.__name__, argdefs=f.__defaults__, closure=f.__closure__)

def _apply(f, *a, **kw):
    func_globals = copy.copy(f.__globals__)
    for k, v in restricted_locals.items():
        if k not in _restricted_locals_keys and k not in func_globals:
            func_globals[k] = v
    f = _copy_func(f, func_globals=func_globals)
    return f(*a, **kw)

Прежде всего, замена встроенного модуля на __import__ реализована неправильно. Эта встроенная функция должна возвращатьимпортированный модуль, а не изменять глобальные файлы, чтобы включить его:

Python 3.9.12 (main, Mar 24 2022, 13:02:21)
[GCC 11.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> __import__('math')
<module 'math' (built-in)>
>>> math
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'math' is not defined

Более эффективным способом реализации __import__ было бы следующее:

_SAFE_MODULES = frozenset(("math",))

def _safe_import(name, *args, **kwargs):
    if name not in _SAFE_MODULES:
        raise Exception(f"Don't you even think about {name!r}")
    return __import__(name, *args, **kwargs)

Тот факт, что вы мутировали глобальные объекты в вашей первоначальной реализации, частично маскировал основную ошибку. А именно: присвоения имен в ограниченном коде (определения функций, присвоения переменных и импорты) мутируют дикт локалей, но присвоения имен по умолчанию выполняются как глобальные, минуя локали. Вы можете убедиться в этом, дизассемблировав ограниченный байткод с помощью :__import__('dis').dis(byte_code)

  2           0 LOAD_CONST               0 (0)
              2 LOAD_CONST               1 (None)
              4 IMPORT_NAME              0 (math)
              6 STORE_NAME               0 (math)

  4           8 LOAD_CONST               2 (<code object foo at 0x7fbef4eef3a0, file "<user_code>", line 4>)
             10 LOAD_CONST               3 ('foo')
             12 MAKE_FUNCTION            0
             14 STORE_NAME               1 (foo)

  7          16 LOAD_CONST               4 (<code object myceil at 0x7fbef4eef660, file "<user_code>", line 7>)
             18 LOAD_CONST               5 ('myceil')
             20 MAKE_FUNCTION            0
             22 STORE_NAME               2 (myceil)
             24 LOAD_CONST               1 (None)
             26 RETURN_VALUE

Disassembly of <code object foo at 0x7fbef4eef3a0, file "<user_code>", line 4>:
  5           0 LOAD_CONST               1 (7)
              2 RETURN_VALUE

Disassembly of <code object myceil at 0x7fbef4eef660, file "<user_code>", line 7>:
  8           0 LOAD_GLOBAL              0 (_getattr_)
              2 LOAD_GLOBAL              1 (math)
              4 LOAD_CONST               1 ('ceil')
              6 CALL_FUNCTION            2
              8 LOAD_FAST                0 (x)
             10 CALL_FUNCTION            1
             12 LOAD_GLOBAL              2 (foo)
             14 CALL_FUNCTION            0
             16 BINARY_ADD
             18 RETURN_VALUE

Раздельные отображения для локалов и глобалов совершенно надуманны, поскольку документация для exec объясняет (выделено мной):

Если указано только globals, то это должен быть словарь (а не подкласс словаря), который будет использоваться как для глобальных, так и для локальных переменных. Если указаны globals и locals, то они используются для глобальных и локальных переменных, соответственно. Если указано, locals может быть любым объектом отображения. Помните, что на уровне модуля globals и locals - это один и тот же словарь. Если exec получает два отдельных объекта как globals и locals, код будет выполнен, как если бы он был встроен в определение класса.

Поэтому мы можем просто избавиться от диктанта locals и поместить все в globals. Весь код должен выглядеть примерно так:

from RestrictedPython import safe_builtins, compile_restricted


_SAFE_MODULES = frozenset(("math",))


def _import(name, globals=None, locals=None, fromlist=(), level=0):
    safe_modules = ["math"]
    if name in safe_modules:
        globals[name] = __import__(name, globals, locals, fromlist, level)
    else:
        raise Exception("Don't you even think about it {0}".format(name))


def execute_user_code(user_code, user_func, *args, **kwargs):
    my_globals = {
        "__builtins__": {
            **safe_builtins,
            "__import__": _safe_import,
        },
    }

    try:
        byte_code = compile_restricted(
            user_code, filename="<user_code>", mode="exec")
    except SyntaxError:
        # syntax error in the sandboxed code
        raise

    try:
        exec(byte_code, my_globals)
        return my_globals[user_func](*args, **kwargs)
    except BaseException:
        # runtime error (probably) in the sandboxed code
        raise

Выше мне также удалось исправить пару касательных вопросов:

  • Вместо того, чтобы вставлять вызов функции в скомпилированный фрагмент, я ищу функцию в дикте globals напрямую. Это позволяет избежать потенциального вектора инъекции кода, если user_func придет из недоверенного источника, и избежать необходимости инъекции args, kwargs и result в песочницу, что позволит коду песочницы разрушить ее.
  • Я избегаю мутировать объект safe_builtins, предоставляемый модулем RestrictedPython. В противном случае, если какой-либо другой код в вашей программе случайно использует RestrictedPython, он может быть затронут.
  • Я разделил обработку исключений между двумя этапами: компиляцией и выполнением. Это минимизирует вероятность того, что ошибки в коде песочницы будут ошибочно отнесены к коду песочницы.
  • .
  • Я изменил тип перехватываемого исключения времени выполнения на BaseException, чтобы также перехватывать случаи, когда код песочницы пытается поднять KeyboardInterrupt или SystemExit (которые не являются производными от Exception, а только BaseException).
  • Я также удалил ссылки на _getitem_ и _apply_, которые, похоже, ни для чего не используются. Если окажется, что они все-таки нужны, вы можете восстановить их.

(Обратите внимание, однако, что это не защищает от DoS через бесконечные циклы в песочнице)

Вернуться на верх