Skip to content

Commit 4327c6d

Browse files
fix: strengthen deserialization allowlist checks to prevent bypass vulnerabilities (#12028)
Co-authored-by: David S. Batista <dsbatista@gmail.com>
1 parent a7fb2f1 commit 4327c6d

5 files changed

Lines changed: 214 additions & 7 deletions

File tree

haystack/core/serialization_security.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from collections.abc import Iterable, Iterator
2323
from contextlib import contextmanager
2424
from dataclasses import dataclass, field
25+
from types import ModuleType
2526

2627
from haystack.core.errors import DeserializationError
2728

@@ -161,6 +162,65 @@ def _check_module_allowed(module_name: str) -> None:
161162
)
162163

163164

165+
def _check_resolved_module_allowed(resolved: object, declared_module: str | None = None) -> None:
166+
"""
167+
Gate on the module a resolved object *actually* comes from, not on the declared handle.
168+
169+
The allowlist checks that run earlier in deserialization apply to the declared dotted path,
170+
which an attacker controls. A crafted handle can name an object re-exported as an attribute of
171+
an allowlisted module and thereby escape the allowlist:
172+
173+
- a module: ``haystack.utils.auth.os`` resolves to the standard-library ``os`` module, because
174+
``haystack.utils.auth`` does ``import os`` at module scope. The handle has the allowlisted
175+
``haystack`` prefix, but the resolved object belongs to the un-allowlisted ``os`` module.
176+
- a class or function: ``haystack.<mod>.<Name>`` could resolve a class/function imported into
177+
an allowlisted module from an un-allowlisted one (e.g. a re-exported ``subprocess.Popen``).
178+
179+
Checking the resolved object's real module closes both gaps. Objects without a usable
180+
``__module__`` (e.g. ``functools.partial`` instances) are left to the other gates; they are not
181+
reachable as gadgets through an attribute walk that stays entirely inside allowlisted modules.
182+
183+
:param resolved:
184+
The object resolved from the serialized handle.
185+
:param declared_module:
186+
The allowlisted module the object was actually resolved from (the module that was imported
187+
and walked). Used to accept the private implementation module that backs a public module —
188+
see below.
189+
:raises DeserializationError:
190+
If the resolved object's real module is not on the allowlist.
191+
"""
192+
if _get_context().unsafe:
193+
return
194+
# Builtins are gated separately and authoritatively — by the identity denylist
195+
# (`_check_not_denied_builtin`) in the callable path and by the type requirement
196+
# (`_check_builtin_is_type`) in the class path. They can also report a private `__module__`
197+
# (e.g. `open.__module__ == "_io"`), so exempt any object that is a genuine builtin (reachable
198+
# under its own name in the `builtins` module) from the module check to avoid shadowing those
199+
# gates. This exemption cannot widen access: a dangerous builtin is still stopped downstream.
200+
name = getattr(resolved, "__name__", None)
201+
if isinstance(name, str) and getattr(builtins, name, None) is resolved:
202+
return
203+
# Modules carry their identity in `__name__`, not `__module__`.
204+
module_name = resolved.__name__ if isinstance(resolved, ModuleType) else getattr(resolved, "__module__", None)
205+
if not (isinstance(module_name, str) and module_name):
206+
return
207+
if _is_module_allowed(module_name):
208+
return
209+
# Private implementation module backing an allowlisted public module: a symbol legitimately
210+
# exposed by an allowlisted module can report a private `__module__` — e.g. `operator.add`
211+
# resolves to `_operator.add`, `io.StringIO` to `_io.StringIO`. Accept it only when the private
212+
# module is the C accelerator of the *same* allowlisted module the object was resolved from
213+
# (`_operator` backs `operator`, `_io` backs `io`). This never admits a public, un-allowlisted
214+
# module such as `os` or `subprocess`, so the escape gadgets stay blocked.
215+
if (
216+
declared_module is not None
217+
and module_name.lstrip("_") == declared_module
218+
and _is_module_allowed(declared_module)
219+
):
220+
return
221+
_check_module_allowed(module_name)
222+
223+
164224
def _is_denied_builtin(resolved: object) -> bool:
165225
"""
166226
Return whether `resolved` is one of the builtins denied for callable deserialization.

haystack/utils/callable_serialization.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44

55
import inspect
66
from collections.abc import Callable
7+
from types import ModuleType
78
from typing import Any
89

910
from haystack import logging
1011
from haystack.core.errors import DeserializationError, SerializationError
1112
from haystack.core.serialization_security import (
1213
_check_module_allowed,
1314
_check_not_denied_builtin,
15+
_check_resolved_module_allowed,
1416
_is_denied_builtin,
1517
_is_module_allowed,
1618
)
@@ -84,13 +86,14 @@ def deserialize_callable(callable_handle: str) -> Callable:
8486

8587
parts = callable_handle.split(".")
8688

87-
# Allow if any prefix is on the allowlist; checking each one individually would wrongly
88-
# reject patterns like `j*on` against `json.dumps` (matches `json`, not the full handle).
89-
if not any(_is_module_allowed(".".join(parts[:i])) for i in range(1, len(parts) + 1)):
90-
_check_module_allowed(callable_handle) # raises with the standard help message
91-
9289
for i in range(len(parts), 0, -1):
9390
module_name = ".".join(parts[:i])
91+
# Only import modules that are on the allowlist. Gating the import (rather than a mere
92+
# string prefix of the handle) means a disallowed module is never imported for its
93+
# side effects, and the resolver can only ever start from a trusted module. Shorter
94+
# prefixes are tried in turn, so `json.dumps` still resolves when `json` is allowed.
95+
if not _is_module_allowed(module_name):
96+
continue
9497
try:
9598
mod: Any = thread_safe_import(module_name)
9699
except Exception:
@@ -104,6 +107,12 @@ def deserialize_callable(callable_handle: str) -> Callable:
104107
except AttributeError as e:
105108
container = getattr(attr_value, "__name__", type(attr_value).__name__)
106109
raise DeserializationError(f"Could not find attribute '{part}' in {container}") from e
110+
# A crafted handle can walk into a *module* re-exported as an attribute of an
111+
# allowlisted module (e.g. `haystack.utils.auth.os` -> the `os` module). The declared
112+
# path had an allowlisted prefix, but the module's real identity (`__name__`) is not
113+
# allowlisted. Re-check every module hop so the walk cannot escape the allowlist.
114+
if isinstance(attr_value, ModuleType):
115+
_check_module_allowed(attr_value.__name__)
107116

108117
# when the attribute is a classmethod, we need the underlying function
109118
if isinstance(attr_value, (classmethod, staticmethod)):
@@ -120,11 +129,20 @@ def deserialize_callable(callable_handle: str) -> Callable:
120129
if not callable(attr_value):
121130
raise DeserializationError(f"The final attribute is not callable: {attr_value}")
122131

132+
# Final defense: gate on the module the resolved callable actually comes from, not on the
133+
# declared handle. This catches a dangerous callable bound as a plain (non-module) attribute
134+
# of an allowlisted object, which the module-walk check above would not see. `module_name`
135+
# is the allowlisted module we resolved from, so a private C accelerator backing it (e.g.
136+
# `operator.add` -> `_operator`) is still accepted.
137+
_check_resolved_module_allowed(attr_value, declared_module=module_name)
138+
123139
# `builtins` is on the allowlist (for `builtins.print` etc.), so the module check
124140
# above does not stop dangerous builtins like `eval`/`exec` from resolving here. Block them.
125141
_check_not_denied_builtin(attr_value, callable_handle)
126142

127143
return attr_value
128144

129-
# Fallback if we never find anything
145+
# Nothing on the allowlist was importable. Surface the standard allowlist error when the
146+
# top-level module is untrusted; otherwise report a plain resolution failure.
147+
_check_module_allowed(callable_handle)
130148
raise DeserializationError(f"Could not import '{callable_handle}' as a module or callable.")

haystack/utils/type_serialization.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212

1313
from haystack import logging
1414
from haystack.core.errors import DeserializationError
15-
from haystack.core.serialization_security import _check_builtin_is_type, _check_module_allowed
15+
from haystack.core.serialization_security import (
16+
_check_builtin_is_type,
17+
_check_module_allowed,
18+
_check_resolved_module_allowed,
19+
)
1620

1721
logger = logging.getLogger(__name__)
1822

@@ -256,6 +260,13 @@ def _import_class_by_name(fully_qualified_name: str) -> Any:
256260
)
257261
module = thread_safe_import(module_path)
258262
resolved = getattr(module, attr_name)
263+
# `_check_module_allowed` above gates the declared module path, which the caller controls.
264+
# A class/module re-exported as an attribute of an allowlisted module (e.g.
265+
# `haystack.utils.auth.os` -> the `os` module, or a re-exported `subprocess.Popen`) would
266+
# otherwise slip through. Re-check the module the object actually belongs to; `module_path`
267+
# is the allowlisted module it was resolved from, so a private C accelerator backing it
268+
# (e.g. `io.StringIO` -> `_io`) is still accepted.
269+
_check_resolved_module_allowed(resolved, declared_module=module_path)
259270
if module_path == "builtins":
260271
_check_builtin_is_type(resolved, fully_qualified_name)
261272
return resolved
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
security:
3+
- |
4+
Fixed a deserialization-allowlist bypass that could allow arbitrary code execution when
5+
loading an untrusted pipeline (``Pipeline.load`` / ``Pipeline.loads`` / ``Pipeline.from_dict``).
6+
The module allowlist was enforced against a string prefix of the serialized handle, while the
7+
callable/class resolvers walked attributes from the deepest importable module. Because an
8+
allowlisted package can expose another module as an attribute (e.g. a module-scope ``import os``
9+
makes ``haystack.utils.auth.os`` the standard-library ``os`` module), a handle such as
10+
``haystack.utils.auth.os.system`` passed the allowlist and resolved to ``os.system``. The
11+
allowlist is now enforced against the module each object is *actually* resolved from — every
12+
module hop during attribute resolution is re-checked, and the resolved callable's/class's real
13+
module must be on the allowlist. Legitimate handles (including harmless builtins such as
14+
``builtins.print``) continue to resolve.

test/core/test_serialization_security.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
#
33
# SPDX-License-Identifier: Apache-2.0
44

5+
import functools
56
import io
67
import json
8+
import operator
79
import subprocess
810
from collections.abc import Callable
911

@@ -302,6 +304,108 @@ def test_unsafe_mode_bypasses_the_block(self):
302304
assert deserialize_callable("builtins.eval") is eval
303305

304306

307+
class TestModuleAttributeWalkBypass:
308+
"""
309+
The allowlist must be enforced against the module a handle
310+
*actually resolves to*, not against a string prefix of the declared handle.
311+
312+
An allowlisted package can expose another module as an attribute (a module-scope `import os`
313+
makes `haystack.utils.auth.os` the standard-library `os` module). A handle like
314+
`haystack.utils.auth.os.system` carries the allowlisted `haystack` prefix, so the old
315+
prefix-only check accepted it, and the resolver then walked `.os.system` into the un-allowlisted
316+
`os` module — a deserialization-allowlist bypass reaching arbitrary command execution.
317+
"""
318+
319+
# (handle, module the walk escapes into) — real gadgets present in the default install.
320+
_GADGETS = [
321+
("haystack.utils.auth.os.system", "os"),
322+
("haystack.utils.auth.os.popen", "os"),
323+
("haystack.components.converters.output_adapter.ast.literal_eval", "ast"),
324+
]
325+
326+
@pytest.mark.parametrize("handle, escaped_module", _GADGETS)
327+
def test_callable_walk_into_unallowlisted_module_rejected(self, handle, escaped_module):
328+
with pytest.raises(DeserializationError, match=f"module '{escaped_module}'"):
329+
deserialize_callable(handle)
330+
331+
def test_class_path_module_leak_rejected(self):
332+
# The class path resolves `haystack.utils.auth.os` to the `os` module itself; it must not
333+
# leak an un-allowlisted module as if it were a class.
334+
with pytest.raises(DeserializationError, match="module 'os'"):
335+
import_class_by_name("haystack.utils.auth.os")
336+
337+
def test_type_path_module_leak_rejected(self):
338+
with pytest.raises(DeserializationError, match="module 'os'"):
339+
deserialize_type("haystack.utils.auth.os")
340+
341+
def test_resolved_module_check_covers_plain_attribute(self, monkeypatch):
342+
# Defense-in-depth: a dangerous callable bound as a plain (non-module) attribute of an
343+
# allowlisted module is not caught by the module-walk check but must still be rejected by
344+
# the resolved-`__module__` gate (`subprocess.getoutput.__module__ == "subprocess"`).
345+
monkeypatch.setattr("haystack.utils.auth.injected_gadget", subprocess.getoutput, raising=False)
346+
with pytest.raises(DeserializationError, match="module 'subprocess'"):
347+
deserialize_callable("haystack.utils.auth.injected_gadget")
348+
349+
def test_legitimate_haystack_callable_still_resolves(self):
350+
from haystack.utils.callable_serialization import serialize_callable
351+
352+
handle = "haystack.utils.callable_serialization.serialize_callable"
353+
assert deserialize_callable(handle) is serialize_callable
354+
355+
def test_pipeline_loads_rejects_gadget_yaml(self):
356+
# End-to-end through the public default-safe API: an attacker-supplied pipeline naming the
357+
# gadget as an `OutputAdapter` custom filter must be rejected at load time.
358+
gadget_yaml = (
359+
"components:\n"
360+
" adapter:\n"
361+
" type: haystack.components.converters.output_adapter.OutputAdapter\n"
362+
" init_parameters:\n"
363+
' template: "{{ x }}"\n'
364+
" output_type: str\n"
365+
" custom_filters:\n"
366+
' pwn: "haystack.utils.auth.os.system"\n'
367+
"connections: []\n"
368+
)
369+
with pytest.raises(DeserializationError):
370+
Pipeline.loads(gadget_yaml)
371+
372+
373+
class TestPrivateBackingModuleCompatibility:
374+
"""
375+
A symbol legitimately exposed by an allowlisted public module can report a *private* C
376+
accelerator as its `__module__` (`operator.add.__module__ == "_operator"`,
377+
`io.StringIO.__module__ == "_io"`). The resolved-module gate must still accept these when the
378+
private module backs the allowlisted module the object was resolved from — otherwise allowing
379+
a public module would silently fail to resolve its own members and force users to widen trust
380+
to low-level private modules.
381+
"""
382+
383+
def test_callable_from_private_backing_module_resolves(self):
384+
with _deserialization_context(allowed_modules=["operator"]):
385+
assert deserialize_callable("operator.add") is operator.add
386+
387+
def test_callable_functools_reduce_resolves(self):
388+
with _deserialization_context(allowed_modules=["functools"]):
389+
assert deserialize_callable("functools.reduce") is functools.reduce
390+
391+
def test_type_from_private_backing_module_resolves(self):
392+
with _deserialization_context(allowed_modules=["io"]):
393+
assert deserialize_type("io.StringIO") is io.StringIO
394+
395+
def test_import_class_from_private_backing_module_resolves(self):
396+
with _deserialization_context(allowed_modules=["io"]):
397+
assert import_class_by_name("io.StringIO") is io.StringIO
398+
399+
def test_exemption_is_scoped_to_the_matching_private_module(self, monkeypatch):
400+
# The exemption only trusts the private module that backs the *same* allowlisted module.
401+
# A symbol whose real module is a different (still un-allowlisted) private module must stay
402+
# blocked: here `operator` is allowed but the injected attribute resolves to `_io`.
403+
monkeypatch.setattr("operator.injected_gadget", io.StringIO, raising=False)
404+
with _deserialization_context(allowed_modules=["operator"]):
405+
with pytest.raises(DeserializationError, match="module '_io'"):
406+
deserialize_callable("operator.injected_gadget")
407+
408+
305409
@pytest.fixture
306410
def _registered_untrusted_component():
307411
"""

0 commit comments

Comments
 (0)