diff options
| author | 2020-11-20 15:51:32 -0800 | |
|---|---|---|
| committer | 2020-11-20 15:51:32 -0800 | |
| commit | 9ccfe94fb311de030fee1383e95f84c50143a435 (patch) | |
| tree | d3e258d3fb28879c813a717616d1ea11762d6ebb | |
| parent | Set maximum file size to 10Mb through rlimit_fsize (diff) | |
| parent | Fix typo in _consume_stdout docstring (diff) | |
Merge pull request #81 - Limit STDOUT to prevent OOM
Diffstat (limited to '')
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | config/snekbox.cfg | 1 | ||||
| -rw-r--r-- | snekbox/nsjail.py | 53 | ||||
| -rw-r--r-- | tests/test_nsjail.py | 27 | 
4 files changed, 76 insertions, 6 deletions
@@ -24,6 +24,7 @@ result <- |             |<----------|           | <----------+  The code is executed in a Python process that is launched through [NsJail], which is responsible for sandboxing the Python process. See [`snekbox.cfg`] for the NsJail configuration. +The output returned by snekbox is truncated at around 1 MB.  ## HTTP REST API diff --git a/config/snekbox.cfg b/config/snekbox.cfg index 5b47459..27caf27 100644 --- a/config/snekbox.cfg +++ b/config/snekbox.cfg @@ -18,7 +18,6 @@ envar: "NUMEXPR_NUM_THREADS=1"  keep_caps: false  rlimit_as: 700 -rlimit_fsize: 10  clone_newnet: true  clone_newuser: true diff --git a/snekbox/nsjail.py b/snekbox/nsjail.py index cafde6d..cc1f682 100644 --- a/snekbox/nsjail.py +++ b/snekbox/nsjail.py @@ -27,6 +27,10 @@ NSJAIL_PATH = os.getenv("NSJAIL_PATH", "/usr/sbin/nsjail")  NSJAIL_CFG = os.getenv("NSJAIL_CFG", "./config/snekbox.cfg")  MEM_MAX = 52428800 +# Limit of stdout bytes we consume before terminating nsjail +OUTPUT_MAX = 1_000_000  # 1 MB +READ_CHUNK_SIZE = 10_000  # chars +  class NsJail:      """ @@ -101,6 +105,39 @@ class NsJail:                  # Treat fatal as error.                  log.error(msg) +    @staticmethod +    def _consume_stdout(nsjail: subprocess.Popen) -> str: +        """ +        Consume STDOUT, stopping when the output limit is reached or NsJail has exited. + +        The aim of this function is to limit the size of the output received from +        NsJail to prevent container from claiming too much memory. If the output +        received from STDOUT goes over the OUTPUT_MAX limit, the NsJail subprocess +        is asked to terminate with a SIGKILL. + +        Once the subprocess has exited, either naturally or because it was terminated, +        we return the output as a single string. +        """ +        output_size = 0 +        output = [] + +        # We'll consume STDOUT as long as the NsJail subprocess is running. +        while nsjail.poll() is None: +            chars = nsjail.stdout.read(READ_CHUNK_SIZE) +            output_size += sys.getsizeof(chars) +            output.append(chars) + +            if output_size > OUTPUT_MAX: +                # Terminate the NsJail subprocess with SIGKILL. +                log.info("Output exceeded the output limit, sending SIGKILL to NsJail.") +                nsjail.kill() +                break + +        # Ensure that we wait for the NsJail subprocess to terminate. +        nsjail.wait() + +        return "".join(output) +      def python3(self, code: str) -> CompletedProcess:          """Execute Python 3 code in an isolated environment and return the completed process."""          with NamedTemporaryFile() as nsj_log: @@ -124,7 +161,7 @@ class NsJail:              log.info(msg)              try: -                result = subprocess.run( +                nsjail = subprocess.Popen(                      args,                      stdout=subprocess.PIPE,                      stderr=subprocess.STDOUT, @@ -133,11 +170,19 @@ class NsJail:              except ValueError:                  return CompletedProcess(args, None, "ValueError: embedded null byte", None) +            output = self._consume_stdout(nsjail) + +            # When you send signal `N` to a subprocess to terminate it using Popen, it +            # will return `-N` as its exit code. As we normally get `N + 128` back, we +            # convert negative exit codes to the `N + 128` form. +            returncode = -nsjail.returncode + 128 if nsjail.returncode < 0 else nsjail.returncode +              log_lines = nsj_log.read().decode("utf-8").splitlines() -            if not log_lines and result.returncode == 255: +            if not log_lines and returncode == 255:                  # NsJail probably failed to parse arguments so log output will still be in stdout -                log_lines = result.stdout.splitlines() +                log_lines = output.splitlines()              self._parse_log(log_lines) -        return result +        log.info(f"nsjail return code: {returncode}") +        return CompletedProcess(args, returncode, output, None) diff --git a/tests/test_nsjail.py b/tests/test_nsjail.py index 0b755b2..40c27f9 100644 --- a/tests/test_nsjail.py +++ b/tests/test_nsjail.py @@ -1,8 +1,11 @@ +import io  import logging +import sys  import unittest +import unittest.mock  from textwrap import dedent -from snekbox.nsjail import MEM_MAX, NsJail +from snekbox.nsjail import MEM_MAX, NsJail, OUTPUT_MAX, READ_CHUNK_SIZE  class NsJailTests(unittest.TestCase): @@ -174,3 +177,25 @@ class NsJailTests(unittest.TestCase):              msg="stdout does not come before stderr"          )          self.assertEqual(result.stderr, None) + +    def test_stdout_flood_results_in_graceful_sigterm(self): +        stdout_flood = dedent(""" +            while True: +                print('abcdefghij') +        """).strip() + +        result = self.nsjail.python3(stdout_flood) +        self.assertEqual(result.returncode, 137) + +    def test_large_output_is_truncated(self): +        chunk = "a" * READ_CHUNK_SIZE +        expected_chunks = OUTPUT_MAX // sys.getsizeof(chunk) + 1 + +        nsjail_subprocess = unittest.mock.Mock() + +        # Go 10 chunks over to make sure we exceed the limit +        nsjail_subprocess.stdout = io.StringIO((expected_chunks + 10) * chunk) +        nsjail_subprocess.poll.return_value = None + +        output = self.nsjail._consume_stdout(nsjail_subprocess) +        self.assertEqual(output, chunk * expected_chunks)  |