Coverage for haystack/utils/callable_serialization.py: 88%
66 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5import inspect
6from collections.abc import Callable
7from typing import Any
9from haystack import logging
10from haystack.core.errors import DeserializationError, SerializationError
11from haystack.core.serialization_security import (
12 _check_module_allowed,
13 _check_not_denied_builtin,
14 _check_not_denied_callable,
15 _check_not_deserialization_internal,
16 _check_resolved_module_allowed,
17 _check_traversable_attribute,
18 _is_denied_builtin,
19 _is_module_allowed,
20 mark_deserialization_internal,
21)
22from haystack.utils.type_serialization import thread_safe_import
24logger = logging.getLogger(__name__)
27def serialize_callable(callable_handle: Callable) -> str:
28 """
29 Serializes a callable to its full path.
31 :param callable_handle: The callable to serialize
32 :return: The full path of the callable
33 """
34 try:
35 full_arg_spec = inspect.getfullargspec(callable_handle)
36 is_instance_method = bool(full_arg_spec.args and full_arg_spec.args[0] == "self")
37 except TypeError:
38 is_instance_method = False
39 if is_instance_method:
40 raise SerializationError("Serialization of instance methods is not supported.")
42 # __qualname__ contains the fully qualified path we need for classmethods and staticmethods
43 qualname = getattr(callable_handle, "__qualname__", "")
44 if "<lambda>" in qualname:
45 raise SerializationError("Serialization of lambdas is not supported.")
46 if "<locals>" in qualname:
47 raise SerializationError("Serialization of nested functions is not supported.")
49 name = qualname or callable_handle.__name__
51 # Get the full package path of the function
52 module = inspect.getmodule(callable_handle)
53 if module is not None:
54 full_path = f"{module.__name__}.{name}"
55 else:
56 full_path = name
58 # Serialization succeeds, but a denied builtin (e.g. `eval`) won't reload without `unsafe=True`.
59 if _is_denied_builtin(callable_handle):
60 logger.warning(
61 "Serialized callable '{full_path}' is a builtin that is blocked during deserialization; "
62 "the resulting pipeline will only be loadable with unsafe=True.",
63 full_path=full_path,
64 )
66 return full_path
69@mark_deserialization_internal
70def deserialize_callable(callable_handle: str) -> Callable:
71 """
72 Deserializes a callable given its full import path as a string.
74 Every module path tried during resolution is checked against the
75 deserialization allowlist (see `haystack.core.serialization_security`). Callables in modules
76 outside the allowlist are rejected with a `DeserializationError` before any import is
77 attempted. To allow a third-party module, extend the allowlist via
78 `Pipeline.load(..., allowed_modules=[...])`, `allow_deserialization_module(...)`, or the
79 `HAYSTACK_DESERIALIZATION_ALLOWLIST` environment variable.
81 :param callable_handle: The full path of the callable_handle
82 :return: The callable
83 :raises DeserializationError:
84 If the module path is not on the deserialization allowlist, or if the callable cannot
85 be found.
86 """
87 # Import here to avoid circular imports
88 from haystack.hooks.from_function import FunctionHook
89 from haystack.tools.tool import Tool
91 parts = callable_handle.split(".")
93 for i in range(len(parts), 0, -1):
94 module_name = ".".join(parts[:i])
95 # Only import modules that are on the allowlist. Gating the import (rather than a mere
96 # string prefix of the handle) means a disallowed module is never imported for its
97 # side effects, and the resolver can only ever start from a trusted module. Shorter
98 # prefixes are tried in turn, so `json.dumps` still resolves when `json` is allowed.
99 if not _is_module_allowed(module_name):
100 continue
101 try:
102 mod: Any = thread_safe_import(module_name)
103 except Exception:
104 # keep reducing i until we find a valid module import
105 continue
107 attr_value = mod
108 for part in parts[i:]:
109 # A handle legitimately walks `module.Class.method`, never into an object's internals.
110 # Refuse dunder/frame attributes (`__globals__`, `__dict__`, `__class__`, ...) before the
111 # getattr: `<func>.__globals__` yields a live module namespace (a gateway to the allowlist
112 # state and to `__builtins__`/`eval`) even though the traversal never leaves an allowlisted
113 # module, so neither the module allowlist nor the resolved-object checks below would catch it.
114 _check_traversable_attribute(part, callable_handle)
115 try:
116 attr_value = getattr(attr_value, part)
117 except AttributeError as e:
118 container = getattr(attr_value, "__name__", type(attr_value).__name__)
119 raise DeserializationError(f"Could not find attribute '{part}' in {container}") from e
120 # A crafted handle can walk through an object re-exported from an unallowlisted module and then reach a
121 # final callable whose own module is allowlisted. For example, an allowlisted Haystack module re-exports
122 # `rich.console.Console`; walking through that class to `Console._environ.update` ends at
123 # `collections.abc.MutableMapping.update`, hiding the unallowlisted `rich` hop from the final check below.
124 # Validate every object reached during traversal so no intermediate hop can escape the allowlist.
125 _check_resolved_module_allowed(attr_value, declared_module=module_name)
127 # when the attribute is a classmethod, we need the underlying function
128 if isinstance(attr_value, (classmethod, staticmethod)):
129 attr_value = attr_value.__func__
131 # Handle the case where @tool decorator replaced the function with a Tool object
132 if isinstance(attr_value, Tool):
133 attr_value = attr_value.function or attr_value.async_function
135 # Handle the case where @hook decorator replaced the function with a FunctionHook object
136 if isinstance(attr_value, FunctionHook):
137 attr_value = attr_value.function or attr_value.async_function
139 if not callable(attr_value):
140 raise DeserializationError(f"The final attribute is not callable: {attr_value}")
142 # Final defense: gate on the module the resolved callable actually comes from, not on the
143 # declared handle. This catches a dangerous callable bound as a plain (non-module) attribute
144 # of an allowlisted object, which the module-walk check above would not see. `module_name`
145 # is the allowlisted module we resolved from, so a private C accelerator backing it (e.g.
146 # `operator.add` -> `_operator`) is still accepted.
147 _check_resolved_module_allowed(attr_value, declared_module=module_name)
149 # `builtins` is on the allowlist (for `builtins.print` etc.), so the module check
150 # above does not stop dangerous builtins like `eval`/`exec` from resolving here. Block them.
151 _check_not_denied_builtin(attr_value, callable_handle)
153 # The module check also does not stop import primitives that live inside an allowlisted
154 # namespace (e.g. `haystack...thread_safe_import`), which are gateways to code execution
155 # equivalent to the denied builtin `__import__`. Block them too.
156 _check_not_denied_callable(attr_value, callable_handle)
158 # Refuse the deserializer's own machinery — the allowlist-administration function
159 # (`allow_deserialization_module`) and the resolution helpers (`deserialize_callable`,
160 # `deserialize_type`, `import_class_by_name`). They live in the allowlisted `haystack`
161 # namespace, so the module checks above admit them, but resolving them from serialized data
162 # lets a hostile pipeline register them as Jinja custom filters, disarm the allowlist with
163 # `'*'`, and then resolve and invoke arbitrary callables such as `os.system`.
164 _check_not_deserialization_internal(attr_value, callable_handle)
166 return attr_value
168 # Nothing on the allowlist was importable. Surface the standard allowlist error when the
169 # top-level module is untrusted; otherwise report a plain resolution failure.
170 _check_module_allowed(callable_handle)
171 raise DeserializationError(f"Could not import '{callable_handle}' as a module or callable.")