Skip to content

Commit 1a753ba

Browse files
authored
Resolve attribute annotations under the policy in eval_node (#15337)
**Arbitrary code execution from class annotations in eval_node** The attribute branch falls back to `typing.get_type_hints()` after the policy has already refused the attribute, and that call resolves stringized annotations with `eval()`, so completing `obj.attr` runs whatever the annotation contains under the default `limited` policy. Collecting the raw annotations with `inspect.get_annotations(eval_str=False)` and resolving them through `eval_node` keeps annotation completion working while routing the expression through the same policy checks as the rest of the input.
2 parents 6056996 + 876b5aa commit 1a753ba

2 files changed

Lines changed: 47 additions & 15 deletions

File tree

‎IPython/core/guarded_eval.py‎

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from copy import copy
2-
from inspect import isclass, signature, Signature, getmodule
2+
from inspect import get_annotations, isclass, signature, Signature, getmodule
33
from typing import (
44
Annotated,
55
AnyStr,
@@ -29,7 +29,7 @@
2929
from IPython.utils.decorators import undoc
3030

3131
import types
32-
from typing import Self, LiteralString, get_type_hints
32+
from typing import Self, LiteralString
3333

3434
if sys.version_info < (3, 12):
3535
from typing_extensions import TypeAliasType
@@ -1125,14 +1125,9 @@ def dummy_function(*args, **kwargs):
11251125
value if isinstance(value, type) else getattr(value, "__class__", None)
11261126
)
11271127
if cls is not None:
1128-
resolved_hints = get_type_hints(
1129-
cls,
1130-
globalns=(context.globals or {}),
1131-
localns=(context.locals or {}),
1132-
)
1133-
if node.attr in resolved_hints:
1134-
annotated = resolved_hints[node.attr]
1135-
return _resolve_annotation(annotated, context)
1128+
hints = _collect_annotations(cls)
1129+
if node.attr in hints:
1130+
return _resolve_annotation(hints[node.attr], context)
11361131
except Exception:
11371132
# Fall through to the guard rejection
11381133
pass
@@ -1319,15 +1314,27 @@ def _eval_return_type(func: Callable, node: ast.Call, context: EvaluationContext
13191314
return NOT_EVALUATED
13201315

13211316

1317+
def _collect_annotations(cls: type) -> dict:
1318+
"""Collect annotations of a class and its bases without resolving them.
1319+
1320+
`typing.get_type_hints()` is not usable here because it resolves stringized
1321+
annotations with `eval()`; under PEP 563 every annotation of a module is a
1322+
string, so that would run arbitrary code from `__annotations__`. Strings are
1323+
left as-is and later resolved by `_eval_annotation` under the policy.
1324+
"""
1325+
annotations: dict = {}
1326+
for base in reversed(cls.__mro__):
1327+
annotations.update(get_annotations(base, eval_str=False))
1328+
return annotations
1329+
1330+
13221331
def _eval_annotation(
13231332
annotation: str,
13241333
context: EvaluationContext,
13251334
):
1326-
return (
1327-
_eval_node_name(annotation, context)
1328-
if isinstance(annotation, str)
1329-
else annotation
1330-
)
1335+
if not isinstance(annotation, str):
1336+
return annotation
1337+
return eval_node(ast.parse(annotation, mode="eval").body, context)
13311338

13321339

13331340
class _GetItemDuck(dict):

‎tests/test_guarded_eval.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,31 @@ def iloc(self):
122122
guarded_eval("data.iloc[0]", context)
123123

124124

125+
def test_rejects_stringized_annotations():
126+
"""Annotations are arbitrary expressions and must be resolved under the policy.
127+
128+
Every annotation of a module using PEP 563 is a string, so resolving them
129+
eagerly would run whatever the string spells out.
130+
"""
131+
called = []
132+
133+
def side_effect():
134+
called.append(1)
135+
return int
136+
137+
class Custom:
138+
attr: "side_effect()"
139+
140+
def __getattr__(self, key):
141+
raise AssertionError("should not be called")
142+
143+
context = limited(data=Custom(), side_effect=side_effect)
144+
145+
with pytest.raises(GuardRejection):
146+
guarded_eval("data.attr", context)
147+
assert called == []
148+
149+
125150
@dec.skip_without("pandas")
126151
def test_accepts_non_overriden_properties():
127152
import pandas as pd

0 commit comments

Comments
 (0)