fix: prevent RCE by hardening deserialization and Jinja sandbox (#12312)

This commit is contained in:
bogdankostic
2026-08-11 18:22:28 +02:00
committed by GitHub
parent 8e42dc9b8b
commit efcf6b895f
12 changed files with 314 additions and 11 deletions
@@ -6,14 +6,13 @@ import json
from dataclasses import replace
from typing import Any, Literal
from jinja2.sandbox import SandboxedEnvironment
from haystack import component, default_from_dict, default_to_dict, logging
from haystack.dataclasses.chat_message import ChatMessage, ChatRole, TextContent
from haystack.lazy_imports import LazyImport
from haystack.utils import Jinja2TimeExtension
from haystack.utils.jinja2_chat_extension import ChatMessageExtension
from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments
from haystack.utils.jinja2_sandbox import HaystackSandboxedEnvironment
logger = logging.getLogger(__name__)
@@ -165,7 +164,7 @@ class ChatPromptBuilder:
self._required_variables = required_variables
self.template = template
self._env = SandboxedEnvironment(extensions=[ChatMessageExtension])
self._env = HaystackSandboxedEnvironment(extensions=[ChatMessageExtension])
if arrow_import.is_successful():
self._env.add_extension(Jinja2TimeExtension)
@@ -4,11 +4,10 @@
from typing import Any, Literal
from jinja2.sandbox import SandboxedEnvironment
from haystack import component, default_to_dict, logging
from haystack.utils import Jinja2TimeExtension
from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments
from haystack.utils.jinja2_sandbox import HaystackSandboxedEnvironment
logger = logging.getLogger(__name__)
@@ -170,9 +169,9 @@ class PromptBuilder:
try:
# The Jinja2TimeExtension needs an optional dependency to be installed.
# If it's not available we can do without it and use the PromptBuilder as is.
self._env = SandboxedEnvironment(extensions=[Jinja2TimeExtension])
self._env = HaystackSandboxedEnvironment(extensions=[Jinja2TimeExtension])
except ImportError:
self._env = SandboxedEnvironment()
self._env = HaystackSandboxedEnvironment()
self.template = self._env.from_string(template)
@@ -10,11 +10,13 @@ from typing import Any, TypeAlias
import jinja2.runtime
from jinja2 import TemplateSyntaxError
from jinja2.nativetypes import NativeEnvironment
from jinja2.sandbox import SandboxedEnvironment
from haystack import component, default_from_dict, default_to_dict, logging
from haystack.core.errors import DeserializationError
from haystack.core.serialization_security import _is_unsafe_deserialization
from haystack.utils import deserialize_callable, deserialize_type, serialize_callable, serialize_type
from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments
from haystack.utils.jinja2_sandbox import HaystackSandboxedEnvironment
logger = logging.getLogger(__name__)
@@ -80,7 +82,9 @@ class OutputAdapter:
)
logger.warning(msg)
self._env = (
NativeEnvironment() if self._unsafe else SandboxedEnvironment(undefined=jinja2.runtime.StrictUndefined)
NativeEnvironment()
if self._unsafe
else HaystackSandboxedEnvironment(undefined=jinja2.runtime.StrictUndefined)
)
try:
@@ -168,6 +172,16 @@ class OutputAdapter:
The deserialized component.
"""
init_params = data.get("init_parameters", {})
# `unsafe=True` swaps the Jinja sandbox for a NativeEnvironment that executes arbitrary code.
# Honor it from serialized data only when the whole pipeline is being loaded in unsafe mode;
# otherwise a hostile pipeline could disable the sandbox on its own in default safe mode.
if init_params.get("unsafe") and not _is_unsafe_deserialization():
raise DeserializationError(
"Refusing to deserialize an OutputAdapter with unsafe=True while loading in safe mode. "
"If you trust the source of this data, load it with Pipeline.load(..., unsafe=True)."
)
init_params["output_type"] = deserialize_type(init_params["output_type"])
custom_filters = init_params.get("custom_filters", {})
@@ -9,12 +9,14 @@ from typing import Any, TypedDict, get_args, get_origin
from jinja2 import Environment, TemplateSyntaxError
from jinja2.nativetypes import NativeEnvironment
from jinja2.sandbox import SandboxedEnvironment
from typing_extensions import NotRequired
from haystack import component, default_from_dict, default_to_dict, logging
from haystack.core.errors import DeserializationError
from haystack.core.serialization_security import _is_unsafe_deserialization
from haystack.utils import deserialize_callable, deserialize_type, serialize_callable, serialize_type
from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments
from haystack.utils.jinja2_sandbox import HaystackSandboxedEnvironment
from haystack.utils.type_serialization import _is_union_type
logger = logging.getLogger(__name__)
@@ -275,7 +277,7 @@ class ConditionalRouter:
)
logger.warning(msg)
self._env = NativeEnvironment() if self._unsafe else SandboxedEnvironment()
self._env = NativeEnvironment() if self._unsafe else HaystackSandboxedEnvironment()
self._env.filters.update(self.custom_filters)
self._validate_routes(routes)
@@ -363,6 +365,16 @@ class ConditionalRouter:
The deserialized component.
"""
init_params = data.get("init_parameters", {})
# `unsafe=True` swaps the Jinja sandbox for a NativeEnvironment that executes arbitrary code.
# Honor it from serialized data only when the whole pipeline is being loaded in unsafe mode;
# otherwise a hostile pipeline could disable the sandbox on its own in default safe mode.
if init_params.get("unsafe") and not _is_unsafe_deserialization():
raise DeserializationError(
"Refusing to deserialize a ConditionalRouter with unsafe=True while loading in safe mode. "
"If you trust the source of this data, load it with Pipeline.load(..., unsafe=True)."
)
routes = init_params.get("routes")
for route in routes:
# output_type needs to be deserialized from a string to a type
+54
View File
@@ -18,6 +18,7 @@ The two-mode loading API (`unsafe=True`) bypasses the allowlist entirely.
import builtins
import contextvars
import fnmatch
import importlib
import os
from collections.abc import Iterable, Iterator
from contextlib import contextmanager
@@ -73,6 +74,19 @@ _DENIED_BUILTIN_NAMES: frozenset[str] = frozenset(
# reach the same builtin via a different import path (e.g. `io.open is builtins.open`).
_DENIED_BUILTIN_OBJECTS: frozenset = frozenset([getattr(builtins, name) for name in _DENIED_BUILTIN_NAMES])
# Import primitives are functional twins of the denied builtin `__import__`: they load an arbitrary
# module by name (the gateway to `os`/`subprocess`/...). The module-granular allowlist does not stop
# them, because a wrapper like `haystack.utils.type_serialization.thread_safe_import` lives inside
# the allowlisted `haystack` namespace and reports `__module__ == "haystack..."`, and the builtin
# denylist above only matches `builtins` members by identity. Deny these import primitives too.
_DENIED_CALLABLE_OBJECTS: frozenset = frozenset({importlib.import_module, importlib.reload})
# `thread_safe_import` is matched by its (`__module__`, `__qualname__`) pair rather than by identity
# so this module need not import `haystack.utils.type_serialization` (which itself imports from this
# module, so an import here would be circular).
_DENIED_CALLABLE_QUALNAMES: frozenset[tuple[str, str]] = frozenset(
{("haystack.utils.type_serialization", "thread_safe_import")}
)
@dataclass(frozen=True)
class _DeserializationContext:
@@ -90,6 +104,18 @@ def _get_context() -> _DeserializationContext:
return ctx if ctx is not None else _DeserializationContext()
def _is_unsafe_deserialization() -> bool:
"""
Return whether the active deserialization context was entered with `unsafe=True`.
Components deserializing their own data (e.g. `OutputAdapter.from_dict`) use this to decide
whether to honor an embedded `unsafe` flag: a serialized component may only disable its Jinja
sandbox when the whole pipeline is being loaded in unsafe mode (`Pipeline.load(..., unsafe=True)`),
never on its own from otherwise-untrusted data in default safe mode.
"""
return _get_context().unsafe
# Process-wide patterns set via allow_deserialization_module.
_extra_allowed_modules: list[str] = []
@@ -258,6 +284,34 @@ def _check_not_denied_builtin(resolved: object, handle: str) -> None:
)
def _check_not_denied_callable(resolved: object, handle: str) -> None:
"""
Reject `resolved` if it is an import primitive that is unsafe to resolve from serialized data.
Used by the callable-resolution path (`deserialize_callable`) as a companion to
:func:`_check_not_denied_builtin`. It blocks the non-builtin import primitives in
:data:`_DENIED_CALLABLE_OBJECTS` / :data:`_DENIED_CALLABLE_QUALNAMES` (e.g.
`importlib.import_module`, `haystack.utils.type_serialization.thread_safe_import`), which are
functionally equivalent to the already-denied builtin `__import__` and can load any module as a
gateway to code execution. Bypassed in `unsafe=True` mode, which disables all safety checks.
:param resolved:
The object resolved from the serialized handle.
:param handle:
The original serialized handle, used only for the error message.
"""
if _get_context().unsafe:
return
ident = (getattr(resolved, "__module__", ""), getattr(resolved, "__qualname__", ""))
if any(resolved is denied for denied in _DENIED_CALLABLE_OBJECTS) or ident in _DENIED_CALLABLE_QUALNAMES:
raise DeserializationError(
f"Refusing to deserialize '{handle}': it resolves to an import primitive that can load "
f"arbitrary modules (equivalent to the blocked builtin '__import__'), which is a gateway "
f"to code execution. If you trust the source of this data, load it with unsafe=True to "
f"bypass deserialization safety checks."
)
def _check_builtin_is_type(resolved: object, handle: str) -> None:
"""
Reject a `builtins` member resolved in a type/class context that is not a `type`.
+6
View File
@@ -12,6 +12,7 @@ from haystack.core.errors import DeserializationError, SerializationError
from haystack.core.serialization_security import (
_check_module_allowed,
_check_not_denied_builtin,
_check_not_denied_callable,
_check_resolved_module_allowed,
_is_denied_builtin,
_is_module_allowed,
@@ -140,6 +141,11 @@ def deserialize_callable(callable_handle: str) -> Callable:
# above does not stop dangerous builtins like `eval`/`exec` from resolving here. Block them.
_check_not_denied_builtin(attr_value, callable_handle)
# The module check also does not stop import primitives that live inside an allowlisted
# namespace (e.g. `haystack...thread_safe_import`), which are gateways to code execution
# equivalent to the denied builtin `__import__`. Block them too.
_check_not_denied_callable(attr_value, callable_handle)
return attr_value
# Nothing on the allowlist was importable. Surface the standard allowlist error when the
+67
View File
@@ -0,0 +1,67 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <[email protected]>
#
# SPDX-License-Identifier: Apache-2.0
from types import ModuleType
from typing import Any
from jinja2.sandbox import SandboxedEnvironment
# Roots of modules whose callables must never be invocable from a template. Reaching one of these
# from inside a rendered template is only possible after a sandbox escape (e.g. a custom filter that
# returns a module object), so blocking the call is a defense-in-depth backstop. `builtins` is
# deliberately excluded: ordinary template operations such as `{{ name.upper() }}` resolve to
# builtin methods, and blocking those would break legitimate templates.
_UNSAFE_MODULE_ROOTS: frozenset[str] = frozenset(
{
"os",
"sys",
"subprocess",
"socket",
"shutil",
"importlib",
"ctypes",
"posix",
"nt",
"pty",
"pickle",
"shelve",
"marshal",
"multiprocessing",
"code",
"pdb",
}
)
class HaystackSandboxedEnvironment(SandboxedEnvironment):
"""
A `SandboxedEnvironment` hardened against sandbox-escape gadgets.
On top of Jinja2's stock sandbox it additionally:
- refuses attribute access on module objects, so a module that leaks into the template context
(e.g. via a custom filter that imports one) cannot be walked into (`os.system`, ...);
- refuses to call module objects, and refuses to call any callable whose defining module is
rooted in a dangerous standard-library module (see :data:`_UNSAFE_MODULE_ROOTS`).
Note that Jinja invokes *filters* directly, bypassing `is_safe_callable`, so this does not
constrain what a registered `custom_filters` function itself does; it only governs attribute
access and calls written in template text.
"""
def is_safe_attribute(self, obj: Any, attr: str, value: Any) -> bool:
"""Reject attribute access on module objects; otherwise defer to the stock sandbox."""
# Templates never legitimately reach into a module object's attributes.
if isinstance(obj, ModuleType):
return False
return super().is_safe_attribute(obj, attr, value)
def is_safe_callable(self, obj: Any) -> bool:
"""Reject calling module objects and callables from dangerous modules; else defer to super."""
if isinstance(obj, ModuleType):
return False
root = (getattr(obj, "__module__", "") or "").split(".", 1)[0]
if root in _UNSAFE_MODULE_ROOTS:
return False
return super().is_safe_callable(obj)
@@ -0,0 +1,20 @@
---
upgrade:
- |
Loading a serialized ``OutputAdapter`` or ``ConditionalRouter`` whose ``unsafe`` init parameter
is set to ``true`` now raises ``DeserializationError`` unless the pipeline is loaded in unsafe
mode. Pipelines that legitimately rely on an unsafe ``OutputAdapter``/``ConditionalRouter``
embedded in serialized data must now load with ``Pipeline.load(..., unsafe=True)`` (or
``Pipeline.loads`` / ``Pipeline.from_dict`` with ``unsafe=True``).
security:
- |
Fixed a remote code execution vulnerability that could be triggered by loading an untrusted
pipeline in default safe mode (``Pipeline.load`` / ``Pipeline.loads`` / ``Pipeline.from_dict``,
without ``unsafe=True``). A malicious pipeline could either (a) set ``unsafe: true`` on an
``OutputAdapter`` or ``ConditionalRouter`` to disable the Jinja sandbox entirely, or (b) register
the ``thread_safe_import`` import primitive as a Jinja ``custom_filters`` entry to import ``os``
and execute arbitrary commands — bypassing the deserialization allowlist and Jinja sandbox.
The fix denies import primitives during callable deserialization, refuses to
honor a component's ``unsafe`` flag while loading in safe mode, and hardens the Jinja sandbox
(``OutputAdapter``, ``ConditionalRouter``, ``PromptBuilder``, ``ChatPromptBuilder``) to block
attribute access on module objects and calls into dangerous modules.
@@ -6,11 +6,14 @@ import json
from typing import Any, Callable, List
import pytest
from jinja2.nativetypes import NativeEnvironment
from haystack import Pipeline, component
from haystack.components.converters import OutputAdapter
from haystack.components.converters.output_adapter import OutputAdaptationException
from haystack.core.component.sockets import InputSocket
from haystack.core.errors import DeserializationError
from haystack.core.serialization_security import _deserialization_context
from haystack.dataclasses import Document
@@ -217,6 +220,27 @@ class TestOutputAdapter:
res = adapter.run(documents=documents)
assert res["output"] == documents[0]
def test_from_dict_rejects_unsafe_in_safe_mode(self):
# A serialized component must not be able to disable its Jinja sandbox (`unsafe=True` swaps
# in a NativeEnvironment) on its own while the pipeline is being loaded in default safe mode.
data = {
"type": "haystack.components.converters.output_adapter.OutputAdapter",
"init_parameters": {"template": "{{ documents[0] }}", "output_type": "str", "unsafe": True},
}
with pytest.raises(DeserializationError, match="unsafe=True while loading in safe mode"):
OutputAdapter.from_dict(data)
def test_from_dict_allows_unsafe_when_loading_unsafe(self):
# When the loader explicitly opts into unsafe mode, the embedded `unsafe=True` is honored.
data = {
"type": "haystack.components.converters.output_adapter.OutputAdapter",
"init_parameters": {"template": "{{ documents[0] }}", "output_type": "str", "unsafe": True},
}
with _deserialization_context(unsafe=True):
adapter = OutputAdapter.from_dict(data)
assert adapter._unsafe
assert isinstance(adapter._env, NativeEnvironment)
def test_variables_correct_with_assignment(self) -> None:
template = """{% if control == 'something' %}
{% set output = 1 %}
@@ -12,6 +12,8 @@ from jinja2.nativetypes import NativeEnvironment
from haystack import Pipeline
from haystack.components.routers import ConditionalRouter
from haystack.components.routers.conditional_router import NoRouteSelectedException
from haystack.core.errors import DeserializationError
from haystack.core.serialization_security import _deserialization_context
from haystack.dataclasses import ChatMessage
@@ -416,6 +418,27 @@ class TestRouter:
res = router.run(streams=streams, message=message)
assert res == {"message": message}
def test_from_dict_rejects_unsafe_in_safe_mode(self):
# A serialized router must not be able to disable its Jinja sandbox (`unsafe=True` swaps in
# a NativeEnvironment) on its own while the pipeline is being loaded in default safe mode.
routes = [
{"condition": "{{streams|length < 2}}", "output": "{{query}}", "output_type": str, "output_name": "query"}
]
data = ConditionalRouter(routes, unsafe=True).to_dict()
with pytest.raises(DeserializationError, match="unsafe=True while loading in safe mode"):
ConditionalRouter.from_dict(data)
def test_from_dict_allows_unsafe_when_loading_unsafe(self):
# When the loader explicitly opts into unsafe mode, the embedded `unsafe=True` is honored.
routes = [
{"condition": "{{streams|length < 2}}", "output": "{{query}}", "output_type": str, "output_name": "query"}
]
data = ConditionalRouter(routes, unsafe=True).to_dict()
with _deserialization_context(unsafe=True):
router = ConditionalRouter.from_dict(data)
assert router._unsafe
assert isinstance(router._env, NativeEnvironment)
def test_validate_output_type_without_unsafe(self):
routes = [
{
+41
View File
@@ -8,6 +8,7 @@ import json
import operator
import subprocess
from collections.abc import Callable
from unittest import mock
import pytest
@@ -304,6 +305,46 @@ class TestDeniedBuiltins:
assert deserialize_callable("builtins.eval") is eval
class TestDeniedImportPrimitives:
"""
An import primitive that lives inside an allowlisted namespace slips through both the
module-granular allowlist (its `__module__` is allowlisted) and the builtin denylist (it is not
a `builtins` member). It is a functional twin of the denied builtin `__import__` — a gateway to
`os`/`subprocess`/... — and must be blocked.
"""
def test_thread_safe_import_rejected(self):
with pytest.raises(DeserializationError, match="import primitive"):
deserialize_callable("haystack.utils.type_serialization.thread_safe_import")
def test_unsafe_mode_bypasses_the_block(self):
from haystack.utils.type_serialization import thread_safe_import
with _deserialization_context(unsafe=True):
handle = "haystack.utils.type_serialization.thread_safe_import"
assert deserialize_callable(handle) is thread_safe_import
def test_pipeline_loads_rejects_import_filter_gadget(self):
# End-to-end through the default-safe API: an attacker registers the import primitive as an
# OutputAdapter custom filter and uses it in the template to import `os` and run a command.
# Loading must reject it before any code runs.
gadget_yaml = (
"components:\n"
" adapter:\n"
" type: haystack.components.converters.output_adapter.OutputAdapter\n"
" init_parameters:\n"
" template: \"{{ ('os' | imp).system('echo pwned') }}\"\n"
" output_type: str\n"
" custom_filters:\n"
' imp: "haystack.utils.type_serialization.thread_safe_import"\n'
"connections: []\n"
)
with mock.patch("os.system") as mocked_system:
with pytest.raises(DeserializationError):
Pipeline.loads(gadget_yaml)
assert not mocked_system.called
class TestModuleAttributeWalkBypass:
"""
The allowlist must be enforced against the module a handle
+44
View File
@@ -0,0 +1,44 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <[email protected]>
#
# SPDX-License-Identifier: Apache-2.0
import os
import jinja2
import pytest
from haystack.utils.jinja2_sandbox import HaystackSandboxedEnvironment
class TestHaystackSandboxedEnvironment:
def test_blocks_module_attribute_access(self):
# Reaching into a module object (e.g. os.system) is the final step of the reported escape.
env = HaystackSandboxedEnvironment()
with pytest.raises(jinja2.exceptions.SecurityError):
env.from_string("{{ mod.system('echo pwned') }}").render(mod=os)
def test_blocks_calling_dangerous_module_callable(self):
env = HaystackSandboxedEnvironment()
with pytest.raises(jinja2.exceptions.SecurityError):
env.from_string("{{ fn('echo pwned') }}").render(fn=os.system)
def test_blocks_calling_module_object(self):
env = HaystackSandboxedEnvironment()
with pytest.raises(jinja2.exceptions.SecurityError):
env.from_string("{{ mod() }}").render(mod=os)
def test_allows_builtin_string_methods(self):
# `builtins` is intentionally excluded from the callable blocklist so ordinary template
# operations keep working.
env = HaystackSandboxedEnvironment()
assert env.from_string("{{ name.upper() }}").render(name="hi") == "HI"
def test_allows_custom_filter(self):
# Filters are invoked directly by Jinja and are unaffected by the sandbox hardening.
env = HaystackSandboxedEnvironment()
env.filters["shout"] = lambda v: v.upper()
assert env.from_string("{{ name | shout }}").render(name="hi") == "HI"
def test_allows_object_data_access(self):
env = HaystackSandboxedEnvironment()
assert env.from_string("{{ doc['content'] }}").render(doc={"content": "hello"}) == "hello"