Skip to content
Merged
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
13 changes: 6 additions & 7 deletions haystack/utils/callable_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import inspect
from collections.abc import Callable
from types import ModuleType
from typing import Any

from haystack import logging
Expand Down Expand Up @@ -118,12 +117,12 @@ def deserialize_callable(callable_handle: str) -> Callable:
except AttributeError as e:
container = getattr(attr_value, "__name__", type(attr_value).__name__)
raise DeserializationError(f"Could not find attribute '{part}' in {container}") from e
# A crafted handle can walk into a *module* re-exported as an attribute of an
# allowlisted module (e.g. `haystack.utils.auth.os` -> the `os` module). The declared
# path had an allowlisted prefix, but the module's real identity (`__name__`) is not
# allowlisted. Re-check every module hop so the walk cannot escape the allowlist.
if isinstance(attr_value, ModuleType):
_check_module_allowed(attr_value.__name__)
# A crafted handle can walk through an object re-exported from an unallowlisted module and then reach a
# final callable whose own module is allowlisted. For example, an allowlisted Haystack module re-exports
# `rich.console.Console`; walking through that class to `Console._environ.update` ends at
# `collections.abc.MutableMapping.update`, hiding the unallowlisted `rich` hop from the final check below.
# Validate every object reached during traversal so no intermediate hop can escape the allowlist.
_check_resolved_module_allowed(attr_value, declared_module=module_name)

# when the attribute is a classmethod, we need the underlying function
if isinstance(attr_value, (classmethod, staticmethod)):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
security:
- |
Harden callable deserialization by checking the real module of every object traversed in a
dotted callable path. This prevents an allowlisted module from exposing an object defined in an
unallowlisted module that leads back to an otherwise allowlisted final callable.
18 changes: 13 additions & 5 deletions test/core/test_serialization_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -785,11 +785,10 @@ class TestModuleAttributeWalkBypass:
The allowlist must be enforced against the module a handle
*actually resolves to*, not against a string prefix of the declared handle.

An allowlisted package can expose another module as an attribute (a module-scope `import os`
makes `haystack.utils.auth.os` the standard-library `os` module). A handle like
`haystack.utils.auth.os.system` carries the allowlisted `haystack` prefix, so the old
prefix-only check accepted it, and the resolver then walked `.os.system` into the un-allowlisted
`os` module — a deserialization-allowlist bypass reaching arbitrary command execution.
An allowlisted package can expose an object from another module as an attribute. A handle can
then carry an allowlisted `haystack` prefix while its attribute walk escapes through that object
into an unallowlisted module. Every intermediate object's real module must be checked, not only
module objects and the final callable.
"""

# (handle, module the walk escapes into) — real gadgets present in the default install.
Expand Down Expand Up @@ -822,6 +821,15 @@ def test_resolved_module_check_covers_plain_attribute(self, monkeypatch):
with pytest.raises(DeserializationError, match="module 'subprocess'"):
deserialize_callable("haystack.utils.auth.injected_gadget")

def test_callable_walk_through_reexported_unallowlisted_class_rejected(self):
# `Console` is re-exported from an allowlisted Haystack module but belongs to `rich.console`.
# Its `_environ` class attribute is the live `os.environ` mapping, whose `update` method
# reports the default-allowlisted `collections.abc` module. Checking only module hops and the
# final callable therefore misses the escape; checking the intermediate class rejects it.
handle = "haystack.hooks.human_in_the_loop.user_interfaces.Console._environ.update"
with pytest.raises(DeserializationError, match="module 'rich.console'"):
deserialize_callable(handle)

def test_legitimate_haystack_callable_still_resolves(self):
from haystack.utils.callable_serialization import serialize_callable

Expand Down
Loading