##// END OF EJS Templates
Increase coverage of `guard_eval`
krassowski -
Show More
@@ -996,7 +996,8 b' class Completer(Configurable):'
996
996
997 - ``forbidden``: no evaluation of code is permitted,
997 - ``forbidden``: no evaluation of code is permitted,
998 - ``minimal``: evaluation of literals and access to built-in namespace;
998 - ``minimal``: evaluation of literals and access to built-in namespace;
999 no item/attribute evaluation nor access to locals/globals,
999 no item/attribute evaluationm no access to locals/globals,
1000 no evaluation of any operations or comparisons.
1000 - ``limited``: access to all namespaces, evaluation of hard-coded methods
1001 - ``limited``: access to all namespaces, evaluation of hard-coded methods
1001 (for example: :any:`dict.keys`, :any:`object.__getattr__`,
1002 (for example: :any:`dict.keys`, :any:`object.__getattr__`,
1002 :any:`object.__getitem__`) on allow-listed objects (for example:
1003 :any:`object.__getitem__`) on allow-listed objects (for example:
@@ -108,6 +108,7 b' class EvaluationPolicy:'
108 return True
108 return True
109
109
110 owner_method = _unbind_method(func)
110 owner_method = _unbind_method(func)
111
111 if owner_method and owner_method in self.allowed_calls:
112 if owner_method and owner_method in self.allowed_calls:
112 return True
113 return True
113
114
@@ -127,6 +128,10 b' def _has_original_dunder_external('
127 value_type = type(value)
128 value_type = type(value)
128 if type(value) == member_type:
129 if type(value) == member_type:
129 return True
130 return True
131 if method_name == "__getattribute__":
132 # we have to short-circuit here due to an unresolved issue in
133 # `isinstance` implementation: https://bugs.python.org/issue32683
134 return False
130 if isinstance(value, member_type):
135 if isinstance(value, member_type):
131 method = getattr(value_type, method_name, None)
136 method = getattr(value_type, method_name, None)
132 member_method = getattr(member_type, method_name, None)
137 member_method = getattr(member_type, method_name, None)
@@ -149,7 +154,7 b' def _has_original_dunder('
149
154
150 method = getattr(value_type, method_name, None)
155 method = getattr(value_type, method_name, None)
151
156
152 if not method:
157 if method is None:
153 return None
158 return None
154
159
155 if method in allowed_methods:
160 if method in allowed_methods:
@@ -193,6 +198,7 b' class SelectivePolicy(EvaluationPolicy):'
193 allowed_external=self.allowed_getattr_external,
198 allowed_external=self.allowed_getattr_external,
194 method_name="__getattr__",
199 method_name="__getattr__",
195 )
200 )
201
196 # Many objects do not have `__getattr__`, this is fine
202 # Many objects do not have `__getattr__`, this is fine
197 if has_original_attr is None and has_original_attribute:
203 if has_original_attr is None and has_original_attribute:
198 return True
204 return True
@@ -200,10 +206,6 b' class SelectivePolicy(EvaluationPolicy):'
200 # Accept objects without modifications to `__getattr__` and `__getattribute__`
206 # Accept objects without modifications to `__getattr__` and `__getattribute__`
201 return has_original_attr and has_original_attribute
207 return has_original_attr and has_original_attribute
202
208
203 def get_attr(self, value, attr):
204 if self.can_get_attr(value, attr):
205 return getattr(value, attr)
206
207 def can_get_item(self, value, item):
209 def can_get_item(self, value, item):
208 """Allow accessing `__getiitem__` of allow-listed instances unless it was not modified."""
210 """Allow accessing `__getiitem__` of allow-listed instances unless it was not modified."""
209 return _has_original_dunder(
211 return _has_original_dunder(
@@ -215,20 +217,24 b' class SelectivePolicy(EvaluationPolicy):'
215 )
217 )
216
218
217 def can_operate(self, dunders: Tuple[str, ...], a, b=None):
219 def can_operate(self, dunders: Tuple[str, ...], a, b=None):
220 objects = [a]
221 if b is not None:
222 objects.append(b)
218 return all(
223 return all(
219 [
224 [
220 _has_original_dunder(
225 _has_original_dunder(
221 a,
226 obj,
222 allowed_types=self.allowed_operations,
227 allowed_types=self.allowed_operations,
223 allowed_methods=self._dunder_methods(dunder),
228 allowed_methods=self._operator_dunder_methods(dunder),
224 allowed_external=self.allowed_operations_external,
229 allowed_external=self.allowed_operations_external,
225 method_name=dunder,
230 method_name=dunder,
226 )
231 )
227 for dunder in dunders
232 for dunder in dunders
233 for obj in objects
228 ]
234 ]
229 )
235 )
230
236
231 def _dunder_methods(self, dunder: str) -> Set[Callable]:
237 def _operator_dunder_methods(self, dunder: str) -> Set[Callable]:
232 if dunder not in self._operation_methods_cache:
238 if dunder not in self._operation_methods_cache:
233 self._operation_methods_cache[dunder] = self._safe_get_methods(
239 self._operation_methods_cache[dunder] = self._safe_get_methods(
234 self.allowed_operations, dunder
240 self.allowed_operations, dunder
@@ -257,7 +263,7 b' class SelectivePolicy(EvaluationPolicy):'
257
263
258
264
259 class _DummyNamedTuple(NamedTuple):
265 class _DummyNamedTuple(NamedTuple):
260 pass
266 """Used internally to retrieve methods of named tuple instance."""
261
267
262
268
263 class EvaluationContext(NamedTuple):
269 class EvaluationContext(NamedTuple):
@@ -451,12 +457,15 b' def eval_node(node: Union[ast.AST, None], context: EvaluationContext):'
451 f"not allowed in {context.evaluation} mode",
457 f"not allowed in {context.evaluation} mode",
452 )
458 )
453 else:
459 else:
454 raise ValueError(f"Comparison `{dunder}` not supported")
460 raise ValueError(
461 f"Comparison `{dunder}` not supported"
462 ) # pragma: no cover
455 return all_true
463 return all_true
456 if isinstance(node, ast.Constant):
464 if isinstance(node, ast.Constant):
457 return node.value
465 return node.value
458 if isinstance(node, ast.Index):
466 if isinstance(node, ast.Index):
459 return eval_node(node.value, context)
467 # deprecated since Python 3.9
468 return eval_node(node.value, context) # pragma: no cover
460 if isinstance(node, ast.Tuple):
469 if isinstance(node, ast.Tuple):
461 return tuple(eval_node(e, context) for e in node.elts)
470 return tuple(eval_node(e, context) for e in node.elts)
462 if isinstance(node, ast.List):
471 if isinstance(node, ast.List):
@@ -477,7 +486,8 b' def eval_node(node: Union[ast.AST, None], context: EvaluationContext):'
477 eval_node(node.step, context),
486 eval_node(node.step, context),
478 )
487 )
479 if isinstance(node, ast.ExtSlice):
488 if isinstance(node, ast.ExtSlice):
480 return tuple([eval_node(dim, context) for dim in node.dims])
489 # deprecated since Python 3.9
490 return tuple([eval_node(dim, context) for dim in node.dims]) # pragma: no cover
481 if isinstance(node, ast.UnaryOp):
491 if isinstance(node, ast.UnaryOp):
482 value = eval_node(node.operand, context)
492 value = eval_node(node.operand, context)
483 dunders = _find_dunder(node.op, UNARY_OP_DUNDERS)
493 dunders = _find_dunder(node.op, UNARY_OP_DUNDERS)
@@ -490,7 +500,6 b' def eval_node(node: Union[ast.AST, None], context: EvaluationContext):'
490 type(value),
500 type(value),
491 f"not allowed in {context.evaluation} mode",
501 f"not allowed in {context.evaluation} mode",
492 )
502 )
493 raise ValueError("Unhandled unary operation:", node.op)
494 if isinstance(node, ast.Subscript):
503 if isinstance(node, ast.Subscript):
495 value = eval_node(node.value, context)
504 value = eval_node(node.value, context)
496 slice_ = eval_node(node.slice, context)
505 slice_ = eval_node(node.slice, context)
@@ -507,14 +516,14 b' def eval_node(node: Union[ast.AST, None], context: EvaluationContext):'
507 if policy.allow_globals_access and node.id in context.globals:
516 if policy.allow_globals_access and node.id in context.globals:
508 return context.globals[node.id]
517 return context.globals[node.id]
509 if policy.allow_builtins_access and hasattr(builtins, node.id):
518 if policy.allow_builtins_access and hasattr(builtins, node.id):
510 # note: do not use __builtins__, it is implementation detail of Python
519 # note: do not use __builtins__, it is implementation detail of cPython
511 return getattr(builtins, node.id)
520 return getattr(builtins, node.id)
512 if not policy.allow_globals_access and not policy.allow_locals_access:
521 if not policy.allow_globals_access and not policy.allow_locals_access:
513 raise GuardRejection(
522 raise GuardRejection(
514 f"Namespace access not allowed in {context.evaluation} mode"
523 f"Namespace access not allowed in {context.evaluation} mode"
515 )
524 )
516 else:
525 else:
517 raise NameError(f"{node.id} not found in locals nor globals")
526 raise NameError(f"{node.id} not found in locals, globals, nor builtins")
518 if isinstance(node, ast.Attribute):
527 if isinstance(node, ast.Attribute):
519 value = eval_node(node.value, context)
528 value = eval_node(node.value, context)
520 if policy.can_get_attr(value, node.attr):
529 if policy.can_get_attr(value, node.attr):
@@ -540,7 +549,7 b' def eval_node(node: Union[ast.AST, None], context: EvaluationContext):'
540 func, # not joined to avoid calling `repr`
549 func, # not joined to avoid calling `repr`
541 f"not allowed in {context.evaluation} mode",
550 f"not allowed in {context.evaluation} mode",
542 )
551 )
543 raise ValueError("Unhandled node", node)
552 raise ValueError("Unhandled node", ast.dump(node))
544
553
545
554
546 SUPPORTED_EXTERNAL_GETITEM = {
555 SUPPORTED_EXTERNAL_GETITEM = {
@@ -552,6 +561,7 b' SUPPORTED_EXTERNAL_GETITEM = {'
552 ("numpy", "void"),
561 ("numpy", "void"),
553 }
562 }
554
563
564
555 BUILTIN_GETITEM: Set[InstancesHaveGetItem] = {
565 BUILTIN_GETITEM: Set[InstancesHaveGetItem] = {
556 dict,
566 dict,
557 str,
567 str,
@@ -583,6 +593,8 b' set_non_mutating_methods = set(dir(set)) & set(dir(frozenset))'
583 dict_keys: Type[collections.abc.KeysView] = type({}.keys())
593 dict_keys: Type[collections.abc.KeysView] = type({}.keys())
584 method_descriptor: Any = type(list.copy)
594 method_descriptor: Any = type(list.copy)
585
595
596 NUMERICS = {int, float, complex}
597
586 ALLOWED_CALLS = {
598 ALLOWED_CALLS = {
587 bytes,
599 bytes,
588 *_list_methods(bytes),
600 *_list_methods(bytes),
@@ -600,6 +612,8 b' ALLOWED_CALLS = {'
600 *_list_methods(str),
612 *_list_methods(str),
601 tuple,
613 tuple,
602 *_list_methods(tuple),
614 *_list_methods(tuple),
615 *NUMERICS,
616 *[method for numeric_cls in NUMERICS for method in _list_methods(numeric_cls)],
603 collections.deque,
617 collections.deque,
604 *_list_methods(collections.deque, list_non_mutating_methods),
618 *_list_methods(collections.deque, list_non_mutating_methods),
605 collections.defaultdict,
619 collections.defaultdict,
@@ -624,12 +638,13 b' BUILTIN_GETATTR: Set[MayHaveGetattr] = {'
624 frozenset,
638 frozenset,
625 object,
639 object,
626 type, # `type` handles a lot of generic cases, e.g. numbers as in `int.real`.
640 type, # `type` handles a lot of generic cases, e.g. numbers as in `int.real`.
641 *NUMERICS,
627 dict_keys,
642 dict_keys,
628 method_descriptor,
643 method_descriptor,
629 }
644 }
630
645
631
646
632 BUILTIN_OPERATIONS = {int, float, complex, *BUILTIN_GETATTR}
647 BUILTIN_OPERATIONS = {*BUILTIN_GETATTR}
633
648
634 EVALUATION_POLICIES = {
649 EVALUATION_POLICIES = {
635 "minimal": EvaluationPolicy(
650 "minimal": EvaluationPolicy(
@@ -1,4 +1,5 b''
1 from typing import NamedTuple
1 from typing import NamedTuple
2 from functools import partial
2 from IPython.core.guarded_eval import (
3 from IPython.core.guarded_eval import (
3 EvaluationContext,
4 EvaluationContext,
4 GuardRejection,
5 GuardRejection,
@@ -9,12 +10,19 b' from IPython.testing import decorators as dec'
9 import pytest
10 import pytest
10
11
11
12
12 def limited(**kwargs):
13 def create_context(evaluation: str, **kwargs):
13 return EvaluationContext(locals=kwargs, globals={}, evaluation="limited")
14 return EvaluationContext(locals=kwargs, globals={}, evaluation=evaluation)
14
15
15
16
16 def unsafe(**kwargs):
17 forbidden = partial(create_context, "forbidden")
17 return EvaluationContext(locals=kwargs, globals={}, evaluation="unsafe")
18 minimal = partial(create_context, "minimal")
19 limited = partial(create_context, "limited")
20 unsafe = partial(create_context, "unsafe")
21 dangerous = partial(create_context, "dangerous")
22
23 LIMITED_OR_HIGHER = [limited, unsafe, dangerous]
24
25 MINIMAL_OR_HIGHER = [minimal, *LIMITED_OR_HIGHER]
18
26
19
27
20 @dec.skip_without("pandas")
28 @dec.skip_without("pandas")
@@ -142,7 +150,7 b' def test_set_literal():'
142 assert guarded_eval('{"a"}', context) == {"a"}
150 assert guarded_eval('{"a"}', context) == {"a"}
143
151
144
152
145 def test_if_expression():
153 def test_evaluates_if_expression():
146 context = limited()
154 context = limited()
147 assert guarded_eval("2 if True else 3", context) == 2
155 assert guarded_eval("2 if True else 3", context) == 2
148 assert guarded_eval("4 if False else 5", context) == 5
156 assert guarded_eval("4 if False else 5", context) == 5
@@ -178,7 +186,7 b' def test_method_descriptor():'
178 [{"a": 1}, "data.keys().isdisjoint({})", "data.update()", True],
186 [{"a": 1}, "data.keys().isdisjoint({})", "data.update()", True],
179 ],
187 ],
180 )
188 )
181 def test_calls(data, good, bad, expected):
189 def test_evaluates_calls(data, good, bad, expected):
182 context = limited(data=data)
190 context = limited(data=data)
183 assert guarded_eval(good, context) == expected
191 assert guarded_eval(good, context) == expected
184
192
@@ -194,9 +202,26 b' def test_calls(data, good, bad, expected):'
194 ["list(range(20))[3:-2:3]", [3, 6, 9, 12, 15]],
202 ["list(range(20))[3:-2:3]", [3, 6, 9, 12, 15]],
195 ],
203 ],
196 )
204 )
197 def test_literals(code, expected):
205 @pytest.mark.parametrize("context", LIMITED_OR_HIGHER)
198 context = limited()
206 def test_evaluates_complex_cases(code, expected, context):
199 assert guarded_eval(code, context) == expected
207 assert guarded_eval(code, context()) == expected
208
209
210 @pytest.mark.parametrize(
211 "code,expected",
212 [
213 ["1", 1],
214 ["1.0", 1.0],
215 ["0xdeedbeef", 0xDEEDBEEF],
216 ["True", True],
217 ["None", None],
218 ["{}", {}],
219 ["[]", []],
220 ],
221 )
222 @pytest.mark.parametrize("context", MINIMAL_OR_HIGHER)
223 def test_evaluates_literals(code, expected, context):
224 assert guarded_eval(code, context()) == expected
200
225
201
226
202 @pytest.mark.parametrize(
227 @pytest.mark.parametrize(
@@ -207,9 +232,9 b' def test_literals(code, expected):'
207 ["~5", -6],
232 ["~5", -6],
208 ],
233 ],
209 )
234 )
210 def test_unary_operations(code, expected):
235 @pytest.mark.parametrize("context", LIMITED_OR_HIGHER)
211 context = limited()
236 def test_evaluates_unary_operations(code, expected, context):
212 assert guarded_eval(code, context) == expected
237 assert guarded_eval(code, context()) == expected
213
238
214
239
215 @pytest.mark.parametrize(
240 @pytest.mark.parametrize(
@@ -228,9 +253,9 b' def test_unary_operations(code, expected):'
228 ["1 & 2", 0],
253 ["1 & 2", 0],
229 ],
254 ],
230 )
255 )
231 def test_binary_operations(code, expected):
256 @pytest.mark.parametrize("context", LIMITED_OR_HIGHER)
232 context = limited()
257 def test_evaluates_binary_operations(code, expected, context):
233 assert guarded_eval(code, context) == expected
258 assert guarded_eval(code, context()) == expected
234
259
235
260
236 @pytest.mark.parametrize(
261 @pytest.mark.parametrize(
@@ -262,16 +287,152 b' def test_binary_operations(code, expected):'
262 ["True is True", True],
287 ["True is True", True],
263 ["False is False", True],
288 ["False is False", True],
264 ["True is False", False],
289 ["True is False", False],
290 ["True is not True", False],
291 ["False is not True", True],
265 ],
292 ],
266 )
293 )
267 def test_comparisons(code, expected):
294 @pytest.mark.parametrize("context", LIMITED_OR_HIGHER)
268 context = limited()
295 def test_evaluates_comparisons(code, expected, context):
269 assert guarded_eval(code, context) == expected
296 assert guarded_eval(code, context()) == expected
297
298
299 def test_guards_comparisons():
300 class GoodEq(int):
301 pass
302
303 class BadEq(int):
304 def __eq__(self, other):
305 assert False
306
307 context = limited(bad=BadEq(1), good=GoodEq(1))
308
309 with pytest.raises(GuardRejection):
310 guarded_eval("bad == 1", context)
311
312 with pytest.raises(GuardRejection):
313 guarded_eval("bad != 1", context)
314
315 with pytest.raises(GuardRejection):
316 guarded_eval("1 == bad", context)
317
318 with pytest.raises(GuardRejection):
319 guarded_eval("1 != bad", context)
320
321 assert guarded_eval("good == 1", context) is True
322 assert guarded_eval("good != 1", context) is False
323 assert guarded_eval("1 == good", context) is True
324 assert guarded_eval("1 != good", context) is False
325
326
327 def test_guards_unary_operations():
328 class GoodOp(int):
329 pass
330
331 class BadOpInv(int):
332 def __inv__(self, other):
333 assert False
334
335 class BadOpInverse(int):
336 def __inv__(self, other):
337 assert False
338
339 context = limited(good=GoodOp(1), bad1=BadOpInv(1), bad2=BadOpInverse(1))
340
341 with pytest.raises(GuardRejection):
342 guarded_eval("~bad1", context)
343
344 with pytest.raises(GuardRejection):
345 guarded_eval("~bad2", context)
346
347
348 def test_guards_binary_operations():
349 class GoodOp(int):
350 pass
270
351
352 class BadOp(int):
353 def __add__(self, other):
354 assert False
271
355
272 def test_access_builtins():
356 context = limited(good=GoodOp(1), bad=BadOp(1))
357
358 with pytest.raises(GuardRejection):
359 guarded_eval("1 + bad", context)
360
361 with pytest.raises(GuardRejection):
362 guarded_eval("bad + 1", context)
363
364 assert guarded_eval("good + 1", context) == 2
365 assert guarded_eval("1 + good", context) == 2
366
367
368 def test_guards_attributes():
369 class GoodAttr(float):
370 pass
371
372 class BadAttr1(float):
373 def __getattr__(self, key):
374 assert False
375
376 class BadAttr2(float):
377 def __getattribute__(self, key):
378 assert False
379
380 context = limited(good=GoodAttr(0.5), bad1=BadAttr1(0.5), bad2=BadAttr2(0.5))
381
382 with pytest.raises(GuardRejection):
383 guarded_eval("bad1.as_integer_ratio", context)
384
385 with pytest.raises(GuardRejection):
386 guarded_eval("bad2.as_integer_ratio", context)
387
388 assert guarded_eval("good.as_integer_ratio()", context) == (1, 2)
389
390
391 @pytest.mark.parametrize("context", MINIMAL_OR_HIGHER)
392 def test_access_builtins(context):
393 assert guarded_eval("round", context()) == round
394
395
396 def test_access_builtins_fails():
273 context = limited()
397 context = limited()
274 assert guarded_eval("round", context) == round
398 with pytest.raises(NameError):
399 guarded_eval("this_is_not_builtin", context)
400
401
402 def test_rejects_forbidden():
403 context = forbidden()
404 with pytest.raises(GuardRejection):
405 guarded_eval("1", context)
406
407
408 def test_guards_locals_and_globals():
409 context = EvaluationContext(
410 locals={"local_a": "a"}, globals={"global_b": "b"}, evaluation="minimal"
411 )
412
413 with pytest.raises(GuardRejection):
414 guarded_eval("local_a", context)
415
416 with pytest.raises(GuardRejection):
417 guarded_eval("global_b", context)
418
419
420 def test_access_locals_and_globals():
421 context = EvaluationContext(
422 locals={"local_a": "a"}, globals={"global_b": "b"}, evaluation="limited"
423 )
424 assert guarded_eval("local_a", context) == "a"
425 assert guarded_eval("global_b", context) == "b"
426
427
428 @pytest.mark.parametrize(
429 "code",
430 ["def func(): pass", "class C: pass", "x = 1", "x += 1", "del x", "import ast"],
431 )
432 @pytest.mark.parametrize("context", [minimal(), limited(), unsafe()])
433 def test_rejects_side_effect_syntax(code, context):
434 with pytest.raises(SyntaxError):
435 guarded_eval(code, context)
275
436
276
437
277 def test_subscript():
438 def test_subscript():
General Comments 0
You need to be logged in to leave comments. Login now