[Bf-blender-cvs] [ae6a4fcc7a7] master: Tests: add test to ensure restricted py-driver execution is working

Campbell Barton noreply at git.blender.org
Tue Jul 12 08:14:47 CEST 2022


Commit: ae6a4fcc7a707e419463c47e191afe54def26764
Author: Campbell Barton
Date:   Tue Jul 12 16:05:15 2022 +1000
Branches: master
https://developer.blender.org/rBae6a4fcc7a707e419463c47e191afe54def26764

Tests: add test to ensure restricted py-driver execution is working

Add internal function (only used for testing at the moment)
`_bpy._driver_secure_code_test`.

Add test `script_pyapi_bpy_driver_secure_eval` to serves two purposes:

- Ensure expressions that should be insecure remain so when upgrading
  Python or making any changes in this area.

- Ensure new versions of Python don't introduce new byte-codes that
  prevent existing expressions from being executed
  (happened when upgrading from 3.7, see [0]).

[0]: dfa52017638abdf59791e5588c439d3a558a191d

===================================================================

M	source/blender/python/intern/bpy.c
M	tests/python/CMakeLists.txt
A	tests/python/bl_pyapi_bpy_driver_secure_eval.py

===================================================================

diff --git a/source/blender/python/intern/bpy.c b/source/blender/python/intern/bpy.c
index 2e97ae0fc1d..d1e8b894ac0 100644
--- a/source/blender/python/intern/bpy.c
+++ b/source/blender/python/intern/bpy.c
@@ -32,6 +32,7 @@
 #include "bpy.h"
 #include "bpy_app.h"
 #include "bpy_capi_utils.h"
+#include "bpy_driver.h"
 #include "bpy_library.h"
 #include "bpy_operator.h"
 #include "bpy_props.h"
@@ -326,6 +327,49 @@ static PyObject *bpy_resource_path(PyObject *UNUSED(self), PyObject *args, PyObj
   return PyC_UnicodeFromByte(path ? path : "");
 }
 
