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
This commit is contained in:
parent
00c7e760b3
commit
ae6a4fcc7a
@ -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));
|
||||
|
@ -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
|
||||
|
220
tests/python/bl_pyapi_bpy_driver_secure_eval.py
Normal file
220
tests/python/bl_pyapi_bpy_driver_secure_eval.py
Normal file
@ -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'{\"_\"}'")
|
||||
|
||||
|
||||
class TestRejectModuleAccess(unittest.TestCase, TestExprMixIn_Reject):
|
||||
# Each of these commands _must_ run `expect_os_unreachable`,
|
||||
# and must also be rejected as insecure - otherwise we have problems.
|
||||
expressions_expect_unreachable = True
|
||||
expressions = (
|
||||
"__import__('os').expect_os_unreachable()",
|
||||
"exec(\"__import__('os').expect_os_unreachable()\")",
|
||||
"(globals().update(__import__('os').__dict__), expect_os_unreachable())",
|
||||
)
|
||||
|
||||
# Ensure the functions are actually called.
|
||||
def setUp(self):
|
||||
self._count = expect_unreachable_count
|
||||
|
||||
def tearDown(self):
|
||||
count_actual = expect_unreachable_count - self._count
|
||||
count_expect = len(self.expressions)
|
||||
if count_actual != count_expect:
|
||||
raise Exception(
|
||||
"Expected 'os.expect_os_unreachable' to be called %d times but was called %d times" %
|
||||
(count_expect, count_actual),
|
||||
)
|
||||
|
||||
|
||||
class TestRejectOpenAccess(unittest.TestCase, TestExprMixIn_Reject):
|
||||
# Each of these commands _must_ run `expect_open_unreachable`,
|
||||
# and must also be rejected as insecure - otherwise we have problems.
|
||||
expressions_expect_unreachable = True
|
||||
expressions = (
|
||||
"open('file.txt', 'r')",
|
||||
"exec(\"open('file.txt', 'r')\")",
|
||||
"(globals().update({'fake_open': __builtins__['open']}), fake_open())",
|
||||
)
|
||||
|
||||
# Ensure the functions are actually called.
|
||||
def setUp(self):
|
||||
self._count = expect_open_unreachable_count
|
||||
|
||||
def tearDown(self):
|
||||
count_actual = expect_open_unreachable_count - self._count
|
||||
count_expect = len(self.expressions)
|
||||
if count_actual != count_expect:
|
||||
raise Exception(
|
||||
"Expected 'open' to be called %d times but was called %d times" %
|
||||
(count_expect, count_actual),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
sys.argv = [__file__] + (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [])
|
||||
unittest.main()
|
Loading…
Reference in New Issue
Block a user