From 33836f8194f2b1a1f27465bb7dde0e64ad5a0e29 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Tue, 20 Mar 2012 19:26:25 +0100 Subject: [PATCH] Execute code in a subprocess --- ChangeLog | 6 ++ README | 3 +- sandbox/__init__.py | 7 ++ sandbox/config.py | 33 +++++++- sandbox/sandbox_class.py | 66 ++++++++++----- sandbox/subprocess.py | 173 +++++++++++++++++++++++++++++++++++++++ tests.py | 23 ++++-- 7 files changed, 280 insertions(+), 31 deletions(-) create mode 100644 sandbox/subprocess.py diff --git a/ChangeLog b/ChangeLog index b00cfd5..1ac7d74 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,9 @@ +Version 1.6 +----------- + + * Execute code in a subprocess using the Python subprocess module + or os.fork() + Version 1.5 (2012-03-20) ------------------------ diff --git a/README b/README index 7f842f9..f418dcb 100644 --- a/README +++ b/README @@ -20,7 +20,8 @@ Blocked Python functions (by default): You can enable all of these features by setting the sandbox configuration. -The default recursion limit is 50 frames. +By default, the untrusted code is executed in a subprocess and the default +recursion limit is 50 frames. Protection of the namespace: diff --git a/sandbox/__init__.py b/sandbox/__init__.py index 9606933..d844ca3 100644 --- a/sandbox/__init__.py +++ b/sandbox/__init__.py @@ -1,6 +1,13 @@ +from __future__ import absolute_import + +DEFAULT_TIMEOUT = 5.0 + class SandboxError(Exception): pass +class Timeout(SandboxError): + pass + class Protection: def enable(self, sandbox): pass diff --git a/sandbox/config.py b/sandbox/config.py index 2a6655b..1dec6f0 100644 --- a/sandbox/config.py +++ b/sandbox/config.py @@ -1,8 +1,10 @@ +from __future__ import absolute_import from os.path import realpath, sep as path_sep, dirname, join as path_join, exists, isdir from sys import version_info #import imp import sys -from sandbox import HAVE_CSANDBOX, HAVE_CPYTHON_RESTRICTED, HAVE_PYPY +from sandbox import (DEFAULT_TIMEOUT, + HAVE_CSANDBOX, HAVE_CPYTHON_RESTRICTED, HAVE_PYPY) def findLicenseFile(): # Adapted from setcopyright() from site.py @@ -59,8 +61,22 @@ def __init__(self, *features, **kw): Usage: - SandboxConfig('stdout', 'stderr') - SandboxConfig('interpreter', cpython_restricted=True) + + Options: + + - use_subprocess=True (bool): if True, execute() run the code in + a subprocess + - cpython_restricted=False (bool): if True, use CPython restricted + mode instead of the _sandbox module """ self.recusion_limit = 50 + self._use_subprocess = kw.get('use_subprocess', True) + if self._use_subprocess: + self._timeout = DEFAULT_TIMEOUT + self._max_memory = 10 * 1024 * 1024 + else: + self._timeout = None + self._max_memory = None # open() whitelist: see safe_open() self._open_whitelist = set() @@ -160,6 +176,18 @@ def __init__(self, *features, **kw): def features(self): return self._features.copy() + @property + def use_subprocess(self): + return self._use_subprocess + + @property + def timeout(self): + return self._timeout + + @property + def max_memory(self): + return self._max_memory + @property def import_whitelist(self): return dict((name, (tuple(value[0]), tuple(value[1]))) @@ -415,8 +443,7 @@ def createOptparseOptions(parser): action="append", type="str") @staticmethod - def fromOptparseOptions(options): - kw = {} + def fromOptparseOptions(options, **kw): if HAVE_CPYTHON_RESTRICTED and options.restricted: kw['cpython_restricted'] = True config = SandboxConfig(**kw) diff --git a/sandbox/sandbox_class.py b/sandbox/sandbox_class.py index e74d4c9..4d6bd92 100644 --- a/sandbox/sandbox_class.py +++ b/sandbox/sandbox_class.py @@ -1,5 +1,6 @@ -from __future__ import with_statement +from __future__ import with_statement, absolute_import from .config import SandboxConfig +from .subprocess import call_fork, execute_subprocess from .proxy import proxy def keywordsProxy(keywords): @@ -11,6 +12,12 @@ def keywordsProxy(keywords): def _call_exec(code, globals, locals): exec code in globals, locals +def _dictProxy(data): + items = data.items() + data.clear() + for key, value in items: + data[proxy(key)] = proxy(value) + class Sandbox: PROTECTIONS = [] @@ -22,6 +29,11 @@ def __init__(self, config=None): self.protections = [protection() for protection in self.PROTECTIONS] def _call(self, func, args, kw): + """ + Call a function in the sandbox. + """ + args = proxy(args) + kw = keywordsProxy(kw) for protection in self.protections: protection.enable(self) try: @@ -34,32 +46,48 @@ def call(self, func, *args, **kw): """ Call a function in the sandbox. """ - args = proxy(args) - kw = keywordsProxy(kw) - return self._call(func, args, kw) + if self.config.use_subprocess: + return call_fork(self, func, args, kw) + else: + return self._call(func, args, kw) - def _dictProxy(self, data): - items = data.items() - data.clear() - for key, value in items: - data[proxy(key)] = proxy(value) + def _execute(self, code, globals, locals): + """ + Execute the code in the sandbox: + + exec code in globals, locals + """ + if globals is None: + globals = {} + for protection in self.protections: + protection.enable(self) + try: + _call_exec(code, globals, locals) + finally: + for protection in reversed(self.protections): + protection.disable(self) def execute(self, code, globals=None, locals=None): """ - execute the code in the sandbox: + Execute the code in the sandbox: exec code in globals, locals - Use globals={} by default to get an empty namespace. + Run the code in a subprocess except if it is disabled in the sandbox + configuration. + + The method has no result. By default, use globals={} to get an empty + namespace. """ - if globals is not None: - self._dictProxy(globals) + if self.config.use_subprocess: + return execute_subprocess(self, code, globals, locals) else: - globals = {} - if locals is not None: - self._dictProxy(locals) - - self._call(_call_exec, (code, globals, locals), {}) + code = proxy(code) + if globals is not None: + _dictProxy(globals) + if locals is not None: + _dictProxy(locals) + return self._execute(code, globals, locals) def createCallback(self, func, *args, **kw): """ @@ -69,6 +97,6 @@ def createCallback(self, func, *args, **kw): args = proxy(args) kw = keywordsProxy(kw) def callback(): - return self._call(func, args, kw) + return self.call(func, *args, **kw) return callback diff --git a/sandbox/subprocess.py b/sandbox/subprocess.py new file mode 100644 index 0000000..94abc9a --- /dev/null +++ b/sandbox/subprocess.py @@ -0,0 +1,173 @@ +from __future__ import absolute_import +from cStringIO import StringIO +from sandbox import SandboxError, Timeout +import fcntl +import os +import pickle +import subprocess +import sys +from signal import SIGALRM + +def apply_limits(config): + try: + import resource + except ImportError: + resource = None + else: + # deny fork and thread + resource.setrlimit(resource.RLIMIT_NPROC, (0, 0)) + + if config.max_memory: + if not resource: + raise NotImplementedError("SubprocessConfig.max_memory is not implemented for your platform") + resource.setrlimit(resource.RLIMIT_AS, (config.max_memory, config.max_memory)) + + if config.timeout: + import math, signal + seconds = int(math.ceil(config.timeout)) + seconds = max(seconds, 1) + signal.alarm(seconds) + +def encode_error(err): + return pickle.dumps(err) + return (type(err).__name__, str(err)) + +def raise_error(data): + err = pickle.loads(data) + raise err + + err_type, err_msg = data + raise Exception('[%s] %s' % (err_type, err_msg)) + +def child_process(): + from sandbox import Sandbox + + output = sys.stdout + redirect_stdout = False #True + if redirect_stdout: + sys.stdout = StringIO() + sys.stderr = sys.stdout + try: + input_data = pickle.load(sys.stdin) + config = input_data['config'] + apply_limits(config) + + sandbox = Sandbox(config) + code = input_data['code'] + locals = input_data.get('locals') + globals = input_data.get('globals') + result = sandbox._execute(code, globals, locals) + output_data = {'result': result} + if 'globals' in input_data: + del globals['__builtins__'] + output_data['globals'] = globals + if 'locals' in input_data: + output_data['locals'] = locals + except BaseException, err: + output_data = {'error': encode_error(err)} + if redirect_stdout: + output_data['stdout'] = sys.stdout.getvalue() + pickle.dump(output_data, output) + +def child_call(wpipe, sandbox, func, args, kw): + try: + result = sandbox._call(func, args, kw) + data = {'result': result} + except BaseException, err: + data = {'error': encode_error(err)} + output = os.fdopen(wpipe, 'wb') + pickle.dump(data, output) + output.flush() + output.close() + os._exit(0) + +def set_cloexec_flag(fd): + try: + cloexec_flag = fcntl.FD_CLOEXEC + except AttributeError: + cloexec_flag = 1 + flags = fcntl.fcntl(fd, fcntl.F_GETFD) + fcntl.fcntl(fd, fcntl.F_SETFD, flags | cloexec_flag) + +def call_fork(sandbox, func, args, kw): + rpipe, wpipe = os.pipe() + set_cloexec_flag(wpipe) + pid = os.fork() + if pid == 0: + os.close(rpipe) + try: + child_call(wpipe, sandbox, func, args, kw) + except: + # FIXME: handle error differently? + raise + else: + os.close(wpipe) + try: + status = os.waitpid(pid, 0)[1] + except: + os.close(rpipe) + raise + rpipe_file = os.fdopen(rpipe, 'rb') + try: + data = pickle.load(rpipe_file) + finally: + rpipe_file.close() + if 'error' in data: + raise_error(data['error']) + return data['result'] + +def execute_subprocess(sandbox, code, globals, locals): + # prepare data + input_data = { + 'code': code, + 'config': sandbox.config, + } + if locals is not None: + input_data['locals'] = locals + if globals is not None: + input_data['globals'] = globals + args = (sys.executable, '-E', '-S', '-m', __name__) + kw = { + 'stdin': subprocess.PIPE, + 'stdout': subprocess.PIPE, + 'stderr': subprocess.STDOUT, + 'close_fds': True, + 'shell': False, + } + + # create the subprocess + process = subprocess.Popen(args, **kw) + + # wait data + stdout, stderr = process.communicate(pickle.dumps(input_data)) + exitcode = process.wait() + if exitcode: + sys.stdout.write(stdout) + sys.stdout.flush() + + if os.name != "nt" and exitcode < 0: + signum = -exitcode + if signum == SIGALRM: + raise Timeout() + text = "subprocess killed by signal %s" % signum + else: + text = "subprocess failed with exit code %s" % exitcode + raise SandboxError(text) + + output_data = pickle.loads(stdout) + if 'stdout' in output_data: + sys.stdout.write(output_data['stdout']) + sys.stdout.flush() + if 'error' in output_data: + raise_error(output_data['error']) + if 'locals' in input_data: + input_data['locals'].clear() + input_data['locals'].update(output_data['locals']) + if 'globals' in input_data: + input_data['globals'].clear() + input_data['globals'].update(output_data['globals']) + return output_data['result'] + +if __name__ == "__main__": + child_process() + diff --git a/tests.py b/tests.py index 748435b..1ea3814 100644 --- a/tests.py +++ b/tests.py @@ -25,10 +25,12 @@ def parseOptions(): exit(1) return options -def run_tests(options, cpython_restricted): - print("Run tests with cpython_restricted=%s" % cpython_restricted) +def run_tests(options, use_subprocess, cpython_restricted): + print("Run tests with cpython_restricted=%s and use_subprocess=%s" + % (cpython_restricted, use_subprocess)) print("") createSandboxConfig.cpython_restricted = cpython_restricted + createSandboxConfig.use_subprocess = use_subprocess # Get all tests all_tests = getTests(globals(), options.keyword) @@ -42,6 +44,8 @@ def run_tests(options, cpython_restricted): base_exception = BaseException for func in all_tests: name = '%s.%s()' % (func.__module__.split('.')[-1], func.__name__) + if options.debug: + print(name) try: func() except SkipTest, skip: @@ -67,12 +71,15 @@ def main(): print("WARNING: _sandbox module is missing") print - nskipped, nerrors, ntests = run_tests(options, False) - if not nerrors: - result = run_tests(options, True) - nskipped += result[0] - nerrors += result[1] - ntests += result[2] + nskipped, nerrors, ntests = 0, 0, 0 + for use_subprocess in (False, True): + for cpython_restricted in (False, True): + result = run_tests(options, use_subprocess, cpython_restricted) + nskipped += result[0] + nerrors += result[1] + ntests += result[2] + if nerrors: + break # Exit from sys import exit