Show More
@@ -996,7 +996,8 b' class Completer(Configurable):' | |||
|
996 | 996 | |
|
997 | 997 | - ``forbidden``: no evaluation of code is permitted, |
|
998 | 998 | - ``minimal``: evaluation of literals and access to built-in namespace; |
|
999 |
no item/attribute evaluation no |
|
|
999 | no item/attribute evaluationm no access to locals/globals, | |
|
1000 | no evaluation of any operations or comparisons. | |
|
1000 | 1001 | - ``limited``: access to all namespaces, evaluation of hard-coded methods |
|
1001 | 1002 | (for example: :any:`dict.keys`, :any:`object.__getattr__`, |
|
1002 | 1003 | :any:`object.__getitem__`) on allow-listed objects (for example: |
@@ -108,6 +108,7 b' class EvaluationPolicy:' | |||
|
108 | 108 | return True |
|
109 | 109 | |
|
110 | 110 | owner_method = _unbind_method(func) |
|
111 | ||
|
111 | 112 | if owner_method and owner_method in self.allowed_calls: |
|
112 | 113 | return True |
|
113 | 114 | |
@@ -127,6 +128,10 b' def _has_original_dunder_external(' | |||
|
127 | 128 | value_type = type(value) |
|
128 | 129 | if type(value) == member_type: |
|
129 | 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 | 135 | if isinstance(value, member_type): |
|
131 | 136 | method = getattr(value_type, method_name, None) |
|
132 | 137 | member_method = getattr(member_type, method_name, None) |
@@ -149,7 +154,7 b' def _has_original_dunder(' | |||
|
149 | 154 | |
|
150 | 155 | method = getattr(value_type, method_name, None) |
|
151 | 156 | |
|
152 |
if |
|
|
157 | if method is None: | |
|
153 | 158 | return None |
|
154 | 159 | |
|
155 | 160 | if method in allowed_methods: |
@@ -193,6 +198,7 b' class SelectivePolicy(EvaluationPolicy):' | |||
|
193 | 198 | allowed_external=self.allowed_getattr_external, |
|
194 | 199 | method_name="__getattr__", |
|
195 | 200 | ) |
|
201 | ||
|
196 | 202 | # Many objects do not have `__getattr__`, this is fine |
|
197 | 203 | if has_original_attr is None and has_original_attribute: |
|
198 | 204 | return True |
@@ -200,10 +206,6 b' class SelectivePolicy(EvaluationPolicy):' | |||
|
200 | 206 | # Accept objects without modifications to `__getattr__` and `__getattribute__` |
|
201 | 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 | 209 | def can_get_item(self, value, item): |
|
208 | 210 | """Allow accessing `__getiitem__` of allow-listed instances unless it was not modified.""" |
|
209 | 211 | return _has_original_dunder( |
@@ -215,20 +217,24 b' class SelectivePolicy(EvaluationPolicy):' | |||
|
215 | 217 | ) |
|
216 | 218 | |
|
217 | 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 | 223 | return all( |
|
219 | 224 | [ |
|
220 | 225 | _has_original_dunder( |
|
221 |
|
|
|
226 | obj, | |
|
222 | 227 | allowed_types=self.allowed_operations, |
|
223 | allowed_methods=self._dunder_methods(dunder), | |
|
228 | allowed_methods=self._operator_dunder_methods(dunder), | |
|
224 | 229 | allowed_external=self.allowed_operations_external, |
|
225 | 230 | method_name=dunder, |
|
226 | 231 | ) |
|
227 | 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 | 238 | if dunder not in self._operation_methods_cache: |
|
233 | 239 | self._operation_methods_cache[dunder] = self._safe_get_methods( |
|
234 | 240 | self.allowed_operations, dunder |
@@ -257,7 +263,7 b' class SelectivePolicy(EvaluationPolicy):' | |||
|
257 | 263 | |
|
258 | 264 | |
|
259 | 265 | class _DummyNamedTuple(NamedTuple): |
|
260 | pass | |
|
266 | """Used internally to retrieve methods of named tuple instance.""" | |
|
261 | 267 | |
|
262 | 268 | |
|
263 | 269 | class EvaluationContext(NamedTuple): |
@@ -451,12 +457,15 b' def eval_node(node: Union[ast.AST, None], context: EvaluationContext):' | |||
|
451 | 457 | f"not allowed in {context.evaluation} mode", |
|
452 | 458 | ) |
|
453 | 459 | else: |
|
454 |
raise ValueError( |
|
|
460 | raise ValueError( | |
|
461 | f"Comparison `{dunder}` not supported" | |
|
462 | ) # pragma: no cover | |
|
455 | 463 | return all_true |
|
456 | 464 | if isinstance(node, ast.Constant): |
|
457 | 465 | return node.value |
|
458 | 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 | 469 | if isinstance(node, ast.Tuple): |
|
461 | 470 | return tuple(eval_node(e, context) for e in node.elts) |
|
462 | 471 | if isinstance(node, ast.List): |
@@ -477,7 +486,8 b' def eval_node(node: Union[ast.AST, None], context: EvaluationContext):' | |||
|
477 | 486 | eval_node(node.step, context), |
|
478 | 487 | ) |
|
479 | 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 | 491 | if isinstance(node, ast.UnaryOp): |
|
482 | 492 | value = eval_node(node.operand, context) |
|
483 | 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 | 500 | type(value), |
|
491 | 501 | f"not allowed in {context.evaluation} mode", |
|
492 | 502 | ) |
|
493 | raise ValueError("Unhandled unary operation:", node.op) | |
|
494 | 503 | if isinstance(node, ast.Subscript): |
|
495 | 504 | value = eval_node(node.value, context) |
|
496 | 505 | slice_ = eval_node(node.slice, context) |
@@ -507,14 +516,14 b' def eval_node(node: Union[ast.AST, None], context: EvaluationContext):' | |||
|
507 | 516 | if policy.allow_globals_access and node.id in context.globals: |
|
508 | 517 | return context.globals[node.id] |
|
509 | 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 | 520 | return getattr(builtins, node.id) |
|
512 | 521 | if not policy.allow_globals_access and not policy.allow_locals_access: |
|
513 | 522 | raise GuardRejection( |
|
514 | 523 | f"Namespace access not allowed in {context.evaluation} mode" |
|
515 | 524 | ) |
|
516 | 525 | else: |
|
517 |
raise NameError(f"{node.id} not found in locals |
|
|
526 | raise NameError(f"{node.id} not found in locals, globals, nor builtins") | |
|
518 | 527 | if isinstance(node, ast.Attribute): |
|
519 | 528 | value = eval_node(node.value, context) |
|
520 | 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 | 549 | func, # not joined to avoid calling `repr` |
|
541 | 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 | 555 | SUPPORTED_EXTERNAL_GETITEM = { |
@@ -552,6 +561,7 b' SUPPORTED_EXTERNAL_GETITEM = {' | |||
|
552 | 561 | ("numpy", "void"), |
|
553 | 562 | } |
|
554 | 563 | |
|
564 | ||
|
555 | 565 | BUILTIN_GETITEM: Set[InstancesHaveGetItem] = { |
|
556 | 566 | dict, |
|
557 | 567 | str, |
@@ -583,6 +593,8 b' set_non_mutating_methods = set(dir(set)) & set(dir(frozenset))' | |||
|
583 | 593 | dict_keys: Type[collections.abc.KeysView] = type({}.keys()) |
|
584 | 594 | method_descriptor: Any = type(list.copy) |
|
585 | 595 | |
|
596 | NUMERICS = {int, float, complex} | |
|
597 | ||
|
586 | 598 | ALLOWED_CALLS = { |
|
587 | 599 | bytes, |
|
588 | 600 | *_list_methods(bytes), |
@@ -600,6 +612,8 b' ALLOWED_CALLS = {' | |||
|
600 | 612 | *_list_methods(str), |
|
601 | 613 | tuple, |
|
602 | 614 | *_list_methods(tuple), |
|
615 | *NUMERICS, | |
|
616 | *[method for numeric_cls in NUMERICS for method in _list_methods(numeric_cls)], | |
|
603 | 617 | collections.deque, |
|
604 | 618 | *_list_methods(collections.deque, list_non_mutating_methods), |
|
605 | 619 | collections.defaultdict, |
@@ -624,12 +638,13 b' BUILTIN_GETATTR: Set[MayHaveGetattr] = {' | |||
|
624 | 638 | frozenset, |
|
625 | 639 | object, |
|
626 | 640 | type, # `type` handles a lot of generic cases, e.g. numbers as in `int.real`. |
|
641 | *NUMERICS, | |
|
627 | 642 | dict_keys, |
|
628 | 643 | method_descriptor, |
|
629 | 644 | } |
|
630 | 645 | |
|
631 | 646 | |
|
632 |
BUILTIN_OPERATIONS = { |
|
|
647 | BUILTIN_OPERATIONS = {*BUILTIN_GETATTR} | |
|
633 | 648 | |
|
634 | 649 | EVALUATION_POLICIES = { |
|
635 | 650 | "minimal": EvaluationPolicy( |
@@ -1,4 +1,5 b'' | |||
|
1 | 1 | from typing import NamedTuple |
|
2 | from functools import partial | |
|
2 | 3 | from IPython.core.guarded_eval import ( |
|
3 | 4 | EvaluationContext, |
|
4 | 5 | GuardRejection, |
@@ -9,12 +10,19 b' from IPython.testing import decorators as dec' | |||
|
9 | 10 | import pytest |
|
10 | 11 | |
|
11 | 12 | |
|
12 | def limited(**kwargs): | |
|
13 |
return EvaluationContext(locals=kwargs, globals={}, evaluation= |
|
|
13 | def create_context(evaluation: str, **kwargs): | |
|
14 | return EvaluationContext(locals=kwargs, globals={}, evaluation=evaluation) | |
|
14 | 15 | |
|
15 | 16 | |
|
16 | def unsafe(**kwargs): | |
|
17 | return EvaluationContext(locals=kwargs, globals={}, evaluation="unsafe") | |
|
17 | forbidden = partial(create_context, "forbidden") | |
|
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 | 28 | @dec.skip_without("pandas") |
@@ -142,7 +150,7 b' def test_set_literal():' | |||
|
142 | 150 | assert guarded_eval('{"a"}', context) == {"a"} |
|
143 | 151 | |
|
144 | 152 | |
|
145 | def test_if_expression(): | |
|
153 | def test_evaluates_if_expression(): | |
|
146 | 154 | context = limited() |
|
147 | 155 | assert guarded_eval("2 if True else 3", context) == 2 |
|
148 | 156 | assert guarded_eval("4 if False else 5", context) == 5 |
@@ -178,7 +186,7 b' def test_method_descriptor():' | |||
|
178 | 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 | 190 | context = limited(data=data) |
|
183 | 191 | assert guarded_eval(good, context) == expected |
|
184 | 192 | |
@@ -194,9 +202,26 b' def test_calls(data, good, bad, expected):' | |||
|
194 | 202 | ["list(range(20))[3:-2:3]", [3, 6, 9, 12, 15]], |
|
195 | 203 | ], |
|
196 | 204 | ) |
|
197 | def test_literals(code, expected): | |
|
198 | context = limited() | |
|
199 | assert guarded_eval(code, context) == expected | |
|
205 | @pytest.mark.parametrize("context", LIMITED_OR_HIGHER) | |
|
206 | def test_evaluates_complex_cases(code, expected, context): | |
|
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 | 227 | @pytest.mark.parametrize( |
@@ -207,9 +232,9 b' def test_literals(code, expected):' | |||
|
207 | 232 | ["~5", -6], |
|
208 | 233 | ], |
|
209 | 234 | ) |
|
210 | def test_unary_operations(code, expected): | |
|
211 | context = limited() | |
|
212 | assert guarded_eval(code, context) == expected | |
|
235 | @pytest.mark.parametrize("context", LIMITED_OR_HIGHER) | |
|
236 | def test_evaluates_unary_operations(code, expected, context): | |
|
237 | assert guarded_eval(code, context()) == expected | |
|
213 | 238 | |
|
214 | 239 | |
|
215 | 240 | @pytest.mark.parametrize( |
@@ -228,9 +253,9 b' def test_unary_operations(code, expected):' | |||
|
228 | 253 | ["1 & 2", 0], |
|
229 | 254 | ], |
|
230 | 255 | ) |
|
231 | def test_binary_operations(code, expected): | |
|
232 | context = limited() | |
|
233 | assert guarded_eval(code, context) == expected | |
|
256 | @pytest.mark.parametrize("context", LIMITED_OR_HIGHER) | |
|
257 | def test_evaluates_binary_operations(code, expected, context): | |
|
258 | assert guarded_eval(code, context()) == expected | |
|
234 | 259 | |
|
235 | 260 | |
|
236 | 261 | @pytest.mark.parametrize( |
@@ -262,16 +287,152 b' def test_binary_operations(code, expected):' | |||
|
262 | 287 | ["True is True", True], |
|
263 | 288 | ["False is False", True], |
|
264 | 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): | |
|
268 | context = limited() | |
|
269 | assert guarded_eval(code, context) == expected | |
|
294 | @pytest.mark.parametrize("context", LIMITED_OR_HIGHER) | |
|
295 | def test_evaluates_comparisons(code, expected, context): | |
|
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 | 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 | 438 | def test_subscript(): |
General Comments 0
You need to be logged in to leave comments.
Login now