|
11 | 11 | import paraview |
12 | 12 | import vtkmodules.numpy_interface.dataset_adapter as dsa |
13 | 13 | from vtkmodules.numpy_interface.algorithms import * |
| 14 | +import vtkmodules.numpy_interface.algorithms as _alg |
14 | 15 | # -- this will import vtkMultiProcessController and vtkMPI4PyCommunicator |
15 | 16 |
|
16 | 17 | from paraview.vtk import vtkDataObject, vtkDoubleArray, vtkSelectionNode, vtkSelection, vtkStreamingDemandDrivenPipeline |
17 | 18 | from paraview.modules import vtkPVVTKExtensionsFiltersPython |
18 | 19 | from paraview.vtk.util.numpy_support import get_numpy_array_type |
19 | 20 | import textwrap |
| 21 | +import os |
| 22 | +import ast |
| 23 | + |
| 24 | +# Optional secure mode: when PARAVIEW_CALCULATOR_SECURE is set to a truthy |
| 25 | +# value, expressions are validated using a conservative AST whitelist to |
| 26 | +# mitigate arbitrary code execution via the Python Calculator. |
| 27 | +_CALCULATOR_SECURE = os.environ.get("PARAVIEW_CALCULATOR_SECURE", "").lower() in ["1", "true", "yes", "on"] |
| 28 | + |
| 29 | +# A small allowlist of builtin functions considered safe/commonly used in |
| 30 | +# calculator expressions. This list can be extended cautiously. |
| 31 | +_SAFE_BUILTINS = { |
| 32 | + 'abs': abs, |
| 33 | + 'min': min, |
| 34 | + 'max': max, |
| 35 | + 'len': len, |
| 36 | + 'sum': sum, |
| 37 | + 'float': float, |
| 38 | + 'int': int, |
| 39 | + 'pow': pow, |
| 40 | + 'range': range, |
| 41 | +} |
| 42 | + |
| 43 | +# Allowlist of numpy functions accessible via np.<func> in secure mode |
| 44 | +_SAFE_NUMPY_FUNCS = { |
| 45 | + 'sin', 'cos', 'tan', 'arcsin', 'arccos', 'arctan', |
| 46 | + 'sinh', 'cosh', 'tanh', |
| 47 | + 'exp', 'log', 'log10', 'log1p', 'sqrt', |
| 48 | + 'abs', 'fabs', 'sign', 'clip', 'round', 'floor', 'ceil', |
| 49 | + 'minimum', 'maximum', |
| 50 | + 'where', 'select', |
| 51 | + 'mean', 'sum', 'prod', 'std', 'var', |
| 52 | + 'min', 'max', |
| 53 | + 'radians', 'degrees' |
| 54 | +} |
| 55 | + |
| 56 | +# Whitelisted AST node types for expressions. We intentionally exclude |
| 57 | +# Import/Attribute access starting with underscores, function/lambda |
| 58 | +# definitions, comprehensions that could leak scope, etc. This list is |
| 59 | +# deliberately tight; broaden only with a security review. |
| 60 | +_ALLOWED_AST_NODES = ( |
| 61 | + ast.Expression, |
| 62 | + ast.BoolOp, |
| 63 | + ast.BinOp, |
| 64 | + ast.UnaryOp, |
| 65 | + ast.IfExp, |
| 66 | + ast.Compare, |
| 67 | + ast.Call, |
| 68 | + ast.Num, # Py <3.8 |
| 69 | + ast.Constant, |
| 70 | + ast.Name, |
| 71 | + ast.Load, |
| 72 | + ast.Attribute, |
| 73 | + ast.Subscript, |
| 74 | + ast.Slice, |
| 75 | + ast.Tuple, |
| 76 | + ast.List, |
| 77 | + ast.Dict, |
| 78 | + ast.Set, |
| 79 | + ast.ListComp, |
| 80 | + ast.SetComp, |
| 81 | + ast.DictComp, |
| 82 | + ast.GeneratorExp, |
| 83 | + ast.comprehension, |
| 84 | + ast.And, |
| 85 | + ast.Or, |
| 86 | + ast.Add, |
| 87 | + ast.Sub, |
| 88 | + ast.Mult, |
| 89 | + ast.Div, |
| 90 | + ast.FloorDiv, |
| 91 | + ast.Mod, |
| 92 | + ast.Pow, |
| 93 | + ast.USub, |
| 94 | + ast.UAdd, |
| 95 | + ast.Eq, |
| 96 | + ast.NotEq, |
| 97 | + ast.Lt, |
| 98 | + ast.LtE, |
| 99 | + ast.Gt, |
| 100 | + ast.GtE, |
| 101 | + ast.Is, |
| 102 | + ast.IsNot, |
| 103 | + ast.In, |
| 104 | + ast.NotIn, |
| 105 | +) |
| 106 | + |
| 107 | +_DISALLOWED_NAMES = {"__import__", "eval", "exec", "open", "input", "compile", "globals", "locals", "vars"} |
| 108 | + |
| 109 | + |
| 110 | +def _collect_safe_helpers(): |
| 111 | + """Collect a conservative set of helper functions/names that are allowed |
| 112 | + in secure mode expressions. This includes algorithms.* functions and |
| 113 | + numpy under np/numpy aliases; excludes dangerous modules like os.""" |
| 114 | + helpers = {} |
| 115 | + # algorithms functions |
| 116 | + try: |
| 117 | + for _name in dir(_alg): |
| 118 | + if _name.startswith('_'): |
| 119 | + continue |
| 120 | + _obj = getattr(_alg, _name) |
| 121 | + if callable(_obj): |
| 122 | + helpers[_name] = _obj |
| 123 | + except Exception: |
| 124 | + # If introspection fails, fall back to empty set (safer) |
| 125 | + pass |
| 126 | + # numpy shortcuts |
| 127 | + helpers['np'] = np |
| 128 | + helpers['numpy'] = np |
| 129 | + # selected safe helpers from this module |
| 130 | + for _name in ('pointIsNear', 'cellContainsPoint'): |
| 131 | + if _name in globals() and callable(globals()[_name]): |
| 132 | + helpers[_name] = globals()[_name] |
| 133 | + return helpers |
| 134 | + |
| 135 | + |
| 136 | +def _build_safe_globals(): |
| 137 | + g = {"__builtins__": _SAFE_BUILTINS} |
| 138 | + g.update(_collect_safe_helpers()) |
| 139 | + return g |
| 140 | + |
| 141 | + |
| 142 | +def _validate_ast(tree): |
| 143 | + allowed_call_names = set(_SAFE_BUILTINS.keys()) | set(_collect_safe_helpers().keys()) |
| 144 | + for node in ast.walk(tree): |
| 145 | + if not isinstance(node, _ALLOWED_AST_NODES): |
| 146 | + raise ValueError("Expression contains disallowed construct: %s" % type(node).__name__) |
| 147 | + # Disallow any Name that is clearly unsafe |
| 148 | + if isinstance(node, ast.Name): |
| 149 | + if node.id in _DISALLOWED_NAMES or node.id.startswith('_'): |
| 150 | + raise ValueError("Use of disallowed name '%s' in expression" % node.id) |
| 151 | + # Disallow attribute access to dunder/private attributes |
| 152 | + if isinstance(node, ast.Attribute): |
| 153 | + if node.attr.startswith('_'): |
| 154 | + raise ValueError("Access to private attribute '%s' not allowed" % node.attr) |
| 155 | + # Constrain function call targets |
| 156 | + if isinstance(node, ast.Call): |
| 157 | + fn = node.func |
| 158 | + if isinstance(fn, ast.Name): |
| 159 | + if fn.id not in allowed_call_names: |
| 160 | + raise ValueError("Call to disallowed function '%s'" % fn.id) |
| 161 | + elif isinstance(fn, ast.Attribute): |
| 162 | + # Allow attribute calls only on numpy aliases (np / numpy) |
| 163 | + if not isinstance(fn.value, ast.Name) or fn.value.id not in {"np", "numpy"}: |
| 164 | + raise ValueError("Method calls are not allowed in secure mode") |
| 165 | + if fn.attr.startswith('_'): |
| 166 | + raise ValueError("Access to private attribute '%s' not allowed" % fn.attr) |
| 167 | + if fn.attr not in _SAFE_NUMPY_FUNCS: |
| 168 | + raise ValueError("Call to disallowed numpy function '%s'" % fn.attr) |
| 169 | + else: |
| 170 | + raise ValueError("Unsupported callable in expression") |
| 171 | + return True |
| 172 | + |
| 173 | + |
| 174 | +def _validate_exec_block(tree: ast.AST): |
| 175 | + """Validate a multiline exec block: allow only simple assignments, bare |
| 176 | + expressions, and a final return, with each expression validated via |
| 177 | + _validate_ast. Reject any other statements.""" |
| 178 | + if not isinstance(tree, ast.Module): |
| 179 | + raise ValueError("Invalid multiline block") |
| 180 | + for stmt in tree.body: |
| 181 | + if isinstance(stmt, ast.Return): |
| 182 | + # Validate return value expression |
| 183 | + if stmt.value is None: |
| 184 | + continue |
| 185 | + _validate_ast(ast.Expression(stmt.value)) |
| 186 | + elif isinstance(stmt, ast.Assign): |
| 187 | + # Validate assigned value only; names are checked by expression validator |
| 188 | + _validate_ast(ast.Expression(stmt.value)) |
| 189 | + elif isinstance(stmt, ast.Expr): |
| 190 | + # Bare expression line |
| 191 | + if stmt.value is not None: |
| 192 | + _validate_ast(ast.Expression(stmt.value)) |
| 193 | + else: |
| 194 | + raise ValueError("Disallowed statement in secure multiline expression: %s" % type(stmt).__name__) |
| 195 | + |
| 196 | + |
| 197 | +def safe_eval(expression, eval_globals, eval_locals): |
| 198 | + """Evaluate an expression safely using a restricted AST whitelist. |
| 199 | +
|
| 200 | + This does NOT guarantee perfect safety but blocks the most direct RCE |
| 201 | + primitives (e.g., __import__, dunder attribute traversal, eval/exec).""" |
| 202 | + tree = ast.parse(expression, mode='eval') |
| 203 | + _validate_ast(tree) |
| 204 | + # Provide a restricted builtin dict |
| 205 | + # Ignore passed-in globals in secure mode to avoid leaking modules |
| 206 | + g = _build_safe_globals() |
| 207 | + return eval(compile(tree, filename='<calculator-secure>', mode='eval'), g, eval_locals) |
20 | 208 |
|
21 | 209 |
|
22 | 210 | def get_arrays(attribs, controller=None): |
@@ -143,24 +331,38 @@ def compute(inputs, expression, ns=None, multiline=False): |
143 | 331 | pass |
144 | 332 |
|
145 | 333 | if multiline: |
146 | | - # Wrap multiline expressions returning a value in a function, and evaluate it. |
147 | | - if "return" not in expression: |
148 | | - raise ValueError( |
149 | | - "Multiline expression does not contain a return statement.") |
150 | | - |
151 | | - multilineFunction = f'def func():\n' \ |
152 | | - f'{textwrap.indent(expression, " " * 4)}\n' \ |
153 | | - f'result = func()\n' |
154 | | - returnValueDict = {} |
155 | | - |
156 | | - # `mylocals` need to be in the global `exec` scope, otherwise it would not be accessible inside the `func` scope |
157 | | - exec(multilineFunction, dict(globals(), **mylocals), returnValueDict) |
158 | | - |
159 | | - return returnValueDict['result'] |
| 334 | + if _CALCULATOR_SECURE: |
| 335 | + # Validate the entire exec block and every expression within |
| 336 | + tree = ast.parse(expression, mode='exec') |
| 337 | + _validate_exec_block(tree) |
| 338 | + multilineFunction = f'def func():\n' \ |
| 339 | + f'{textwrap.indent(expression, " " * 4)}\n' \ |
| 340 | + f'result = func()\n' |
| 341 | + returnValueDict = {} |
| 342 | + g = _build_safe_globals() |
| 343 | + # Expose dataset variables into the function's globals |
| 344 | + g.update(mylocals) |
| 345 | + exec(multilineFunction, g, returnValueDict) |
| 346 | + return returnValueDict['result'] |
| 347 | + else: |
| 348 | + # Original insecure path |
| 349 | + if "return" not in expression: |
| 350 | + raise ValueError( |
| 351 | + "Multiline expression does not contain a return statement.") |
| 352 | + |
| 353 | + multilineFunction = f'def func():\n' \ |
| 354 | + f'{textwrap.indent(expression, " " * 4)}\n' \ |
| 355 | + f'result = func()\n' |
| 356 | + returnValueDict = {} |
| 357 | + exec(multilineFunction, dict(globals(), **mylocals), returnValueDict) |
| 358 | + return returnValueDict['result'] |
160 | 359 | else: |
161 | 360 | finalRet = None |
162 | | - for subEx in expression.split(' and '): # Used in 'extract_selection' to find data matching multiple criteria |
163 | | - retVal = eval(subEx, globals(), mylocals) |
| 361 | + for subEx in expression.split(' and '): |
| 362 | + if _CALCULATOR_SECURE: |
| 363 | + retVal = safe_eval(subEx, None, mylocals) |
| 364 | + else: |
| 365 | + retVal = eval(subEx, globals(), mylocals) |
164 | 366 | if finalRet is None: |
165 | 367 | finalRet = retVal |
166 | 368 | else: |
@@ -250,7 +452,7 @@ def execute(self, expression, multiline=False): |
250 | 452 | vtkRet = retVal.astype(get_numpy_array_type(self.GetResultArrayType())) |
251 | 453 | else: |
252 | 454 | # we can also get a scalar, convert to single element array of correct type |
253 | | - vtkRet = numpy.asarray(retVal, get_numpy_array_type(self.GetResultArrayType())) |
| 455 | + vtkRet = np.asarray(retVal, get_numpy_array_type(self.GetResultArrayType())) |
254 | 456 |
|
255 | 457 | # by default, use filter ArrayAssociation for output attribute. |
256 | 458 | outputAttribute = output.GetAttributes(self.GetArrayAssociation()) |
|
0 commit comments