From 4db4119a8fdf12a6a91c3553754af531213f24d6 Mon Sep 17 00:00:00 2001 From: Sebastian Husch Lee Date: Thu, 20 Aug 2026 09:07:47 +0200 Subject: [PATCH 1/2] fix: validate modules during callable traversal --- haystack/utils/callable_serialization.py | 14 +++++++------- ...uring-deserialization-b8a680e8d292ad4d.yaml | 6 ++++++ test/core/test_serialization_security.py | 18 +++++++++++++----- 3 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 releasenotes/notes/Validate-intermediate-callable-modules-during-deserialization-b8a680e8d292ad4d.yaml diff --git a/haystack/utils/callable_serialization.py b/haystack/utils/callable_serialization.py index 591eebc87a..fa8d689394 100644 --- a/haystack/utils/callable_serialization.py +++ b/haystack/utils/callable_serialization.py @@ -4,7 +4,6 @@ import inspect from collections.abc import Callable -from types import ModuleType from typing import Any from haystack import logging @@ -118,12 +117,13 @@ 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)): diff --git a/releasenotes/notes/Validate-intermediate-callable-modules-during-deserialization-b8a680e8d292ad4d.yaml b/releasenotes/notes/Validate-intermediate-callable-modules-during-deserialization-b8a680e8d292ad4d.yaml new file mode 100644 index 0000000000..7210ea608a --- /dev/null +++ b/releasenotes/notes/Validate-intermediate-callable-modules-during-deserialization-b8a680e8d292ad4d.yaml @@ -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. diff --git a/test/core/test_serialization_security.py b/test/core/test_serialization_security.py index 9f36fea761..03824a4e5b 100644 --- a/test/core/test_serialization_security.py +++ b/test/core/test_serialization_security.py @@ -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. @@ -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 From 55c11a1b73d8707893a3e76914722916c05ff357 Mon Sep 17 00:00:00 2001 From: Sebastian Husch Lee Date: Thu, 20 Aug 2026 10:58:46 +0200 Subject: [PATCH 2/2] improve dev comment --- haystack/utils/callable_serialization.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/haystack/utils/callable_serialization.py b/haystack/utils/callable_serialization.py index fa8d689394..d1eb76a825 100644 --- a/haystack/utils/callable_serialization.py +++ b/haystack/utils/callable_serialization.py @@ -117,12 +117,11 @@ 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 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. + # 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