diff --git a/haystack/utils/jinja2_sandbox.py b/haystack/utils/jinja2_sandbox.py index 696351a764..7faaeaa576 100644 --- a/haystack/utils/jinja2_sandbox.py +++ b/haystack/utils/jinja2_sandbox.py @@ -33,6 +33,16 @@ } ) +# Module prefixes whose callables must never be invocable from a template. +# +# Haystack's own data classes end up in the template context (e.g. `documents`, `messages`) and expose public +# methods that perform file I/O, make network requests or resolve secrets (`ByteStream.to_file`, +# `ByteStream.from_file_path`, `ImageContent.from_url`, `Secret.resolve_value`, ...). +_UNSAFE_CALLABLE_MODULE_PREFIXES: tuple[str, ...] = ("haystack.dataclasses", "haystack.utils.auth") + +# Side-effect-free methods of those data classes that templates may still call. +_SAFE_DATACLASS_METHOD_NAMES: frozenset[str] = frozenset({"to_dict", "is_from", "to_openai_dict_format"}) + class HaystackSandboxedEnvironment(SandboxedEnvironment): """ @@ -42,8 +52,10 @@ class HaystackSandboxedEnvironment(SandboxedEnvironment): - 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`). + - refuses to call module objects, refuses to call any callable whose defining module is rooted in + a dangerous standard-library module (see `_UNSAFE_MODULE_ROOTS`), and refuses to call any + callable defined in one of Haystack's own data-class modules (see `_UNSAFE_CALLABLE_MODULE_PREFIXES`) + except the side-effect-free methods in `_SAFE_DATACLASS_METHOD_NAMES`. 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 @@ -58,10 +70,16 @@ def is_safe_attribute(self, obj: Any, attr: str, value: Any) -> bool: 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.""" + """Reject calling module objects, dangerous-module callables, and Haystack data-class methods.""" if isinstance(obj, ModuleType): return False - root = (getattr(obj, "__module__", "") or "").split(".", 1)[0] + module = getattr(obj, "__module__", "") or "" + root = module.split(".", 1)[0] if root in _UNSAFE_MODULE_ROOTS: return False + if ( + module.startswith(_UNSAFE_CALLABLE_MODULE_PREFIXES) + and getattr(obj, "__name__", None) not in _SAFE_DATACLASS_METHOD_NAMES + ): + return False return super().is_safe_callable(obj) diff --git a/releasenotes/notes/harden-jinja-sandbox-dataclass-methods-c9e2843b9e56f49e.yaml b/releasenotes/notes/harden-jinja-sandbox-dataclass-methods-c9e2843b9e56f49e.yaml new file mode 100644 index 0000000000..83eb0dc5a2 --- /dev/null +++ b/releasenotes/notes/harden-jinja-sandbox-dataclass-methods-c9e2843b9e56f49e.yaml @@ -0,0 +1,20 @@ +--- +security: + - | + Fixed a Jinja sandbox escape in ``PromptBuilder``, ``ChatPromptBuilder``, ``OutputAdapter``, and + ``ConditionalRouter`` that allowed a caller-supplied template to write and read arbitrary files, and + to read arbitrary environment variables. ``HaystackSandboxedEnvironment`` previously only blocked + calls into a denylist of standard-library modules (``os``, ``subprocess``, ...), so calling public + methods of Haystack's own data classes placed in the template context, such as + ``Document.from_dict()``, ``ByteStream.to_file()``, ``ByteStream.from_file_path()``, and + ``Secret.resolve_value()``, was not restricted. A template like + ``{{ documents[0].from_dict({"blob": {...}}).blob.to_file("/some/path") }}`` could therefore write + or read files, or resolve secrets, as the process user. This is only exploitable when untrusted input + can control the template text itself, for example through the ``template`` run input of + ``PromptBuilder`` or ``ChatPromptBuilder``; values passed as template variables are never rendered as + templates. + + Templates rendered in the default (safe) mode can no longer call methods of objects defined in + ``haystack.dataclasses`` and ``haystack.utils.auth``, except ``to_dict()``, ``is_from()``, and + ``to_openai_dict_format()``. Attribute and property access (``doc.content``, ``doc.meta``, + ``message.text``) is unaffected. Templates rendered with ``unsafe=True`` are unaffected. diff --git a/test/utils/test_jinja2_sandbox.py b/test/utils/test_jinja2_sandbox.py index b289952379..fb6661d45d 100644 --- a/test/utils/test_jinja2_sandbox.py +++ b/test/utils/test_jinja2_sandbox.py @@ -7,6 +7,8 @@ import jinja2 import pytest +from haystack.dataclasses import ByteStream, ChatMessage, Document +from haystack.utils.auth import Secret from haystack.utils.jinja2_sandbox import HaystackSandboxedEnvironment @@ -42,3 +44,49 @@ def test_allows_custom_filter(self): def test_allows_object_data_access(self): env = HaystackSandboxedEnvironment() assert env.from_string("{{ doc['content'] }}").render(doc={"content": "hello"}) == "hello" + + def test_allows_document_attribute_access(self): + # Plain attribute access on a Haystack data class must keep working; only calling its + # methods is restricted. + env = HaystackSandboxedEnvironment() + doc = Document(content="hello", meta={"source": "handbook"}) + assert env.from_string("{{ doc.content }} {{ doc.meta.source }}").render(doc=doc) == "hello handbook" + + def test_allows_side_effect_free_dataclass_methods(self): + env = HaystackSandboxedEnvironment() + doc = Document(content="hello") + message = ChatMessage.from_user("hi") + template = ( + "{{ doc.to_dict()['content'] }} {{ message.is_from('user') }} {{ message.to_openai_dict_format()['role'] }}" + ) + assert env.from_string(template).render(doc=doc, message=message) == "hello True user" + + def test_blocks_document_from_dict_gadget(self, tmp_path): + # Document.from_dict()/ByteStream.to_file() let a template write an arbitrary file. + # The target path is passed as a template variable, not interpolated into the template + # text, since a Windows path contains backslashes that Jinja's string-literal lexer would + # otherwise try to parse as escape sequences (e.g. "\Users..." looks like a "\U" escape). + env = HaystackSandboxedEnvironment() + doc = Document(content="hello") + target = tmp_path / "pwned" + template = ( + '{{ doc.from_dict({"content": "x", "blob": {"data": [104, 105], "meta": {}}}).blob.to_file(target) }}' + ) + with pytest.raises(jinja2.exceptions.SecurityError): + env.from_string(template).render(doc=doc, target=str(target)) + assert not target.exists() + + def test_blocks_bytestream_from_file_path_gadget(self): + # ByteStream.from_file_path() lets a template read an arbitrary file into the rendered output. + env = HaystackSandboxedEnvironment() + stream = ByteStream(data=b"") + with pytest.raises(jinja2.exceptions.SecurityError): + env.from_string('{{ stream.from_file_path("/etc/passwd").to_string() }}').render(stream=stream) + + def test_blocks_secret_resolve_value_gadget(self, monkeypatch): + # Secret.resolve_value() lets a template read arbitrary environment variables. + monkeypatch.setenv("HAYSTACK_TEST_SECRET", "super-secret") + env = HaystackSandboxedEnvironment() + secret = Secret.from_env_var("HAYSTACK_TEST_SECRET") + with pytest.raises(jinja2.exceptions.SecurityError): + env.from_string("{{ secret.resolve_value() }}").render(secret=secret)