Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 78 additions & 4 deletions src/codeweaver/core/di/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import ast
import asyncio
import inspect
import logging
Expand Down Expand Up @@ -81,7 +82,81 @@
self._shutdown_hooks: list[Callable[..., Any]] = []
self._cleanup_stack: AsyncExitStack | None = None
self._request_cache: dict[Any, Any] = {} # Keys can be types or callables
self._providers_loaded: bool = False # Track if auto-discovery has run # Track if auto-discovery has run # Track if auto-discovery has run
self._providers_loaded: bool = False # Track if auto-discovery has run

def _safe_eval_type(self, type_str: str, globalns: dict[str, Any]) -> Any:
"""Safely evaluate a type string using AST validation.

Args:
type_str: The string representation of a type.
globalns: The global namespace for evaluation.

Returns:
The evaluated type object.

Raises:
ValueError: If the type string contains forbidden constructs.
"""
try:
tree = ast.parse(type_str, mode="eval")
except SyntaxError:
return None
Comment on lines +97 to +103

class TypeValidator(ast.NodeVisitor):
def generic_visit(self, node: ast.AST) -> None:
# Allowed nodes for type annotations, including support for:
# - Generics: List[int], dict[str, Any] (Subscript, Name, Attribute)
# - Unions: int | str (BinOp, BitOr)
# - Annotated: Annotated[int, Depends(...)] (Call, keyword, Tuple, List)
# - Literals: Literal["foo"] (Constant)
if not isinstance(
node,
(
Comment on lines +105 to +114
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Current validation still allows arbitrary function calls and attribute access from globalns, which may undercut the "safe" aspect of _safe_eval_type if type_str is user-influenced.

Because ast.Call and ast.Attribute are allowed on anything in globalns, a user-controlled type_str could call or inspect any object exposed there (e.g., os.system or DI helpers), despite __builtins__ being removed.

To harden this:

  • Only allow calls to a small, explicit whitelist (e.g. Annotated, Literal, Depends, Optional, Union, etc.).
  • Restrict attribute access to explicitly approved modules/types (e.g. typing, collections.abc) or a registry you control.
  • Consider banning ast.Call entirely unless there is a clearly bounded, vetted set of call targets.

This would better match the “safe eval” intent and avoid arbitrary code paths via type strings.

ast.Expression,
ast.Name,
ast.Attribute,
ast.Subscript,
ast.Constant,
ast.BinOp,
ast.BitOr,
ast.Load,
ast.Tuple,
ast.List,
ast.Call,
ast.keyword,
),
):
raise ValueError(f"Forbidden AST node in type string: {type(node).__name__}")

Check failure on line 129 in src/codeweaver/core/di/container.py

View workflow job for this annotation

GitHub Actions / Lint / Lint and Format

ruff (TRY004)

src/codeweaver/core/di/container.py:129:21: TRY004 Prefer `TypeError` exception for invalid type
Comment on lines +112 to +129
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Allowing generic ast.BinOp enables arbitrary binary operations in type strings, not just | unions.

The validator currently accepts any ast.BinOp without checking that the operator is BitOr, so expressions like Foo + Bar or Foo * 2 would also be validated and evaluated, which goes beyond what’s needed for type strings.

Consider either:

  • Dropping ast.BinOp and relying solely on ast.BitOr, or
  • Keeping ast.BinOp but enforcing isinstance(node.op, ast.BitOr) and rejecting all other operators.
Suggested change
if not isinstance(
node,
(
ast.Expression,
ast.Name,
ast.Attribute,
ast.Subscript,
ast.Constant,
ast.BinOp,
ast.BitOr,
ast.Load,
ast.Tuple,
ast.List,
ast.Call,
ast.keyword,
),
):
raise ValueError(f"Forbidden AST node in type string: {type(node).__name__}")
if not isinstance(
node,
(
ast.Expression,
ast.Name,
ast.Attribute,
ast.Subscript,
ast.Constant,
ast.BinOp,
ast.BitOr,
ast.Load,
ast.Tuple,
ast.List,
ast.Call,
ast.keyword,
),
):
raise ValueError(f"Forbidden AST node in type string: {type(node).__name__}")
# Restrict binary operations to unions using `|` only
if isinstance(node, ast.BinOp) and not isinstance(node.op, ast.BitOr):
raise ValueError(
f"Forbidden binary operator in type string: {type(node.op).__name__}"
)


# Block dunder access to prevent escaping the restricted environment
if isinstance(node, ast.Name) and node.id.startswith("__"):
raise ValueError(f"Forbidden dunder name: {node.id}")
if isinstance(node, ast.Attribute) and node.attr.startswith("__"):
raise ValueError(f"Forbidden dunder attribute: {node.attr}")

super().generic_visit(node)

TypeValidator().visit(tree)

# Restricted eval: only allow basic builtin types to be resolved
# even if they are not in the module's globals.
safe_builtins = {
"int": int,
"float": float,
"str": str,
"bool": bool,
"list": list,
"tuple": tuple,
"dict": dict,
"set": set,
"frozenset": frozenset,
"type": type,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You raise an excellent point about the security risk of including type in safe_builtins.

Since ast.Call is allowed (necessary for Depends() and other legitimate type annotations), expressions like type('X', (object,), {}) could theoretically be constructed if ast.Dict were also allowed. Even though ast.Dict is currently not in the allowed nodes, having type available is unnecessary and increases the attack surface.

Recommendation: Remove type from safe_builtins at line 153. I reviewed the codebase and couldn't identify any legitimate type annotation resolution scenario that would require the type() builtin to be callable within the eval context.

If the authors can demonstrate a specific use case where type is needed, it should be documented with a comment and a test case. Otherwise, defense-in-depth principles suggest removing it.

Great catch on this potential security issue! 🔒

"object": object,
"bytes": bytes,
}

