Skip to content
This repository has been archived by the owner on Aug 30, 2019. It is now read-only.

Commit

Permalink
Execute code in a subprocess
Browse files Browse the repository at this point in the history
  • Loading branch information
vstinner committed Mar 20, 2012
1 parent 7a521bb commit 33836f8
Show file tree
Hide file tree
Showing 7 changed files with 280 additions and 31 deletions.
6 changes: 6 additions & 0 deletions 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)
------------------------

Expand Down
3 changes: 2 additions & 1 deletion README
Expand Up @@ -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:

Expand Down
7 changes: 7 additions & 0 deletions 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
Expand Down
33 changes: 30 additions & 3 deletions 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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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])))
Expand Down Expand Up @@ -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)
Expand Down
66 changes: 47 additions & 19 deletions 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):
Expand All @@ -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 = []

Expand All @@ -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:
Expand All @@ -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):
"""
Expand All @@ -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

173 changes: 173 additions & 0 deletions 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()

0 comments on commit 33836f8

Please sign in to comment.