mirror of
https://github.com/deepset-ai/haystack.git
synced 2026-10-12 02:12:50 +00:00
fix: prevent RCE by hardening deserialization and Jinja sandbox (#12312)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user