Implemented operator intercepting
authorArmin Ronacher <armin.ronacher@active-4.com>
Mon, 29 Nov 2010 12:21:57 +0000 (13:21 +0100)
committerArmin Ronacher <armin.ronacher@active-4.com>
Mon, 29 Nov 2010 12:21:57 +0000 (13:21 +0100)
CHANGES
jinja2/compiler.py
jinja2/nodes.py
jinja2/sandbox.py
jinja2/testsuite/security.py

diff --git a/CHANGES b/CHANGES
index 590dd18d4a5c1b9e77a781a78cbafe9bef0723d4..b171ac29be1e4d1792ab7af7345cdcbb0cbac484 100644 (file)
--- a/CHANGES
+++ b/CHANGES
@@ -9,6 +9,10 @@ Version 2.6
   of returning an undefined.  This fixes problems when passing undefined
   objects to Python semantics expecting APIs.
 - traceback support now works properly for PyPy.  (Tested with 1.4)
+- implemented operator intercepting for sandboxed environments.  This
+  allows application developers to disable builtin operators for better
+  security.  (For instance limit the mathematical operators to actual
+  integers instead of longs)
 
 Version 2.5.5
 -------------
index 50618a842ae32bbc070b06d862454221677bf94d..657a8bcf045786ea7032776562394ca851c721e0 100644 (file)
@@ -1421,19 +1421,31 @@ class CodeGenerator(NodeVisitor):
             self.visit(item.value, frame)
         self.write('}')
 
-    def binop(operator):
+    def binop(operator, interceptable=True):
         def visitor(self, node, frame):
-            self.write('(')
-            self.visit(node.left, frame)
-            self.write(' %s ' % operator)
-            self.visit(node.right, frame)
+            if self.environment.sandboxed and \
+               operator in self.environment.intercepted_binops:
+                self.write('environment.call_binop(context, %r, ' % operator)
+                self.visit(node.left, frame)
+                self.write(', ')
+                self.visit(node.right, frame)
+            else:
+                self.write('(')
+                self.visit(node.left, frame)
+                self.write(' %s ' % operator)
+                self.visit(node.right, frame)
             self.write(')')
         return visitor
 
-    def uaop(operator):
+    def uaop(operator, interceptable=True):
         def visitor(self, node, frame):
-            self.write('(' + operator)
-            self.visit(node.node, frame)
+            if self.environment.sandboxed and \
+               operator in self.environment.intercepted_unops:
+                self.write('environment.call_unop(context, %r, ' % operator)
+                self.visit(node.node, frame)
+            else:
+                self.write('(' + operator)
+                self.visit(node.node, frame)
             self.write(')')
         return visitor
 
@@ -1444,11 +1456,11 @@ class CodeGenerator(NodeVisitor):
     visit_FloorDiv = binop('//')
     visit_Pow = binop('**')
     visit_Mod = binop('%')
-    visit_And = binop('and')
-    visit_Or = binop('or')
+    visit_And = binop('and', interceptable=False)
+    visit_Or = binop('or', interceptable=False)
     visit_Pos = uaop('+')
     visit_Neg = uaop('-')
-    visit_Not = uaop('not ')
+    visit_Not = uaop('not ', interceptable=False)
     del binop, uaop
 
     def visit_Concat(self, node, frame):
index 7c6b358a550546d2fb56edd70fbe87aca26f4a57..c18d299c83817c6911c024addbcd8a3aa29fc284 100644 (file)
@@ -372,6 +372,10 @@ class BinExpr(Expr):
 
     def as_const(self, eval_ctx=None):
         eval_ctx = get_eval_context(self, eval_ctx)
+        # intercepted operators cannot be folded at compile time
+        if self.environment.sandboxed and \
+           self.operator in self.environment.intercepted_binops:
+            raise Impossible()
         f = _binop_to_func[self.operator]
         try:
             return f(self.left.as_const(eval_ctx), self.right.as_const(eval_ctx))
@@ -387,6 +391,10 @@ class UnaryExpr(Expr):
 
     def as_const(self, eval_ctx=None):
         eval_ctx = get_eval_context(self, eval_ctx)
+        # intercepted operators cannot be folded at compile time
+        if self.environment.sandboxed and \
+           self.operator in self.environment.intercepted_unops:
+            raise Impossible()
         f = _uaop_to_func[self.operator]
         try:
             return f(self.node.as_const(eval_ctx))
index 0c9f573262f4b802676187f848d7fca851ca1137..3136f648dbf93a323e082234d4b9c70f5cb3394d 100644 (file)
@@ -13,7 +13,6 @@
     :license: BSD.
 """
 import operator
-from jinja2.runtime import Undefined
 from jinja2.environment import Environment
 from jinja2.exceptions import SecurityError
 from jinja2.utils import FunctionType, MethodType, TracebackType, CodeType, \
@@ -182,9 +181,81 @@ class SandboxedEnvironment(Environment):
     """
     sandboxed = True
 
