CVE-2016-10745: Use of Externally-Controlled Format String (CWE-134)
value: In Pallets Jinja before 2.8.1, str.format allows a sandbox escape.
CWE description: The product uses a function that accepts a format string as an argument, but the format string originates from an external source.
CVSS v3 base score: 8.6
Published: 2019-04-08T13:29Z
Repository: https://github.com/pallets/jinja
Language: Python
Files changed: jinja2/nodes.py, jinja2/sandbox.py, tests/test_security.py
Commit date: 2016-12-29 14:13:38 +0100
Commit message: SECURITY: support sandboxing in format expressions

Diff: diff --git a/jinja2/nodes.py b/jinja2/nodes.py
index d32046c..6d4593b 100644
--- a/jinja2/nodes.py
+++ b/jinja2/nodes.py
@@ -602,11 +602,11 @@ class Call(Expr):
     """
     fields = ('node', 'args', 'kwargs', 'dyn_args', 'dyn_kwargs')
 
     def as_const(self, eval_ctx=None):
         eval_ctx = get_eval_context(self, eval_ctx)
-        if eval_ctx.volatile:
+        if eval_ctx.volatile or eval_ctx.environment.sandboxed:
             raise Impossible()
         obj = self.node.as_const(eval_ctx)
 
         # don't evaluate context functions
         args = [x.as_const(eval_ctx) for x in self.args]
diff --git a/jinja2/sandbox.py b/jinja2/sandbox.py
index 7e40ab3..c035dde 100644
--- a/jinja2/sandbox.py
+++ b/jinja2/sandbox.py
@@ -12,13 +12,21 @@
     :copyright: (c) 2010 by the Jinja Team.
     :license: BSD.
 """
 import types
 import operator
+from collections import Mapping
 from jinja2.environment import Environment
 from jinja2.exceptions import SecurityError
-from jinja2._compat import string_types, PY2
+from jinja2._compat import string_types, text_type, PY2
+from jinja2.utils import Markup
+
+has_format = False
+if hasattr(text_type, 'format'):
+    from markupsafe import EscapeFormatter
+    from string import Formatter
+    has_format = True
 
 
 #: maximum number of items a range may produce
 MAX_RANGE = 100000
 
@@ -36,10 +44,16 @@ else:
 UNSAFE_METHOD_ATTRIBUTES = set(['im_class', 'im_func', 'im_self'])
 
 #: unsafe generator attirbutes.
 UNSAFE_GENERATOR_ATTRIBUTES = set(['gi_frame', 'gi_code'])
 
+#: unsafe attributes on coroutines
+UNSAFE_COROUTINE_ATTRIBUTES = set(['cr_frame', 'cr_code'])
+
+#: unsafe attributes on async generators
+UNSAFE_ASYNC_GENERATOR_ATTRIBUTES = set(['ag_code', 'ag_frame'])
+
 import warnings
 
 # make sure we don't warn in python 2.6 about stuff we don't care about
 warnings.filterwarnings('ignore', 'the sets module', DeprecationWarning,
                         module='jinja2.sandbox')
@@ -92,10 +106,53 @@ _mutable_spec = (
         'popleft', 'remove', 'rotate'
     ]))
 )
 
 
+class _MagicFormatMapping(Mapping):
+    """This class implements a dummy wrapper to fix a bug in the Python
+    standard library for string formatting.
+
+    See http://bugs.python.org/issue13598 for information about why
+    this is necessary.
+    """
+
+    def __init__(self, args, kwargs):
+        self._args = args
+        self._kwargs = kwargs
+        self._last_index = 0
+
+    def __getitem__(self, key):
+        if key == '':
+            idx = self._last_index
+            self._last_index += 1
+            try:
+                return self._args[idx]
+            except LookupError:
+                pass
+            key = str(idx)
+        return self._kwargs[key]
+
+    def __iter__(self):
+        return iter(self._kwargs)
+
+    def __len__(self):
+        return len(self._kwargs)
+
+
+def inspect_format_method(callable):
+    if not has_format:
+        return None
+    if not isinstance(callable, (types.MethodType,
+                                 types.BuiltinMethodType)) or \
+       callable.__name__ != 'format':
+        return None
+    obj = callable.__self__
+    if isinstance(obj, string_types):
+        return obj
+
+
 def safe_range(*args):
     """A range that can't generate ranges with a length of more than
     MAX_RANGE items.
     """
     rng = range(*args)