+/* This is only exposed for tests, see: `tests/python/bl_pyapi_bpy_driver_secure_eval.py`. */
+PyDoc_STRVAR(bpy_driver_secure_code_test_doc,
+             ".. function:: _driver_secure_code_test(code)\n"
+             "\n"
+             "   Test if the script should be considered trusted.\n"
+             "\n"
+             "   :arg code: The code to test.\n"
+             "   :type code: code\n"
+             "   :arg namespace: The namespace of values which are allowed.\n"
+             "   :type namespace: dict\n"
+             "   :arg verbose: Print the reason for considering insecure to the ``stderr``.\n"
+             "   :type verbose: bool\n"
+             "   :return: True when the script is considered trusted.\n"
+             "   :rtype: bool\n");
+static PyObject *bpy_driver_secure_code_test(PyObject *UNUSED(self), PyObject *args, PyObject *kw)
+{
+  PyObject *py_code;
+  PyObject *py_namespace = NULL;
+  const bool verbose = false;
+  static const char *_keywords[] = {"code", "namespace", "verbose", NULL};
+  static _PyArg_Parser _parser = {
+      "O!" /* `expression` */
+      "|$" /* Optional keyword only arguments. */
+      "O!" /* `namespace` */
+      "O&" /* `verbose` */
+      ":driver_secure_code_test",
+      _keywords,
+      0,
+  };
+  if (!_PyArg_ParseTupleAndKeywordsFast(args,
+                                        kw,
+                                        &_parser,
+                                        &PyCode_Type,
+                                        &py_code,
+                                        &PyDict_Type,
+                                        &py_namespace,
+                                        PyC_ParseBool,
+                                        &verbose)) {
+    return NULL;
+  }
+  return PyBool_FromLong(BPY_driver_secure_bytecode_test(py_code, py_namespace, verbose));
+}
+
 PyDoc_STRVAR(bpy_escape_identifier_doc,
              ".. function:: escape_identifier(string)\n"
              "\n"
@@ -528,6 +572,12 @@ static PyMethodDef meth_bpy_resource_path = {
     METH_VARARGS | METH_KEYWORDS,
     bpy_resource_path_doc,
 };
+static PyMethodDef meth_bpy_driver_secure_code_test = {
+    "_driver_secure_code_test",
+    (PyCFunction)bpy_driver_secure_code_test,
+    METH_VARARGS | METH_KEYWORDS,
+    bpy_driver_secure_code_test_doc,
+};
 static PyMethodDef meth_bpy_escape_identifier = {
     "escape_identifier",
     (PyCFunction)bpy_escape_identifier,
@@ -647,6 +697,9 @@ void BPy_init_modules(struct bContext *C)
   PyModule_AddObject(mod,
                      meth_bpy_resource_path.ml_name,
                      (PyObject *)PyCFunction_New(&meth_bpy_resource_path, NULL));
+  PyModule_AddObject(mod,
+                     meth_bpy_driver_secure_code_test.ml_name,
+                     (PyObject *)PyCFunction_New(&meth_bpy_driver_secure_code_test, NULL));
   PyModule_AddObject(mod,
                      meth_bpy_escape_identifier.ml_name,
                      (PyObject *)PyCFunction_New(&meth_bpy_escape_identifier, NULL));
diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt
index 38c3fc4389a..d95f2cd2644 100644
--- a/tests/python/CMakeLists.txt
+++ b/tests/python/CMakeLists.txt
@@ -101,6 +101,11 @@ add_blender_test(
   --python ${CMAKE_CURRENT_LIST_DIR}/bl_pyapi_mathutils.py
 )
 
+add_blender_test(
+  script_pyapi_bpy_driver_secure_eval
+  --python ${CMAKE_CURRENT_LIST_DIR}/bl_pyapi_bpy_driver_secure_eval.py
+)
+
 add_blender_test(
   script_pyapi_idprop
   --python ${CMAKE_CURRENT_LIST_DIR}/bl_pyapi_idprop.py
diff --git a/tests/python/bl_pyapi_bpy_driver_secure_eval.py b/tests/python/bl_pyapi_bpy_driver_secure_eval.py
new file mode 100644
index 00000000000..953dbcd5381
--- /dev/null
+++ b/tests/python/bl_pyapi_bpy_driver_secure_eval.py
@@ -0,0 +1,220 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+# ./blender.bin --background -noaudio --python tests/python/bl_pyapi_bpy_driver_secure_eval.py -- --verbose
+import bpy
+import unittest
+import builtins
+from types import ModuleType
+
+
+# -----------------------------------------------------------------------------
+# Mock Environment
+
+
+expect_unreachable_msg = "This function should _NEVER_ run!"
+# Internal check, to ensure this actually runs as expected.
+expect_unreachable_count = 0
+
+
+def expect_os_unreachable():
+    global expect_unreachable_count
+    expect_unreachable_count += 1
+    raise Exception(expect_unreachable_msg)
+
+
+__import__("os").expect_os_unreachable = expect_os_unreachable
+
+
+expect_open_unreachable_count = 0
+
+
+def open_expect_unreachable(*args, **kwargs):
+    global expect_open_unreachable_count
+    expect_open_unreachable_count += 1
+    raise Exception(expect_unreachable_msg)
+
+
+mock_builtins = {**builtins.__dict__, **{"open": open_expect_unreachable}}
+
+
+# -----------------------------------------------------------------------------
+# Utility Functions
+
+
+def is_expression_secure(expr_str, verbose):
+    """
+    Return (ok, code) where ok is true if expr_str is considered secure.
+    """
+    # Internal function only for testing (not part of the public API).
+    from _bpy import _driver_secure_code_test
+    expr_code = compile(expr_str, "<is_expression_secure>", 'eval')
+    ok = _driver_secure_code_test(expr_code, verbose=verbose)
+    return ok, expr_code
+
+
+# -----------------------------------------------------------------------------
+# Tests (Accept)
+
+
+class _TestExprMixIn:
+    """
+    Sub-classes must define:
+    - expressions_expect_secure: boolean, the expected secure state.
+    - expressions: A sequence of expressions that must evaluate in the driver name-space.
+
+    Optionally:
+    - expressions_expect_unreachable:
+      A boolean, when true, it's expected each expression should call
+    ``expect_os_unreachable`` or ``expect_open_unreachable``.
+    """
+
+    # Sub-class may override.
+    expressions_expect_unreachable = False
+
+    def assertSecure(self, expect_secure, expr_str):
+        is_secure, expr_code = is_expression_secure(
+            expr_str,
+            # Only verbose when secure as this is will result in an failure,
+            # in that case it's useful to know which op-codes caused the test to unexpectedly fail.
+            verbose=expect_secure,
+        )
+        if is_secure != expect_secure:
+            raise self.failureException(
+                "Expression \"%s\" was expected to be %s" %
+                (expr_str, "secure" if expect_secure else "insecure"))
+        # NOTE: executing is not essential, it's just better to ensure the expressions make sense.
+        try:
+            exec(
+                expr_code,
+                {"__builtins__": mock_builtins},
+                {**bpy.app.driver_namespace, **{"__builtins__": mock_builtins}},
+            )
+            # exec(expr_code, {}, bpy.app.driver_namespace)
+            ex = None
+        except BaseException as ex_test:
+            ex = ex_test
+
+        if self.expressions_expect_unreachable:
+            if ex and ex.args == (expect_unreachable_msg,):
+                ex = None
+            elif not ex:
+                raise self.failureException("Expression \"%s\" failed to run `os.expect_os_unreachable`" % (expr_str,))
+            else:
+                # An unknown exception was raised, use the exception below.
+                pass
+
+        if ex:
+            raise self.failureException("Expression \"%s\" failed to evaluate with error: %r" % (expr_str, ex))
+
+    def test_expr(self):
+        expect_secure = self.expressions_expect_secure
+        for expr_str in self.expressions:
+            self.assertSecure(expect_secure, expr_str)
+
+
+class TestExprMixIn_Accept(_TestExprMixIn):
+    expressions_expect_secure = True
+
+
+class TestExprMixIn_Reject(_TestExprMixIn):
+    expressions_expect_secure = False
+
+
+class TestAcceptLiteralNumbers(unittest.TestCase, TestExprMixIn_Accept):
+    expressions = ("1", "1_1", "1.1", "1j", "0x1", "0o1", "0b1")
+
+
+class TestAcceptLiteralStrings(unittest.TestCase, TestExprMixIn_Accept):
+    expressions = ("''", "'_'", "r''", "r'_'", "'''_'''")
+
+
+class TestAcceptSequencesEmpty(unittest.TestCase, TestExprMixIn_Accept):
+    expressions = ("()", "[]", "{}", "[[]]", "(())")
+
+
+class TestAcceptSequencesSimple(unittest.TestCase, TestExprMixIn_Accept):
+    expressions = ("('', '')", "['', '_']", "{'', '_'}", "{'': '_'}")
+
+
+class TestAcceptSequencesExpand(unittest.TestCase, TestExprMixIn_Accept):
+    expressions = ("(*('', '_'),)", "[*(), *[]]", "{*{1, 2}}")
+
+
+class TestAcceptSequencesComplex(unittest.TestCase, TestExprMixIn_Accept):
+    expressions = ("[1, 2, 3][-1:0:-1][0]", "1 in (1, 2)", "False if 1 in {1, 2} else True")
+
+
+class TestAcceptMathOperators(unittest.TestCase, TestExprMixIn_Accept):
+    expressions = ("4 / 4", "4 * 4", "4 // 4", "2 ** 2", "4 ^ -1", "4 & 1", "4 % 1")
+
+
+class TestAcceptMathFunctionsSimple(unittest.TestCase, TestExprMixIn_Accept):
+    expressions = ("sin(pi)", "degrees(pi / 2)", "clamp(4, 0, 1)")
+
+
+class TestAcceptMathFunctionsComplex(unittest.TestCase, TestExprMixIn_Accept):
+    expressions = ("-(sin(pi) ** 2) / 2", "floor(22 / 7)", "ceil(pi + 1)")
+
+
+# -----------------------------------------------------------------------------
+# Tests (Reject)
+
+class TestRejectLiteralFStrings(unittest.TestCase, TestExprMixIn_Reject):
+    # F-String's are not supported as `BUILD_STRING` op-code is disabled,
+    # while it may be safe to enable that needs to be double-checked.
+    # Further it doesn't seem useful for typical math expressions used in drivers.
+    expressions = ("f''", "f'{1}'", "f'{\"_\"}'")
+


@@ Diff output truncated at 10240 characters. @@



More information about the Bf-blender-cvs mailing list