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

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import inspect 

6from collections.abc import Callable 

7from typing import Any 

8 

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 

23 

24logger = logging.getLogger(__name__) 

25 

26 

27def serialize_callable(callable_handle: Callable) -> str: 

28 """ 

29 Serializes a callable to its full path. 

30 

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.") 

41 

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.") 

48 

49 name = qualname or callable_handle.__name__ 

50 

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 

57 

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 ) 

65 

66 return full_path 

67 

68 

69@mark_deserialization_internal 

70def deserialize_callable(callable_handle: str) -> Callable: 

71 """ 

72 Deserializes a callable given its full import path as a string. 

73 

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. 

80 

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 

90 

91 parts = callable_handle.split(".") 

92 

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 

106 

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) 

126 

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__ 

130 

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 

134 

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 

138 

139 if not callable(attr_value): 

140 raise DeserializationError(f"The final attribute is not callable: {attr_value}") 

141 

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) 

148 

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) 

152 

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) 

157 

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) 

165 

166 return attr_value 

167 

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.")