@@ -143,10 +200,16 @@ def is_internal_attribute(obj, attr):
     elif isinstance(obj, (types.CodeType, types.TracebackType, types.FrameType)):
         return True
     elif isinstance(obj, types.GeneratorType):
         if attr in UNSAFE_GENERATOR_ATTRIBUTES:
             return True
+    elif hasattr(types, 'CoroutineType') and isinstance(obj, types.CoroutineType):
+        if attr in UNSAFE_COROUTINE_ATTRIBUTES:
+            return True
+    elif hasattr(types, 'AsyncGeneratorType') and isinstance(obj, types.AsyncGeneratorType):
+        if attri in UNSAFE_ASYNC_GENERATOR_ATTRIBUTES:
+            return True
     return attr.startswith('__')
 
 
 def modifies_known_mutable(obj, attr):
     """This function checks if an attribute on a builtin mutable object
@@ -181,12 +244,12 @@ class SandboxedEnvironment(Environment):
     tells the compiler to generate sandboxed code.  Additionally subclasses of
     this environment may override the methods that tell the runtime what
     attributes or functions are safe to access.
 
     If the template tries to access insecure code a :exc:`SecurityError` is
-    raised.  However also other exceptions may occour during the rendering so
-    the caller has to ensure that all exceptions are catched.
+    raised.  However also other exceptions may occur during the rendering so
+    the caller has to ensure that all exceptions are caught.
     """
     sandboxed = True
 
     #: default callback table for the binary operators.  A copy of this is
     #: available on each instance of a sandboxed environment as
@@ -344,12 +407,28 @@ class SandboxedEnvironment(Environment):
                               'object is unsafe.' % (
             attribute,
             obj.__class__.__name__
         ), name=attribute, obj=obj, exc=SecurityError)
 
+    def format_string(self, s, args, kwargs):
+        """If a format call is detected, then this is routed through this
+        method so that our safety sandbox can be used for it.
+        """
+        if isinstance(s, Markup):
+            formatter = SandboxedEscapeFormatter(self, s.escape)
+        else:
+            formatter = SandboxedFormatter(self)
+        kwargs = _MagicFormatMapping(args, kwargs)
+        rv = formatter.vformat(s, args, kwargs)
+        return type(s)(rv)
+
     def call(__self, __context, __obj, *args, **kwargs):
         """Call an object from sandboxed code."""
+        fmt = inspect_format_method(__obj)
+        if fmt is not None:
+            return __self.format_string(fmt, args, kwargs)
+
         # the double prefixes are to avoid double keyword argument
         # errors when proxying the call.
         if not __self.is_safe_callable(__obj):
             raise SecurityError('%r is not safely callable' % (__obj,))
         return __context.call(__obj, *args, **kwargs)
@@ -363,5 +442,39 @@ class ImmutableSandboxedEnvironment(SandboxedEnvironment):
 
     def is_safe_attribute(self, obj, attr, value):
         if not SandboxedEnvironment.is_safe_attribute(self, obj, attr, value):
             return False
         return not modifies_known_mutable(obj, attr)