+    #: default callback table for the binary operators.  A copy of this is
+    #: available on each instance of a sandboxed environment as
+    #: :attr:`binop_table`
+    default_binop_table = {
+        '+':        operator.add,
+        '-':        operator.sub,
+        '*':        operator.mul,
+        '/':        operator.truediv,
+        '//':       operator.floordiv,
+        '**':       operator.pow,
+        '%':        operator.mod
+    }
+
+    #: default callback table for the unary operators.  A copy of this is
+    #: available on each instance of a sandboxed environment as
+    #: :attr:`unop_table`
+    default_unop_table = {
+        '+':        operator.pos,
+        '-':        operator.neg
+    }
+
+    #: a set of binary operators that should be intercepted.  Each operator
+    #: that is added to this set (empty by default) is delegated to the
+    #: :meth:`call_binop` method that will perform the operator.  The default
+    #: operator callback is specified by :attr:`binop_table`.
+    #:
+    #: The following binary operators are interceptable:
+    #: ``//``, ``%``, ``+``, ``*``, ``-``, ``/``, and ``**``
+    #:
+    #: The default operation form the operator table corresponds to the
+    #: builtin function.  Intercepted calls are always slower than the native
+    #: operator call, so make sure only to intercept the ones you are
+    #: interested in.
+    #:
+    #: .. versionadded:: 2.6
+    intercepted_binops = frozenset()
+
+    #: a set of unary operators that should be intercepted.  Each operator
+    #: that is added to this set (empty by default) is delegated to the
+    #: :meth:`call_unop` method that will perform the operator.  The default
+    #: operator callback is specified by :attr:`unop_table`.
+    #:
+    #: The following unary operators are interceptable: ``+``, ``-``
+    #:
+    #: The default operation form the operator table corresponds to the
+    #: builtin function.  Intercepted calls are always slower than the native
+    #: operator call, so make sure only to intercept the ones you are
+    #: interested in.
+    #:
+    #: .. versionadded:: 2.6
+    intercepted_unops = frozenset()
+
+    def intercept_unop(self, operator):
+        """Called during template compilation with the name of a unary
+        operator to check if it should be intercepted at runtime.  If this
+        method returns `True`, :meth:`call_unop` is excuted for this unary
+        operator.  The default implementation of :meth:`call_unop` will use
+        the :attr:`unop_table` dictionary to perform the operator with the
+        same logic as the builtin one.
+
+        The following unary operators are interceptable: ``+`` and ``-``
+
+        Intercepted calls are always slower than the native operator call,
+        so make sure only to intercept the ones you are interested in.
+
+        .. versionadded:: 2.6
+        """
+        return False
+
+
     def __init__(self, *args, **kwargs):
         Environment.__init__(self, *args, **kwargs)
         self.globals['range'] = safe_range
+        self.binop_table = self.default_binop_table.copy()
+        self.unop_table = self.default_unop_table.copy()
 
     def is_safe_attribute(self, obj, attr, value):
         """The sandboxed environment will call this method to check if the
@@ -204,6 +275,24 @@ class SandboxedEnvironment(Environment):
         return not (getattr(obj, 'unsafe_callable', False) or
                     getattr(obj, 'alters_data', False))
 
+    def call_binop(self, context, operator, left, right):
+        """For intercepted binary operator calls (:meth:`intercepted_binops`)
+        this function is executed instead of the builtin operator.  This can
+        be used to fine tune the behavior of certain operators.
+
+        .. versionadded:: 2.6
+        """
+        return self.binop_table[operator](left, right)
+
+    def call_unop(self, context, operator, arg):
+        """For intercepted unary operator calls (:meth:`intercepted_unops`)
+        this function is executed instead of the builtin operator.  This can
+        be used to fine tune the behavior of certain operators.
+
+        .. versionadded:: 2.6
+        """
+        return self.unop_table[operator](arg)
+
     def getitem(self, obj, argument):
         """Subscribe an object from sandboxed code."""
         try:
index bc100950edd1d183920f5a0c7cd8852e0ec450aa..4518eac659fc33b2284f16e76c4a67b47572fcae 100644 (file)
@@ -16,7 +16,8 @@ from jinja2 import Environment
 from jinja2.sandbox import SandboxedEnvironment, \
      ImmutableSandboxedEnvironment, unsafe
 from jinja2 import Markup, escape
-from jinja2.exceptions import SecurityError, TemplateSyntaxError
+from jinja2.exceptions import SecurityError, TemplateSyntaxError, \
+     TemplateRuntimeError
 
 
 class PrivateStuff(object):
@@ -123,6 +124,40 @@ class SandboxTestCase(JinjaTestCase):
         tmpl = env.from_string('{{ cls|attr("__subclasses__")() }}')
         self.assert_raises(SecurityError, tmpl.render, cls=int)
 
+    def test_binary_operator_intercepting(self):
+        def disable_op(left, right):
+            raise TemplateRuntimeError('that operator so does not work')
+        for expr, ctx, rv in ('1 + 2', {}, '3'), ('a + 2', {'a': 2}, '4'):
+            env = SandboxedEnvironment()
+            env.binop_table['+'] = disable_op
+            t = env.from_string('{{ %s }}' % expr)
+            assert t.render(ctx) == rv
+            env.intercepted_binops = frozenset(['+'])
+            t = env.from_string('{{ %s }}' % expr)
+            try:
+                t.render(ctx)
+            except TemplateRuntimeError, e:
+                pass
+            else:
+                self.fail('expected runtime error')
+
+    def test_unary_operator_intercepting(self):
+        def disable_op(arg):
+            raise TemplateRuntimeError('that operator so does not work')
+        for expr, ctx, rv in ('-1', {}, '-1'), ('-a', {'a': 2}, '-2'):
+            env = SandboxedEnvironment()
+            env.unop_table['-'] = disable_op
+            t = env.from_string('{{ %s }}' % expr)
+            assert t.render(ctx) == rv
+            env.intercepted_unops = frozenset(['-'])
+            t = env.from_string('{{ %s }}' % expr)
+            try:
+                t.render(ctx)
+            except TemplateRuntimeError, e:
+                pass
+            else:
+                self.fail('expected runtime error')
+
 
 def suite():
     suite = unittest.TestSuite()