code = compile(tree, "<string>", "eval")
return eval(code, {"__builtins__": safe_builtins}, globalns)

Check failure on line 159 in src/codeweaver/core/di/container.py

View workflow job for this annotation

GitHub Actions / Lint / Lint and Format

ruff (S307)

src/codeweaver/core/di/container.py:159:16: S307 Use of possibly insecure function; consider using `ast.literal_eval`

@staticmethod
def _unwrap_annotated(annotation: Any) -> Any:
Expand Down Expand Up @@ -135,9 +210,8 @@
return None

# First, try to evaluate the string as a type reference
# ruff: noqa: S307 - eval is necessary for type resolution, not literal evaluation
with suppress(Exception):
return eval(type_str, globalns)
return self._safe_eval_type(type_str, globalns)
# If direct eval failed, check if it's an Annotated pattern like "Annotated[SomeType, ...]"
# In this case, try to resolve the base type from registered factories
if type_str.startswith("Annotated["):
Expand All @@ -164,7 +238,7 @@
enhanced_globalns[base_type_str] = factory_type

with suppress(Exception):
return eval(type_str, enhanced_globalns)
return self._safe_eval_type(type_str, enhanced_globalns)
# Fallback: try to find a factory by matching type name
return next(
(
Expand Down
81 changes: 81 additions & 0 deletions tests/di/test_container_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@

# SPDX-FileCopyrightText: 2025 Knitli Inc.
# SPDX-FileContributor: Adam Poulemanos <adam@knit.li>
#
# SPDX-License-Identifier: MIT OR Apache-2.0

"""Security tests for the dependency injection container.

This module verifies that the DI container safely resolves string type
annotations, preventing arbitrary code execution while supporting
complex Python type hints including generics, unions, and Annotated types.
"""

from typing import Annotated, List, Optional, Union, get_args

import pytest

from codeweaver.core.di.container import Container
from codeweaver.core.di.dependency import Depends

def test_safe_type_resolution():
Comment on lines +1 to +21
container = Container()
globalns = {
"List": List,
"Optional": Optional,
"Union": Union,
"Annotated": Annotated,
"Depends": Depends,
"int": int,
"str": str,
}

# Valid type strings
assert container._resolve_string_type("int", globalns) is int
assert container._resolve_string_type("List[int]", globalns) == List[int]

Check failure on line 35 in tests/di/test_container_security.py

View workflow job for this annotation

GitHub Actions / Lint / Lint and Format

ruff (UP045)

tests/di/test_container_security.py:35:73: UP045 Use `X | None` for type annotations
assert container._resolve_string_type("Optional[str]", globalns) == Optional[str]
assert container._resolve_string_type("int | str", globalns) == (int | str)

# Annotated with Depends
resolved_annotated = container._resolve_string_type("Annotated[int, Depends()]", globalns)

# Check that it's an Annotated type in a cross-version compatible way.
# get_origin(Annotated[int, ...]) should be Annotated, but some environments
# might unwrap it or return a different origin. We check for __metadata__
# which is specific to Annotated types.
assert hasattr(resolved_annotated, "__metadata__"), f"Expected Annotated type, got {type(resolved_annotated)}"
assert get_args(resolved_annotated)[0] is int
assert any(isinstance(m, Depends) for m in resolved_annotated.__metadata__)

def test_malicious_type_resolution():
container = Container()
globalns = {"__name__": "__main__"}

# Malicious strings that should be blocked
malicious_strings = [
"__import__('os').system('echo VULNERABLE')",
"eval('1+1')",
"getattr(int, '__name__')",
"int.__class__",
"(lambda x: x)(1)",
]

for s in malicious_strings:
result = container._resolve_string_type(s, globalns)
assert result is None, f"String '{s}' should have been blocked"
Comment on lines +63 to +65
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests for syntactically invalid or unknown-name type strings to assert they safely return None.

test_malicious_type_resolution currently covers semantically dangerous expressions but not syntactically invalid ones (e.g. "int[") or unknown names (e.g. "UnknownType"). Since _safe_eval_type and _resolve_string_type both fall back to None in those cases, please add a couple of assertions for invalid/unknown strings here or in a separate test to verify we don’t leak exceptions or accidentally resolve them successfully.

Suggested change
for s in malicious_strings:
result = container._resolve_string_type(s, globalns)
assert result is None, f"String '{s}' should have been blocked"
for s in malicious_strings:
result = container._resolve_string_type(s, globalns)
assert result is None, f"String '{s}' should have been blocked"
# Syntactically invalid or unknown type strings should also be safely rejected
invalid_or_unknown_strings = [
"int[", # invalid syntax
"UnknownType", # unknown name
]
for s in invalid_or_unknown_strings:
result = container._resolve_string_type(s, globalns)
assert result is None, f"Invalid or unknown string '{s}' should resolve to None"


def test_dunder_blocking():
container = Container()
globalns = {"int": int}

# Dunder name blocking
assert container._resolve_string_type("__name__", {"__name__": "foo"}) is None

# Dunder attribute blocking
assert container._resolve_string_type("int.__name__", globalns) is None

def test_safe_builtins_resolution():
container = Container()
# No globals provided for basic types
assert container._resolve_string_type("int", {"__name__": "foo"}) is int
assert container._resolve_string_type("list[str]", {"__name__": "foo"}) == list[str]
Loading