+
+
+if has_format:
+    # This really is not a public API apparenlty.
+    try:
+        from _string import formatter_field_name_split
+    except ImportError:
+        def formatter_field_name_split(field_name):
+            return field_name._formatter_field_name_split()
+
+    class SandboxedFormatterMixin(object):
+
+        def __init__(self, env):
+            self._env = env
+
+        def get_field(self, field_name, args, kwargs):
+            first, rest = formatter_field_name_split(field_name)
+            obj = self.get_value(first, args, kwargs)
+            for is_attr, i in rest:
+                if is_attr:
+                    obj = self._env.getattr(obj, i)
+                else:
+                    obj = self._env.getitem(obj, i)
+            return obj, first
+
+    class SandboxedFormatter(SandboxedFormatterMixin, Formatter):
+        def __init__(self, env):
+            SandboxedFormatterMixin.__init__(self, env)
+            Formatter.__init__(self)
+
+    class SandboxedEscapeFormatter(SandboxedFormatterMixin, EscapeFormatter):
+        def __init__(self, env, escape):
+            SandboxedFormatterMixin.__init__(self, env)
+            EscapeFormatter.__init__(self, escape)
diff --git a/tests/test_security.py b/tests/test_security.py
index e5b463f..b033864 100644
--- a/tests/test_security.py
+++ b/tests/test_security.py
@@ -10,11 +10,11 @@
 """
 import pytest
 
 from jinja2 import Environment
 from jinja2.sandbox import SandboxedEnvironment, \
-     ImmutableSandboxedEnvironment, unsafe
+     ImmutableSandboxedEnvironment, unsafe, has_format
 from jinja2 import Markup, escape
 from jinja2.exceptions import SecurityError, TemplateSyntaxError, \
      TemplateRuntimeError
 from jinja2._compat import text_type
 
@@ -157,5 +157,30 @@ class TestSandbox():
                 t.render(ctx)
             except TemplateRuntimeError as e:
                 pass
             else:
                 assert False, 'expected runtime error'
+
+
+@pytest.mark.sandbox
+@pytest.mark.skipif(not has_format, reason='No format support')
+class TestStringFormat(object):
+
+    def test_basic_format_safety(self):
+        env = SandboxedEnvironment()
+        t = env.from_string('{{ "a{0.__class__}b".format(42) }}')
+        assert t.render() == 'ab'
+
+    def test_basic_format_all_okay(self):
+        env = SandboxedEnvironment()
+        t = env.from_string('{{ "a{0.foo}b".format({"foo": 42}) }}')
+        assert t.render() == 'a42b'
+
+    def test_basic_format_safety(self):
+        env = SandboxedEnvironment()
+        t = env.from_string('{{ ("a{0.__class__}b{1}"|safe).format(42, "<foo>") }}')
+        assert t.render() == 'ab&lt;foo&gt;'
+
+    def test_basic_format_all_okay(self):
+        env = SandboxedEnvironment()
+        t = env.from_string('{{ ("a{0.foo}b{1}"|safe).format({"foo": 42}, "<foo>") }}')
+        assert t.render() == 'a42b&lt;foo&gt;'

Vulnerable code:         if eval_ctx.volatile:
from jinja2._compat import string_types, PY2
    raised.  However also other exceptions may occour during the rendering so
    the caller has to ensure that all exceptions are catched.
     ImmutableSandboxedEnvironment, unsafe
Fixed code:         if eval_ctx.volatile or eval_ctx.environment.sandboxed:
from collections import Mapping
from jinja2._compat import string_types, text_type, PY2
from jinja2.utils import Markup

has_format = False
if hasattr(text_type, 'format'):
    from markupsafe import EscapeFormatter
    from string import Formatter
    has_format = True
#: unsafe attributes on coroutines
UNSAFE_COROUTINE_ATTRIBUTES = set(['cr_frame', 'cr_code'])

#: unsafe attributes on async generators
UNSAFE_ASYNC_GENERATOR_ATTRIBUTES = set(['ag_code', 'ag_frame'])

class _MagicFormatMapping(Mapping):
    """This class implements a dummy wrapper to fix a bug in the Python
    standard library for string formatting.

    See http://bugs.python.org/issue13598 for information about why
    this is necessary.
    """

    def __init__(self, args, kwargs):
        self._args = args
        self._kwargs = kwargs
        self._last_index = 0

    def __getitem__(self, key):
        if key == '':
            idx = self._last_index
            self._last_index += 1
            try:
                return self._args[idx]
            except LookupError:
                pass
            key = str(idx)
        return self._kwargs[key]

    def __iter__(self):
        return iter(self._kwargs)

    def __len__(self):
        return len(self._kwargs)


def inspect_format_method(callable):
    if not has_format:
        return None
    if not isinstance(callable, (types.MethodType,
                                 types.BuiltinMethodType)) or \
       callable.__name__ != 'format':
        return None
    obj = callable.__self__
    if isinstance(obj, string_types):
        return obj


    elif hasattr(types, 'CoroutineType') and isinstance(obj, types.CoroutineType):
        if attr in UNSAFE_COROUTINE_ATTRIBUTES:
            return True
    elif hasattr(types, 'AsyncGeneratorType') and isinstance(obj, types.AsyncGeneratorType):
        if attri in UNSAFE_ASYNC_GENERATOR_ATTRIBUTES:
            return True
    raised.  However also other exceptions may occur during the rendering so
    the caller has to ensure that all exceptions are caught.
    def format_string(self, s, args, kwargs):
        """If a format call is detected, then this is routed through this
        method so that our safety sandbox can be used for it.
        """
        if isinstance(s, Markup):
            formatter = SandboxedEscapeFormatter(self, s.escape)
        else:
            formatter = SandboxedFormatter(self)
        kwargs = _MagicFormatMapping(args, kwargs)
        rv = formatter.vformat(s, args, kwargs)
        return type(s)(rv)

        fmt = inspect_format_method(__obj)
        if fmt is not None:
            return __self.format_string(fmt, args, kwargs)



if has_format:
    # This really is not a public API apparenlty.
    try:
        from _string import formatter_field_name_split
    except ImportError:
        def formatter_field_name_split(field_name):
            return field_name._formatter_field_name_split()

    class SandboxedFormatterMixin(object):

        def __init__(self, env):
            self._env = env

        def get_field(self, field_name, args, kwargs):
            first, rest = formatter_field_name_split(field_name)
            obj = self.get_value(first, args, kwargs)
            for is_attr, i in rest:
                if is_attr:
                    obj = self._env.getattr(obj, i)
                else:
                    obj = self._env.getitem(obj, i)
            return obj, first

    class SandboxedFormatter(SandboxedFormatterMixin, Formatter):
        def __init__(self, env):
            SandboxedFormatterMixin.__init__(self, env)
            Formatter.__init__(self)

    class SandboxedEscapeFormatter(SandboxedFormatterMixin, EscapeFormatter):
        def __init__(self, env, escape):
            SandboxedFormatterMixin.__init__(self, env)
            EscapeFormatter.__init__(self, escape)
     ImmutableSandboxedEnvironment, unsafe, has_format


@pytest.mark.sandbox
@pytest.mark.skipif(not has_format, reason='No format support')
class TestStringFormat(object):

    def test_basic_format_safety(self):
        env = SandboxedEnvironment()
        t = env.from_string('{{ "a{0.__class__}b".format(42) }}')
        assert t.render() == 'ab'

    def test_basic_format_all_okay(self):
        env = SandboxedEnvironment()
        t = env.from_string('{{ "a{0.foo}b".format({"foo": 42}) }}')
        assert t.render() == 'a42b'

    def test_basic_format_safety(self):
        env = SandboxedEnvironment()
        t = env.from_string('{{ ("a{0.__class__}b{1}"|safe).format(42, "<foo>") }}')
        assert t.render() == 'ab&lt;foo&gt;'

    def test_basic_format_all_okay(self):
        env = SandboxedEnvironment()
        t = env.from_string('{{ ("a{0.foo}b{1}"|safe).format({"foo": 42}, "<foo>") }}')
        assert t.render() == 'a42b&lt;foo&gt;'