Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions codejail/django_integration_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,30 @@
from . import jail_code


def apply_django_settings(code_jail_settings):
def apply_django_settings(code_jail_settings, config=None):
"""
Apply a settings.CODE_JAIL dictionary to the `jail_code` module.
Apply a settings.CODE_JAIL dictionary to a :class:`~codejail.jail_code.CodeJailConfig`.

``config`` is an optional :class:`~codejail.jail_code.CodeJailConfig` instance.
When omitted the module-level default config (``jail_code._default_config``) is
used, which preserves the original behaviour. Pass an explicit instance in tests
or multi-tenant scenarios where you need isolated configuration.
"""
cfg = config if config is not None else jail_code._default_config
python_bin = code_jail_settings.get('python_bin')
if python_bin:
user = code_jail_settings['user']
jail_code.configure("python", python_bin, user=user)
cfg.configure("python", python_bin, user=user)
limits = code_jail_settings.get('limits', {})
for name, value in limits.items():
jail_code.set_limit(
cfg.set_limit(
limit_name=name,
value=value,
)
limit_overrides = code_jail_settings.get('limit_overrides', {})
for context, overrides in limit_overrides.items():
for name, value in overrides.items():
jail_code.override_limit(
cfg.override_limit(
limit_name=name,
value=value,
limit_overrides_context=context,
Expand Down
211 changes: 144 additions & 67 deletions codejail/jail_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,133 @@

# TODO: limit too much stdout data? # pylint: disable=fixme

# Configure the commands

# COMMANDS is a map from an abstract command name to a list of command-line
# pieces, such as subprocess.Popen wants.
COMMANDS = {}
# ---------------------------------------------------------------------------
# Default resource limits (used by CodeJailConfig.__init__)
# ---------------------------------------------------------------------------

DEFAULT_LIMITS = {
# CPU seconds, defaulting to 1.
"CPU": 1,
# Real time, defaulting to 1 second.
"REALTIME": 1,
# Total process virutal memory, in bytes, defaulting to unlimited.
"VMEM": 0,
# Size of files creatable, in bytes, defaulting to nothing can be written.
"FSIZE": 0,
# The number of processes and threads to allow for the sandbox user (total
# across entire host).
"NPROC": 15,
# Whether to use a proxy process or not. None means use an environment
# variable to decide. NOTE: using a proxy process is NOT THREAD-SAFE, only
# one thread can use CodeJail at a time if you are using a proxy process.
"PROXY": None,
}


# ---------------------------------------------------------------------------
# CodeJailConfig — encapsulates all mutable codejail state
# ---------------------------------------------------------------------------

class CodeJailConfig:
"""
Encapsulate codejail configuration: command paths, resource limits, and
per-context limit overrides.

Using instances of this class instead of the module-level globals allows
independent configurations to coexist in the same process — for example,
isolated test fixtures that do not affect each other or production state.

The module-level helper functions (``configure``, ``is_configured``,
``set_limit``, etc.) all delegate to the module-level ``_default_config``
instance and remain fully backward-compatible.
"""

def __init__(self):
# Map from abstract command name → {'cmdline_start': [...], 'user': ...}
self.COMMANDS = {}
# Active resource limits for this configuration.
self.LIMITS = DEFAULT_LIMITS.copy()
# Per-context limit overrides: {context_str: {limit_name: value}}
self.LIMIT_OVERRIDES = {}

def configure(self, command, bin_path, user=None):
"""
Configure a command for ``jail_code`` to use.

``command`` is the abstract command you're configuring, such as
"python" or "node". ``bin_path`` is the path to the binary.
``user``, if provided, is the user name to run the command under.
"""
cmd_argv = [bin_path]
if command == "python":
# -E means ignore the environment variables PYTHON*
# -B means don't try to write .pyc files.
cmd_argv.extend(['-E', '-B'])
self.COMMANDS[command] = {
'cmdline_start': cmd_argv,
'user': user,
}

def is_configured(self, command):
"""Return True if this config has been set up for ``command``."""
return command in self.COMMANDS

def set_limit(self, limit_name, value):
"""
Set a resource limit on this configuration.

See the module-level ``set_limit`` docstring for the full list of
recognised limit names and their semantics.
"""
self.LIMITS[limit_name] = value

def get_effective_limits(self, overrides_context=None):
"""
Return the effective limits dict, merging any context-specific overrides.
"""
overrides = (
self.LIMIT_OVERRIDES.get(overrides_context, {})
if overrides_context
else {}
)
return {**self.LIMITS, **overrides}

def override_limit(self, limit_name, value, limit_overrides_context):
"""
Override a limit for a specific context on this configuration.

See the module-level ``override_limit`` docstring for semantics.
"""
if limit_name == 'PROXY' and self.LIMITS['PROXY'] != value:
log.error(
'Tried to override value of PROXY to %s. '
'Overriding PROXY on a per-context basis is not permitted. '
'Will use globally-configured value instead: %s.',
value,
self.LIMITS['PROXY'],
)
return
if limit_overrides_context not in self.LIMIT_OVERRIDES:
self.LIMIT_OVERRIDES[limit_overrides_context] = {}
self.LIMIT_OVERRIDES[limit_overrides_context][limit_name] = value


# ---------------------------------------------------------------------------
# Module-level default config instance
# ---------------------------------------------------------------------------

#: The default configuration used by all module-level helper functions.
#: Callers that need isolated state should create their own ``CodeJailConfig``
#: and pass it as ``config=`` to ``jail_code()``.
_default_config = CodeJailConfig()

# Module-level aliases kept for backward compatibility. These names point to
# the *same dict objects* held by ``_default_config``, so direct mutations via
# either path stay in sync.
COMMANDS = _default_config.COMMANDS
LIMITS = _default_config.LIMITS
LIMIT_OVERRIDES = _default_config.LIMIT_OVERRIDES


def configure(command, bin_path, user=None):
Expand All @@ -31,20 +153,7 @@ def configure(command, bin_path, user=None):
the user name to run the command under.

"""
cmd_argv = [bin_path]

# Command-specific arguments
if command == "python":
# -E means ignore the environment variables PYTHON*
# -B means don't try to write .pyc files.
cmd_argv.extend(['-E', '-B'])

COMMANDS[command] = {
# The start of the command line for this program.
'cmdline_start': cmd_argv,
# The user to run this as, perhaps None.
'user': user,
}
_default_config.configure(command, bin_path, user)


def is_configured(command):
Expand All @@ -55,9 +164,10 @@ def is_configured(command):
in the `jail_code` function.

"""
return command in COMMANDS
return _default_config.is_configured(command)


# ---------------------------------------------------------------------------
# By default, look where our current Python is, and maybe there's a
# python-sandbox alongside. Only do this if running in a virtualenv.
# The check for sys.real_prefix covers virtualenv
Expand All @@ -78,34 +188,6 @@ def is_configured(command):
configure("python", sys.prefix + "-sandbox/bin/python", "sandbox")


# The resource limits that we unless otherwise configured.
DEFAULT_LIMITS = {
# CPU seconds, defaulting to 1.
"CPU": 1,
# Real time, defaulting to 1 second.
"REALTIME": 1,
# Total process virutal memory, in bytes, defaulting to unlimited.
"VMEM": 0,
# Size of files creatable, in bytes, defaulting to nothing can be written.
"FSIZE": 0,
# The number of processes and threads to allow for the sandbox user (total
# across entire host).
"NPROC": 15,
# Whether to use a proxy process or not. None means use an environment
# variable to decide. NOTE: using a proxy process is NOT THREAD-SAFE, only
# one thread can use CodeJail at a time if you are using a proxy process.
"PROXY": None,
}

# Configured resource limits.
# Modified by calling `set_limit`.
LIMITS = DEFAULT_LIMITS.copy()

# Map from limit_overrides_contexts (strings) to dictionaries in the shape of LIMITS.
# Modified by calling `override_limit`.
LIMIT_OVERRIDES = {}


def set_limit(limit_name, value):
"""
Set a limit for `jail_code`.
Expand Down Expand Up @@ -141,7 +223,7 @@ def set_limit(limit_name, value):
Providing a limit of 0 will disable that limit, unless otherwise specified.

"""
LIMITS[limit_name] = value
_default_config.set_limit(limit_name, value)


def get_effective_limits(overrides_context=None):
Expand All @@ -152,8 +234,7 @@ def get_effective_limits(overrides_context=None):
overrides_context (str|None): Identifies which set of overrides to use.
If None or missing from `LIMIT_OVERRIDES`, then just return `LIMITS` as-is.
"""
overrides = LIMIT_OVERRIDES.get(overrides_context, {}) if overrides_context else {}
return {**LIMITS, **overrides}
return _default_config.get_effective_limits(overrides_context)


def override_limit(limit_name, value, limit_overrides_context):
Expand All @@ -166,18 +247,7 @@ def override_limit(limit_name, value, limit_overrides_context):
executions of code is not supported. If one attempts to override PROXY, the override
will be ignored and the globally-configured value will be used instead.
"""
if limit_name == 'PROXY' and LIMITS['PROXY'] != value:
log.error(
'Tried to override value of PROXY to %s. '
'Overriding PROXY on a per-context basis is not permitted. '
'Will use globally-configured value instead: %s.',
value,
LIMITS['PROXY'],
)
return
if limit_overrides_context not in LIMIT_OVERRIDES:
LIMIT_OVERRIDES[limit_overrides_context] = {}
LIMIT_OVERRIDES[limit_overrides_context][limit_name] = value
_default_config.override_limit(limit_name, value, limit_overrides_context)


class JailResult:
Expand All @@ -190,7 +260,7 @@ def __init__(self):

# pylint: disable=too-many-positional-arguments
def jail_code(command, code=None, files=None, extra_files=None, argv=None,
stdin=None, limit_overrides_context=None, slug=None):
stdin=None, limit_overrides_context=None, slug=None, config=None):
"""
Run code in a jailed subprocess.

Expand Down Expand Up @@ -224,6 +294,11 @@ def jail_code(command, code=None, files=None, extra_files=None, argv=None,
`slug` is an arbitrary string, a description that's meaningful to the
caller, that will be used in log messages.

`config` is an optional :class:`CodeJailConfig` instance. When provided,
that instance's commands and limits are used instead of the module-level
defaults. This allows isolated test fixtures and multi-tenant scenarios to
coexist without sharing global state.

Return an object with:

.stdout: stdout of the program, a string
Expand All @@ -233,7 +308,9 @@ def jail_code(command, code=None, files=None, extra_files=None, argv=None,
"""
# pylint: disable=too-many-statements

if not is_configured(command):
cfg = config if config is not None else _default_config

if not cfg.is_configured(command):
# pylint: disable=broad-exception-raised
raise Exception("jail_code needs to be configured for %r" % command)

Expand Down Expand Up @@ -281,7 +358,7 @@ def jail_code(command, code=None, files=None, extra_files=None, argv=None,
rm_cmd = []

# Build the command to run.
user = COMMANDS[command]['user']
user = cfg.COMMANDS[command]['user']
if user:
# Run as the specified user
cmd.extend(['sudo', '-u', user])
Expand All @@ -292,13 +369,13 @@ def jail_code(command, code=None, files=None, extra_files=None, argv=None,
# Issue: https://github.com/openedx/codejail/issues/162
cmd.extend(['TMPDIR=tmp'])
# Start with the command line dictated by "python" or whatever.
cmd.extend(COMMANDS[command]['cmdline_start'])
cmd.extend(cfg.COMMANDS[command]['cmdline_start'])

# Add the code-specific command line pieces.
cmd.extend(argv)

# Determine effective resource limits.
effective_limits = get_effective_limits(limit_overrides_context)
effective_limits = cfg.get_effective_limits(limit_overrides_context)
if slug:
log.info(
"Preparing to execute jailed code %r "
Expand Down
Loading